Merge origin/main into brennanb2025/fix-10385-false-connected

This commit is contained in:
Brennan Benson
2026-07-31 22:56:47 -07:00
259 changed files with 17149 additions and 2205 deletions
-10
View File
@@ -140,16 +140,6 @@ jobs:
- name: Windows daemon workspace-close repro
if: runner.os == 'Windows'
run: node config/scripts/windows-daemon-workspace-close-repro.mjs
- if: runner.os == 'Linux'
env:
ORCA_COMPUTER_E2E: '1'
ACCESSIBILITY_ENABLED: '1'
run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
- if: runner.os == 'Windows'
env:
ORCA_COMPUTER_E2E: '1'
run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts
mac-native-owner-smoke:
if: github.event_name == 'pull_request'
runs-on: macos-15
+63
View File
@@ -13,6 +13,10 @@ on:
description: Ref to check out (defaults to the calling workflow's ref)
required: false
type: string
test_files:
description: JSON array of changed specs; empty runs the full suite
required: false
type: string
workflow_dispatch:
inputs:
ref:
@@ -84,6 +88,7 @@ jobs:
e2e:
name: e2e ${{ matrix.shard_name }}
needs: build
if: inputs.test_files == ''
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
@@ -183,9 +188,67 @@ jobs:
retention-days: 7
if-no-files-found: ignore
changed-e2e:
name: changed e2e specs
needs: build
if: inputs.test_files != ''
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
- 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
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download E2E build output
uses: actions/download-artifact@v8
with:
name: e2e-build-out
path: out/
- name: Run changed E2E specs
env:
TEST_FILES_JSON: ${{ inputs.test_files }}
run: |
mapfile -t TEST_FILES < <(jq -r '.[]' <<<"$TEST_FILES_JSON")
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 \
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-traces-changed
path: test-results/
retention-days: 7
if-no-files-found: ignore
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
needs: build
if: inputs.test_files == ''
runs-on: ubuntu-latest
# Why 35: the terminal parking + retention specs below add two more
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
+1 -15
View File
@@ -1,18 +1,6 @@
name: Golden E2E Experiment
on:
pull_request:
paths:
- '.github/workflows/golden-e2e-experiment.yml'
- 'package.json'
- 'tests/e2e/golden-core-flows.spec.ts'
- 'tests/e2e/fixtures/terminal-emoji-table.md'
- 'tests/e2e/helpers/**'
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
- 'src/renderer/src/lib/pane-manager/**'
workflow_dispatch:
inputs:
ref:
@@ -44,9 +32,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: pull_request merge refs disappear after merge, but experiment
# reruns still need a stable commit to checkout.
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }}
ref: ${{ inputs.ref || github.ref }}
- name: Install native build tools
if: runner.os == 'Linux'
+41 -4
View File
@@ -170,7 +170,37 @@ jobs:
- name: Compute hourly version
id: hourly
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/hourly-build-version.mjs >>"$GITHUB_OUTPUT"
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
# Why read the highest number off existing titles rather than counting
# releases: the prune step trims to HOURLY_RETAIN_COUNT, so a count would
# roll backwards after three days and reissue numbers already in use. The
# maximum is always among the retained builds, because pruning drops the
# oldest. `capture(...)?` swallows the non-match on legacy titles, which
# were the raw tag, so the series simply starts at 01.
#
# Why drafts count here but not in the freshness check: that check asks
# "did this commit ship", where a draft is a no. This one asks "is the
# number free", where a stranded draft still holds one.
last_number="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name,isDraft \
--jq '[.[] | (.name // "") | capture(" • (?<n>[0-9]+) • ")? | .n | tonumber] | max // 0')"
build_number=$(( last_number + 1 ))
echo "Hourly build number $build_number (previous high: $last_number)"
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
>"$RUNNER_TEMP/hourly-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
# an empty name and an untitled release — silent, and only visible once
# someone opens the releases page. Fail here instead.
if ! grep -q '^name=' "$RUNNER_TEMP/hourly-identity.txt"; then
echo "::error::hourly-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync."
exit 1
fi
cat "$RUNNER_TEMP/hourly-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
if: steps.freshness.outputs.should_build == 'true'
@@ -189,9 +219,12 @@ jobs:
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: v${{ steps.hourly.outputs.version }}
NAME: ${{ steps.hourly.outputs.name }}
SHA: ${{ steps.freshness.outputs.head_sha }}
run: |
set -euo pipefail
# Kept at 12 even though the title shows 7: the freshness check above
# parses this back out of the body to decide whether main has moved.
short_sha="${SHA:0:12}"
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
@@ -205,7 +238,7 @@ jobs:
# after the manifest is verified.
gh release create "$TAG" \
--repo "$HOURLY_REPO" \
--title "$TAG" \
--title "$NAME" \
--draft \
--notes "Automated hourly macOS dev build from commit \`$short_sha\`.
@@ -276,10 +309,14 @@ jobs:
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.hourly.outputs.name }}
run: |
set -euo pipefail
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease
echo "Published $TAG"
# --title again: electron-builder resolves this draft by tag and may
# rewrite its title on upload. Re-asserting here means the name the
# picker reads is the one composed above, whatever it did in between.
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease --title "$NAME"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.
@@ -1,20 +1,6 @@
name: Linux Wayland GPU Sandbox
on:
pull_request:
paths:
- .github/workflows/linux-wayland-gpu-sandbox.yml
- config/scripts/linux-wayland-renderer-diagnostics.mjs
- config/scripts/linux-wayland-terminal-exercise.mjs
- config/scripts/linux-wayland-validation-watchdog.mjs
- config/scripts/verify-linux-wayland-gpu-sandbox.mjs
- src/main/startup/configure-process.ts
- src/main/startup/configure-process.test.ts
- src/preload/api-types.ts
- src/preload/index.ts
- src/renderer/src/components/terminal-pane/pty-connection.ts
- src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts
- src/renderer/src/web/web-preload-api.ts
workflow_dispatch:
jobs:
@@ -85,34 +71,6 @@ jobs:
done
test -S "$XDG_RUNTIME_DIR/wayland-1"
- name: Reproduce terminal input freeze without the workaround
id: reproduce_base
if: github.event_name == 'pull_request'
# Why: still collect fixed-path Wayland evidence when the base repro
# stops reproducing; the final gate below fails the job in that case.
continue-on-error: true
timeout-minutes: 10
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.sha }}"
# Why: once the PR base includes this workaround, the partial
# checkout cannot reconstruct an unfixed Wayland negative control.
if git show "${{ github.event.pull_request.base.sha }}:src/main/startup/configure-process.ts" | grep -Eq "appendSwitch\\(['\"]disable-gpu-sandbox['\"]\\)"; then
echo "The base build already contains the Linux Wayland GPU sandbox workaround; skipping unfixed-path reproduction."
exit 0
fi
# Why: keep the new verifier scripts, but run them against the
# unfixed production terminal/GPU path instead of a hybrid checkout.
git checkout "${{ github.event.pull_request.base.sha }}" -- \
src/main/startup/configure-process.ts \
src/preload/api-types.ts \
src/preload/index.ts \
src/renderer/src/components/terminal-pane/pty-connection.ts \
src/renderer/src/lib/pane-manager/terminal-webgl-auto-policy.ts \
src/renderer/src/web/web-preload-api.ts
rm -rf out
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs --mode=expect-repro
- name: Verify terminal input under Wayland GPU sandbox workaround
if: ${{ !cancelled() }}
timeout-minutes: 10
@@ -122,12 +80,6 @@ jobs:
rm -rf out
node config/scripts/verify-linux-wayland-gpu-sandbox.mjs
- name: Require base reproduction
if: ${{ github.event_name == 'pull_request' && steps.reproduce_base.outcome != 'success' }}
run: |
echo "The unfixed base build did not reproduce the Wayland terminal input failure."
exit 1
- name: Upload Weston log
if: always()
uses: actions/upload-artifact@v7
+14 -15
View File
@@ -325,11 +325,10 @@ jobs:
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked
# Why: regression specs under tests/e2e/** used to merge green without ever
# running — e2e.yml only fired on schedule/release (#10518). Path-filter so
# ordinary PRs stay light; any E2E suite change still gets a full shard run.
# Why: PR E2E is advisory and only validates changed specs; scheduled and
# release runs retain full-suite coverage.
e2e-paths:
name: detect e2e path changes
name: detect changed e2e specs
runs-on: ubuntu-latest
if: github.event.pull_request.draft != true
# Why: detector only needs to read the checkout; do not inherit repo defaults.
@@ -337,6 +336,7 @@ jobs:
contents: read
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
test_files: ${{ steps.filter.outputs.test_files }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -344,25 +344,22 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Filter E2E-relevant paths
- name: Filter changed E2E specs
id: filter
run: |
set -euo pipefail
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
# Why: capture first so a failed git diff does not look like "no matches"
# (pipeline status in `if` is not aborted by set -e). Merge-base limits the
# list to files this PR introduced, not base-branch drift.
CHANGED="$(git diff --name-only --merge-base "$BASE" "$HEAD")"
# Why: tests/playwright.config.ts sits beside tests/e2e/, not inside it, so
# it needs its own pattern — a bare `playwright.` prefix matches no tracked
# file and would silently skip E2E when the runner config changes.
if printf '%s\n' "$CHANGED" | grep -E '^(tests/e2e/|tests/playwright\.|\.github/workflows/e2e\.yml$)' >/dev/null; then
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
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
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "E2E path changes detected"
echo "Changed E2E specs: $TEST_FILES_JSON"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
echo "No E2E path changes"
echo "No changed E2E specs"
fi
e2e:
@@ -373,6 +370,8 @@ jobs:
permissions:
contents: read
uses: ./.github/workflows/e2e.yml
with:
test_files: ${{ needs.e2e-paths.outputs.test_files }}
verify:
if: always()
-20
View File
@@ -1,26 +1,6 @@
name: Terminal IME E2E
on:
pull_request:
paths:
- '.github/workflows/terminal-ime-e2e.yml'
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
- 'tests/e2e/terminal-ime-*.ts'
workflow_dispatch:
schedule:
- cron: '30 9 * * *'
+1 -47
View File
@@ -6,56 +6,10 @@ name: Windows Crash-Survival E2E
# silent-installs it, opens a terminal, force-kills ONLY the app main (no
# tree-kill), and asserts the daemon + shell stay alive, a relaunch adopts the
# same daemon and shell, and post-crash input causes no pwsh FailFast. Targeted
# pull requests keep it as a durable regression gate. A CI runner is the only
# manual runs keep it as a durable regression proof. A CI runner is the only
# safe place to install — see the relocation post-mortem.
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- '.github/workflows/win-crash-survival-e2e.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'electron.vite.config.ts'
- 'build-plugins/**'
- 'config/electron-builder.config.cjs'
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'config/scripts/verify-packaged-plugin-resources.cjs'
- 'native/**'
# Byte-hashed at package time, and CRLF-sensitive on Windows.
- '.gitattributes'
- 'resources/plugins/**'
- 'resources/win32/**'
- 'src/main/daemon/**'
- 'src/main/index.ts'
- 'src/main/ipc/pty*.ts'
- 'src/main/persistence.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/main/startup/first-window-startup-services.ts'
- 'src/main/window/attach-main-window-services.ts'
- 'src/preload/**'
- 'src/renderer/src/App.tsx'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/hooks/useIpcEvents.ts'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/renderer/src/lib/session-write-subscriber.ts'
- 'src/renderer/src/lib/workspace-session-host-persistence.ts'
- 'src/renderer/src/store/slices/terminals.ts'
- 'src/shared/pty-session-id-format.ts'
- 'tools/win-crash-survival-e2e/**'
- 'tools/win-update-e2e/**'
# Why: source tests and benchmarks do not change the packaged artifact;
# their own verify jobs cover them without spending a Windows build slot.
- '!src/**/*.test.*'
- '!src/**/*.bench.*'
workflow_dispatch:
inputs:
expect:
@@ -1,41 +1,6 @@
name: Windows terminal restart E2E
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- '.github/workflows/windows-terminal-restart-e2e.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'electron.vite.config.ts'
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'tests/playwright.config.ts'
- 'tests/e2e/global-setup.ts'
- 'tests/e2e/global-teardown.ts'
- 'tests/e2e/helpers/**'
- 'tests/e2e/restart-restore-terminal-input.spec.ts'
- 'tests/e2e/restored-terminal-input-readiness.unit.test.ts'
- 'tests/e2e/terminal-probe-input-sequence.ts'
- 'tests/e2e/terminal-probe-input-sequence.unit.test.ts'
- 'tests/e2e/terminal-restart-persistence.spec.ts'
- 'src/main/daemon/**'
- 'src/main/ipc/pty*.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/main/pty-descendant-termination.ts'
- 'src/main/windows-process-tree-kill.ts'
- 'src/main/windows-pty-root-identity.ts'
- 'src/preload/**'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/shared/process-table-snapshot.ts'
- 'src/shared/pty-session-id-format.ts'
workflow_dispatch:
inputs:
ref:
+151 -1
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-30",
"updatedAt": "2026-07-31",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -10,6 +10,156 @@
}
},
"gates": [
{
"id": "ssh-relay.staged-upload-recovery",
"title": "SSH relay uploads remain retryable before the shared install lock",
"maturity": "experimental",
"protection": "partial",
"owner": "ssh-relay-install",
"layer": "ssh-transfer-install-contract",
"surfaces": [
"SSH relay first install",
"split shell and SFTP namespaces",
"system SSH transfer fallback",
"relay install retry after cancellation"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["ssh2", "system-ssh"],
"coveredPlatforms": ["macos", "linux"],
"coveredProviders": ["ssh2", "system-ssh"],
"coverageNotes": "Deterministic unit, exact POSIX shell, native ARM macOS PowerShell 7.6.4, and real ssh2 SFTP-wire tests cover lock ordering, concurrent-install loss, fixed-slot ownership identity, payload-only promotion, bounded stale-stage reclamation, installed-fast-path draining, joined cancellation teardown, cross-version isolation, split-SFTP redirection, and system-SSH bypass. A throwaway linux-arm64 Docker sshd reached through a non-loopback LAN address covers live bytes-in-flight SFTP cancellation, injected unconfirmed cancellation, immediate retry against a real Git repository, fixed-slot recovery behind unclaimable entries, and real version-GC filtering with 15,197 unrelated names.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/9828",
"https://github.com/stablyai/orca/pull/10207"
],
"invariant": "A first-install relay transfer must complete in an attempt-owned fixed staging slot before acquiring the shared version install lock. Reservation, promotion, confirmed cleanup, and stale recovery must reject path replacement, persisted-identity mismatch, POSIX symlinks, and Windows reparse points. Recovery examines only eight fixed slot/claim/delete names and removes at most one stale valid stage per call; eight unclaimable states fail with an explicit manual-recovery message. Split-SFTP hosts must prove the stage identity on the exact transfer session, only payload contents may be promoted under the shared lock, and cancellation must boundedly join SFTP, stream, local file-handle, and transfer settlement.",
"oracle": "Pause a real ssh2 SFTP relay.js write after one remotely acknowledged chunk, prove the remote file is partial, abort the live transfer, and require no shared .install-lock, leaked local descriptor, or foreign-process termination. Separately inject two unconfirmed cancellations, require an independent deployment to install, launch, answer relay RPC, and read a real repository HEAD. Replace one retained fixed slot with an old-mtime same-owner directory while preserving the original, add a fixed-slot POSIX symlink, and require installed-path recovery to skip both while reclaiming a valid stale slot behind them. Add 15,197 unrelated relay-shaped names and run the real version GC, requiring bounded stdout and no removal. Unit and wire contracts cover exact POSIX and native PowerShell 0/1/7/8/9+ quota behavior, no-follow identity fencing, payload symlink/reparse rejection, one-item repeated draining, zero lock acquisition before upload settlement, joined transfer/channel teardown including never-settling failures, SFTP redirection, package.json namespace ownership, promotion only after the lock, cross-version isolation, and system-SSH behavior.",
"commands": [
"node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell <PowerShell-7.6.4-executable> src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-deploy-staged-upload.test.ts src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts src/main/ssh/ssh-relay-sftp-namespace-install.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/ssh-relay-upload-stage-commands.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts src/main/ssh/ssh-relay-cross-version-isolation.test.ts",
"ORCA_REVIEW_SSH_UPLOAD_CANCEL=1 ORCA_REVIEW_SSH_TARGET_HOST=<non-loopback-host> ORCA_REVIEW_SSH_IMAGE=<throwaway-sshd-image> ORCA_REVIEW_EXPECT_RECOVERY=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-upload-cancel.docker.test.ts --maxWorkers=1 --reporter=verbose"
],
"testFiles": [
"src/main/ssh/sftp-upload.test.ts",
"src/main/ssh/ssh-file-transfer-abort.test.ts",
"src/main/ssh/ssh-relay-deploy-staged-upload.test.ts",
"src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts",
"src/main/ssh/ssh-relay-sftp-namespace-install.test.ts",
"src/main/ssh/ssh-relay-install-namespace.test.ts",
"src/main/ssh/ssh-relay-upload-stage-commands.test.ts",
"src/main/ssh/sftp-namespace-resolution.test.ts",
"src/main/ssh/ssh-connection-sftp-wire.test.ts",
"src/main/ssh/ssh-remote-commands.test.ts",
"src/main/ssh/ssh-relay-cross-version-isolation.test.ts",
"src/main/ssh/ssh-relay-upload-cancel.docker.test.ts"
],
"assertionRefs": [
{
"file": "src/main/ssh/sftp-upload.test.ts",
"assertions": ["joins local file-descriptor teardown when a live upload is aborted"]
},
{
"file": "src/main/ssh/ssh-file-transfer-abort.test.ts",
"assertions": [
"joins confirmed SFTP close and transfer teardown before rejecting an abort",
"marks transfer teardown unconfirmed when close wins but the transfer never settles"
]
},
{
"file": "src/main/ssh/ssh-relay-upload-stage-commands.test.ts",
"assertions": [
"bounds reservation at 0, 1, 7, 8, and 9+ entries on POSIX and native PowerShell",
"rejects same-path replacement, symlink, reparse, and identity substitution before promotion or deletion",
"reclaims at most one valid stale fixed slot and progresses across repeated deployments"
]
},
{
"file": "src/main/ssh/ssh-relay-deploy-staged-upload.test.ts",
"assertions": [
"waits for a deferred SFTP upload before acquiring the install lock",
"drops only its stage when a sibling finishes before the locked re-probe",
"recovers one fixed stale stage before a fresh upload",
"launches before bounded installed-path recovery",
"never enumerates arbitrary stage paths during installation",
"retries immediately after an unconfirmed upload termination instead of waiting on a fresh install lock"
]
},
{
"file": "src/main/ssh/ssh-remote-commands.test.ts",
"assertions": [
"uses encoded PowerShell for Windows deploy commands",
"enumerates Windows staging children before copying",
"lets only one PowerShell caller acquire a legacy-visible lock"
]
},
{
"file": "src/main/ssh/ssh-relay-cross-version-isolation.test.ts",
"assertions": ["a v2 deploy never references the v1 install dir or v1 socket path"]
},
{
"file": "src/main/ssh/ssh-relay-sftp-namespace-install.test.ts",
"assertions": [
"redirects every first-install artifact transfer while shell commands stay canonical",
"leaves system-SSH connections unmapped and unprobed"
]
},
{
"file": "src/main/ssh/ssh-relay-upload-cancel.docker.test.ts",
"assertions": [
"aborts a live SFTP upload after remote bytes arrive without creating the shared lock",
"recovers cancellation with bounded safe reclamation and bounded real version GC"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-31",
"runner": "local",
"platform": "macos",
"command": "node config/scripts/run-ssh-staged-upload-reliability.mjs --powershell <PowerShell-7.6.4-executable> src/main/ssh/sftp-upload.test.ts src/main/ssh/ssh-file-transfer-abort.test.ts src/main/ssh/ssh-relay-deploy-staged-upload.test.ts src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts src/main/ssh/ssh-relay-sftp-namespace-install.test.ts src/main/ssh/ssh-relay-install-namespace.test.ts src/main/ssh/ssh-relay-upload-stage-commands.test.ts src/main/ssh/sftp-namespace-resolution.test.ts src/main/ssh/ssh-connection-sftp-wire.test.ts src/main/ssh/ssh-remote-commands.test.ts src/main/ssh/ssh-relay-cross-version-isolation.test.ts",
"result": "passed",
"durationSeconds": 57.9,
"summary": "Eleven focused files passed 152 tests with 3 platform skips using exact POSIX sh, native ARM macOS PowerShell 7.6.4, real ssh2 split-SFTP wire sessions, fixed-slot identity races, bounded recovery, joined cancellation teardown, and cross-version isolation."
},
{
"date": "2026-07-31",
"runner": "local",
"platform": "linux",
"command": "ORCA_REVIEW_SSH_UPLOAD_CANCEL=1 ORCA_REVIEW_SSH_TARGET_HOST=<non-loopback-host> ORCA_REVIEW_SSH_IMAGE=<throwaway-sshd-image> ORCA_REVIEW_EXPECT_RECOVERY=1 pnpm exec vitest run --config config/vitest.config.ts src/main/ssh/ssh-relay-upload-cancel.docker.test.ts --maxWorkers=1 --reporter=verbose",
"result": "passed",
"durationSeconds": 12.58,
"summary": "A throwaway linux-arm64 Docker sshd acknowledged 65,536 of 837,401 relay.js bytes before live abort with no shared lock and preserved the foreign sleep sentinel. Two injected unconfirmed stages remained pre-lock; retry launched the relay, answered RPC, and read a real Git HEAD. Installed recovery preserved a same-owner identity-mismatched replacement, its original, a POSIX symlink, and its foreign target while draining one valid stale slot behind them. Real version GC retained all 15,197 unrelated names, emitted 25 bytes, and completed in 25 ms."
}
],
"runtimeBudget": {
"p95Seconds": 35,
"scope": "focused unit, SFTP-wire, and local Docker SSH contracts"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Focused deterministic and Docker runs pass locally; CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "A byte-identical local Docker oracle run on latest main left a fresh shared .install-lock and blocked retry; the staged candidate left no shared lock and recovered immediately, and disabling staged ordering restored the blocked result. No committed baseline artifact or baseline installed-fast-path cleanup oracle is retained, so this evidence is intentionally not marked complete."
},
"performanceBudget": {
"required": true,
"evidence": "Stage recovery examines only eight fixed slot/claim/delete paths and reclaims at most one stale valid stage per invocation; installed reconnects launch before asynchronous recovery. Full quota produces an explicit error instead of unbounded cleanup. Version GC still scans the relay base directory, but remote filtering caps stdout and local candidate work at 64. Cancellation adds one bounded five-second join of channel and transfer settlement."
},
"promotionCriteria": [
"Collect 100 consecutive CI passes or 14 days of soak history.",
"Run live first-install cancellation and retry on Windows OpenSSH and a split-SFTP Synology-class host.",
"Keep exact lock-order, no-follow identity, promotion, bounded reclamation, and teardown assertions in the gate command."
],
"knownGaps": [
"The live Docker target is Linux ARM64 with a unified namespace; split-SFTP behavior is covered by real ssh2 wire and deterministic deploy fixtures.",
"Native PowerShell coverage runs on ARM macOS with POSIX filesystem paths; Windows OpenSSH, Windows PowerShell 5.1, and system-SSH behavior remain command and transfer-contract coverage rather than a live target.",
"The fixed pool retains up to eight relay bundles; eight foreign or otherwise unclaimable fixed states require manual inspection instead of automatic deletion.",
"Version GC remotely filters and caps output but still scans the base .orca-remote directory; it does not promise constant remote enumeration time.",
"The Docker oracle is opt-in because it requires a local image and a reachable non-loopback host address."
],
"demotionRule": "Demote or quarantine if cancellation creates the shared install lock before transfer settlement, a split-SFTP transfer loses identity proof, recovery deletes a replacement/symlink/reparse/foreign stage, fixed-path work exceeds its eight-slot bound, cancellation leaks a local descriptor, or the focused gate flakes without a product or harness bug."
},
{
"id": "mobile-ui.drawer-close-continuity",
"title": "Mobile drawers finish closing despite parent rerenders",
+5 -11
View File
@@ -225,20 +225,15 @@ describe('computer-use e2e workflow', () => {
)
})
it('runs Linux computer-use e2e in the PR native-smoke job under Xvfb', () => {
it('does not run computer-use e2e in PR smoke jobs', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const installRun = nativeSmokeRuns.find((run) => run.includes('apt-get install'))
expect(installRun).toContain('gedit')
expect(installRun).toContain('xvfb')
expect(nativeSmokeRuns).toContain(
'xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts'
)
expect(nativeSmokeRuns.join('\n')).not.toContain('test:e2e:computer')
})
it('builds Electron main output before every computer-use e2e run', () => {
@@ -267,7 +262,7 @@ describe('computer-use e2e workflow', () => {
}
})
it('runs core Windows computer-use e2e in the PR native-smoke job', () => {
it('keeps computer-use e2e in scheduled jobs only', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
@@ -283,9 +278,8 @@ describe('computer-use e2e workflow', () => {
.filter((run) => typeof run === 'string')
]
expect(nativeSmokeRuns).toContain(
'pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts'
)
expect(nativeSmokeRuns.join('\n')).not.toContain('test:e2e:computer')
expect(allRuns.join('\n')).toContain('test:e2e:computer')
expect(allRuns.join('\n')).not.toContain('test:e2e:computer -- --reporter')
})
+47 -4
View File
@@ -28,16 +28,59 @@ export function createHourlyBuildVersion(baseVersion, date) {
return `${match[1]}-hourly.${stamp}`
}
export function getHourlyBuildIdentity(now = new Date()) {
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
/**
* `1.4.163 • 01 • 07-31 13:54 • e698241` — the human-facing release title, shown
* verbatim in both the GitHub releases list and the in-app build picker.
*
* Why Pacific while the tag's stamp stays UTC: the stamp is a sort key, and a
* local one would repeat an hour at every DST fall-back, making two distinct
* builds compare equal. The title is only ever read, so it uses the timezone the
* people reading it are in. The two therefore disagree by the current offset.
*/
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
throw new Error(`Hourly build number must be a positive integer: ${buildNumber}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Hourly build timestamp is invalid.')
}
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: RELEASE_NAME_TIME_ZONE,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
hourCycle: 'h23'
})
.formatToParts(date)
.map((part) => [part.type, part.value])
)
return [
version.split('-')[0],
String(buildNumber).padStart(2, '0'),
`${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`,
commit.slice(0, 7)
].join(' • ')
}
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
return { commit, version: createHourlyBuildVersion(packageJson.version, now) }
const version = createHourlyBuildVersion(packageJson.version, now)
return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getHourlyBuildIdentity()
const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
const identity = getHourlyBuildIdentity(new Date(), buildNumber)
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(`version=${identity.version}\ncommit=${identity.commit}\n`)
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`
)
}
+43 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { createHourlyBuildVersion } from './hourly-build-version.mjs'
import { createHourlyBuildVersion, formatHourlyReleaseName } from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createHourlyBuildVersion', () => {
@@ -28,3 +28,45 @@ describe('createHourlyBuildVersion', () => {
expect(() => createHourlyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
describe('formatHourlyReleaseName', () => {
const name = (iso, buildNumber = 1, commit = 'e698241abcde') =>
formatHourlyReleaseName('1.4.163-hourly.x', buildNumber, commit, new Date(iso))
it('renders version, number, Pacific timestamp, and short sha', () => {
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • 07-31 13:54 • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • 01-14 18:30 • e698241')
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • 07-31 00:00 • e698241')
})
// Why: hour12: false renders midnight as "24" on some ICU builds, which would
// read as an hour that does not exist and sort oddly beside 00:xx.
it('renders midnight as 00, never 24', () => {
expect(name('2026-07-31T07:00:00Z')).toContain(' 00:00 ')
})
it('pads to two digits and grows past them', () => {
expect(name('2026-07-31T20:54:00Z', 9)).toContain(' • 09 • ')
expect(name('2026-07-31T20:54:00Z', 42)).toContain(' • 42 • ')
expect(name('2026-07-31T20:54:00Z', 1234)).toContain(' • 1234 • ')
})
it('rejects a build number that is not a positive integer', () => {
expect(() => name('2026-07-31T20:54:00Z', 0)).toThrow(/positive integer/)
expect(() => name('2026-07-31T20:54:00Z', -1)).toThrow(/positive integer/)
expect(() => name('2026-07-31T20:54:00Z', 1.5)).toThrow(/positive integer/)
expect(() => name('2026-07-31T20:54:00Z', Number.NaN)).toThrow(/positive integer/)
})
it('rejects an invalid timestamp', () => {
expect(() => formatHourlyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow(
/invalid/
)
})
})
@@ -1,15 +1,21 @@
export const MACOS_TCC_KEY_OVERRIDES = {
'auto.hooks.useMacosTccPromptNotice.title': {
es: '¿Ves avisos de “Orca quiere acceder…”?',
ja: '「Orca がアクセスしようとしています…」という確認が表示されますか?',
ko: '“Orca에서 접근하려고 합니다…” 권한 요청이 표시되나요?',
zh: '看到“Orca 想要访问…”提示?'
},
'auto.hooks.useMacosTccPromptNotice.description': {
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。'
es: 'Los mensajes de permisos de macOS pueden aparecer cuando un agente o una herramienta de terminal que se ejecuta en Orca intenta acceder a archivos protegidos. Concede acceso total al disco en Ajustes para reducir estos avisos.',
ja: 'Orca で実行中のエージェントやターミナルツールが保護されたファイルにアクセスしようとすると、macOS の権限メッセージが表示されることがあります。これらの確認を減らすには、設定でフルディスクアクセスを許可してください。',
ko: 'Orca에서 실행 중인 에이전트나 터미널 도구가 보호된 파일에 접근하려고 하면 macOS 권한 메시지가 표시될 수 있습니다. 이러한 요청을 줄이려면 설정에서 전체 디스크 접근 권한을 허용하세요.',
zh: '当 Orca 中运行的代理或终端工具尝试访问受保护的文件时,macOS 可能会显示权限信息。请在“设置”中授予“完全磁盘访问权限”,以减少此类提示。'
},
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca para reducir esos avisos. Después, cierra y vuelve a abrir Orca.',
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca にこの権限を許可してください。その後、Orca を終了して再度開いてください。',
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca에 이 권한을 허용하세요. 그런 다음 Orca를 종료했다가 다시 여세요.',
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 授予此权限,以减少此类提示。然后退出并重新打开 Orca。'
},
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
ko: '전체 디스크 접근 권한',
@@ -487,7 +487,7 @@ describe('Electron runtime package contract', () => {
expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}')
})
it('keeps terminal rendering regressions in the fast golden E2E gate', () => {
it('keeps terminal rendering regressions in the manual golden E2E workflow', () => {
const packageScripts = packageJson.scripts
const goldenWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8')
@@ -511,7 +511,6 @@ describe('Electron runtime package contract', () => {
return steps.find((step) => step.name === `Run golden E2E tests on ${label}`)
})
const pullRequestPaths = goldenWorkflow.on.pull_request.paths
const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden']
const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence']
const releaseBuildNeeds = releaseWorkflow.jobs.build.needs
@@ -540,11 +539,8 @@ describe('Electron runtime package contract', () => {
for (const runStep of goldenRunSteps) {
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
}
expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
expect(pullRequestPaths).toContain('tests/e2e/terminal-webgl-atlas-budget.spec.ts')
expect(pullRequestPaths).toContain('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
expect(goldenWorkflow.on.pull_request).toBeUndefined()
expect(goldenWorkflow.on.workflow_dispatch).toBeDefined()
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
expect(releaseBuildNeeds).not.toContain('terminal-rendering-release-evidence')
expect(publishReleaseNeeds).toContain('terminal-rendering-golden')
+39 -9
View File
@@ -5,9 +5,10 @@ import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
(step) => step.name === 'Filter E2E-relevant paths'
(step) => step.name === 'Filter changed E2E specs'
)
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
@@ -25,7 +26,7 @@ describe('PR E2E gate contract', () => {
expect(verifyStep.run).not.toContain('$E2E')
})
it('runs E2E only when the detector says the PR touches E2E paths', () => {
it('passes only changed specs to the reusable E2E workflow', () => {
// Why: without this the job could lose its filter and run on every PR — the
// cost the path filter exists to avoid — while the gate assertions above
// stay green.
@@ -34,6 +35,10 @@ describe('PR E2E gate contract', () => {
expect(prWorkflow.jobs['e2e-paths'].outputs.should_run).toBe(
'${{ steps.filter.outputs.should_run }}'
)
expect(prWorkflow.jobs['e2e-paths'].outputs.test_files).toBe(
'${{ steps.filter.outputs.test_files }}'
)
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.e2e-paths.outputs.test_files }}')
})
it('enforces every job verify depends on', () => {
@@ -49,16 +54,41 @@ describe('PR E2E gate contract', () => {
}
})
it('matches the Playwright config where it actually lives', () => {
// Why: the config is tests/playwright.config.ts, beside tests/e2e/ rather
// than inside it. A bare `playwright.` prefix matches no tracked file, so
// editing the runner config would silently skip E2E.
expect(filterStep.run).toContain('tests/playwright\\.')
expect(filterStep.run).not.toMatch(/\(\^?\|\|]tests\/e2e\/\|playwright\\\./)
it('selects modified Playwright specs without running deleted tests', () => {
expect(filterStep.run).toContain('--diff-filter=AMCR')
expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'")
expect(filterStep.run).not.toContain('tests/playwright\\.')
})
it('uses one runner for changed specs and keeps full runs sharded', () => {
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
expect(e2eWorkflow.jobs['changed-e2e'].if).toBe("inputs.test_files != ''")
expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined()
expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toBe("inputs.test_files == ''")
const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find(
(step) => step.name === 'Run changed E2E specs'
)
expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}')
expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1')
})
it('keeps dedicated E2E workflows out of pull request CI', () => {
const dedicatedWorkflows = [
'golden-e2e-experiment.yml',
'linux-wayland-gpu-sandbox.yml',
'terminal-ime-e2e.yml',
'win-crash-survival-e2e.yml',
'windows-terminal-restart-e2e.yml'
]
for (const file of dedicatedWorkflows) {
const workflow = parse(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
expect(workflow.on.pull_request, file).toBeUndefined()
}
})
it('scopes detection to the PR range so base drift cannot false-trigger', () => {
expect(filterStep.run).toContain('git diff --name-only --merge-base "$BASE" "$HEAD"')
expect(filterStep.run).toContain('--merge-base "$BASE" "$HEAD"')
expect(filterStep.run).toContain('set -euo pipefail')
})
})
@@ -0,0 +1,81 @@
import { spawnSync } from 'node:child_process'
const defaultFiles = [
'src/main/ssh/sftp-upload.test.ts',
'src/main/ssh/ssh-file-transfer-abort.test.ts',
'src/main/ssh/ssh-relay-deploy-staged-upload.test.ts',
'src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts',
'src/main/ssh/ssh-relay-sftp-namespace-install.test.ts',
'src/main/ssh/ssh-relay-install-namespace.test.ts',
'src/main/ssh/ssh-relay-upload-stage-commands.test.ts',
'src/main/ssh/sftp-namespace-resolution.test.ts',
'src/main/ssh/ssh-connection-sftp-wire.test.ts',
'src/main/ssh/ssh-remote-commands.test.ts',
'src/main/ssh/ssh-relay-cross-version-isolation.test.ts'
]
const cliArguments = process.argv.slice(2)
const powerShellFlag = cliArguments.indexOf('--powershell')
const configuredPowerShell =
powerShellFlag >= 0 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE
if (powerShellFlag >= 0 && !configuredPowerShell) {
console.error('--powershell requires an executable path')
process.exit(2)
}
const powerShellExecutable = [
configuredPowerShell,
...(process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh'])
].find((candidate) => {
if (!candidate) {
return false
}
return (
spawnSync(
candidate,
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.ToString()'],
{ encoding: 'utf8' }
).status === 0
)
})
if (!powerShellExecutable) {
console.error('A native PowerShell executable is required')
process.exit(2)
}
const powerShellVersion = spawnSync(
powerShellExecutable,
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.ToString()'],
{ encoding: 'utf8' }
).stdout.trim()
console.log(`SSH staged-upload reliability: PowerShell ${powerShellVersion}`)
const requestedFiles = cliArguments.filter(
(_argument, index) => index !== powerShellFlag && index !== powerShellFlag + 1
)
const files = requestedFiles.length > 0 ? requestedFiles : defaultFiles
const result = spawnSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'exec',
'vitest',
'run',
'--config',
'config/vitest.config.ts',
...files,
'--maxWorkers=1',
'--reporter=dot'
],
{
cwd: process.cwd(),
env: {
...process.env,
ORCA_POWERSHELL_EXECUTABLE: powerShellExecutable
},
stdio: 'inherit'
}
)
if (result.error) {
console.error(result.error.message)
process.exit(1)
}
process.exit(result.status ?? 1)
@@ -10,24 +10,10 @@ describe('terminal IME e2e workflow', () => {
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
)
it('runs for xterm patch and terminal IME regression changes', () => {
expect(workflow.on.pull_request.paths).toEqual(
expect.arrayContaining([
'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
'src/renderer/src/components/terminal-pane/keyboard-handlers.ts',
'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx',
'src/renderer/src/components/terminal-pane/pty-connection.ts',
'src/renderer/src/components/terminal-pane/pty-connection.test.ts',
'src/renderer/src/components/terminal-pane/terminal-ime-*',
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts',
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts',
'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts',
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
'tests/e2e/terminal-ime-*.ts'
])
)
it('runs only on schedule or manual dispatch', () => {
expect(workflow.on.pull_request).toBeUndefined()
expect(workflow.on.workflow_dispatch).toBeNull()
expect(workflow.on.schedule).toEqual([{ cron: '30 9 * * *' }])
})
it('installs native IBus Hangul and X11 input tools', () => {
+3 -12
View File
@@ -13,20 +13,11 @@ import { closeApp, resolveElectronMainPid } from '../../tools/win-update-e2e/app
import { isPidAlive } from '../../tools/win-update-e2e/daemon-processes.mjs'
describe('win-crash-survival-e2e proof contracts', () => {
it('keeps the packaged proof wired as a targeted pull-request gate', () => {
it('keeps the packaged proof manually dispatchable without a PR trigger', () => {
const workflow = readFileSync('.github/workflows/win-crash-survival-e2e.yml', 'utf8')
expect(workflow).toMatch(/^ pull_request:/m)
expect(workflow).not.toMatch(/^ pull_request:/m)
expect(workflow).toMatch(/^ workflow_dispatch:/m)
expect(workflow).not.toMatch(/^ push:/m)
expect(workflow).toContain("- 'src/main/daemon/**'")
expect(workflow).toContain("- 'src/main/index.ts'")
expect(workflow).toContain("- 'src/main/ipc/pty*.ts'")
expect(workflow).toContain("- 'src/main/startup/first-window-startup-services.ts'")
expect(workflow).toContain("- 'src/main/window/attach-main-window-services.ts'")
expect(workflow).toContain("- 'src/preload/**'")
expect(workflow).toContain("- 'src/renderer/src/components/terminal-pane/**'")
expect(workflow).toContain("- 'src/renderer/src/store/slices/terminals.ts'")
expect(workflow).toContain("- '!src/**/*.test.*'")
expect(workflow).toContain("- '!src/**/*.bench.*'")
expect(workflow).toContain('--expect "$env:EXPECT"')
expect(workflow).toContain('exit $LASTEXITCODE')
expect(workflow).toContain("'!config/**/*.test.*'")
+25 -68
View File
@@ -2,17 +2,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import {
QrCode,
Settings,
ChevronRight,
Terminal,
Plus,
RefreshCw,
PowerOff,
Edit3,
ListTodo
} from 'lucide-react-native'
import { QrCode, Settings, ChevronRight, Terminal, Plus, ListTodo } from 'lucide-react-native'
import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
import {
type AccountsSnapshot,
@@ -49,7 +39,8 @@ import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { MobileHostCard } from '../src/components/MobileHostCard'
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
import { ActionSheetModal } from '../src/components/ActionSheetModal'
import { getHostListActionSheetActions } from '../src/host-list-action-sheet-actions'
import { ConfirmModal } from '../src/components/ConfirmModal'
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache'
@@ -59,7 +50,9 @@ import {
normalizeVisibleTaskProviders,
type TaskProvider
} from '../src/tasks/mobile-task-providers'
import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks'
import { useResponsiveLayout } from '../src/layout/responsive-layout'
import { createMobileSessionHref } from '../src/session/mobile-session-route'
function endpointLabel(endpoint: string): string {
try {
@@ -295,6 +288,7 @@ function repoColor(name: string): string {
export default function HomeScreen() {
const router = useRouter()
const openMobileTasks = useOpenMobileTasks()
const insets = useSafeAreaInsets()
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
@@ -549,7 +543,6 @@ export default function HomeScreen() {
}
}
// Why: key on host-id set + each client's identity so resubs fire when forceReconnect swaps a host's client, not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
allClients
.map((e) => `${e.hostId}:${clientKey(e.client)}`)
@@ -611,10 +604,9 @@ export default function HomeScreen() {
if (!primaryConnectedHost) {
return
}
const suffix = provider ? `?taskSource=${provider}` : ''
router.push(`/h/${primaryConnectedHost.id}/tasks${suffix}`)
openMobileTasks(primaryConnectedHost.id, provider)
},
[primaryConnectedHost, router]
[openMobileTasks, primaryConnectedHost]
)
const renderTaskHomeCard = () => (
<Pressable
@@ -818,7 +810,11 @@ export default function HomeScreen() {
style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]}
onPress={() =>
router.push(
`/h/${resumeWorktree.hostId}/session/${encodeURIComponent(resumeWorktree.worktree.worktreeId)}`
createMobileSessionHref({
hostId: resumeWorktree.hostId,
worktreeId: resumeWorktree.worktree.worktreeId,
name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo
})
)
}
>
@@ -974,57 +970,18 @@ export default function HomeScreen() {
visible={actionTarget != null}
title={actionTarget?.name}
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
actions={(() => {
const host = actionTarget
if (!host) {
return []
}
const state = hostStates[host.id] ?? 'connecting'
const isLive =
state === 'connected' ||
state === 'connecting' ||
state === 'handshaking' ||
state === 'reconnecting'
// Why: label "Connect" (not "Reconnect") when never connected this session, so the verb matches the action.
const hasEverConnected = (hostLastConnected[host.id] ?? null) != null
const items: ActionSheetAction[] = []
items.push({
label: hasEverConnected && isLive ? 'Reconnect' : 'Connect',
icon: RefreshCw,
onPress: () => {
setActionTarget(null)
void forceReconnectHost(host.id).catch(showForceReconnectError)
}
})
if (isLive) {
items.push({
label: 'Disconnect',
icon: PowerOff,
onPress: () => {
setActionTarget(null)
closeHostClient(host.id)
}
})
}
items.push({
label: 'Edit host',
icon: Edit3,
closeBeforePress: true,
onPress: () => {
setActionTarget(null)
navigateToMobileHostEdit(router, host.id)
}
})
items.push({
label: 'Remove',
destructive: true,
closeBeforePress: true,
onPress: () => {
setConfirmRemove(host)
}
})
return items
})()}
actions={getHostListActionSheetActions({
host: actionTarget,
state: actionTarget ? (hostStates[actionTarget.id] ?? 'connecting') : 'disconnected',
hasEverConnected: actionTarget
? (hostLastConnected[actionTarget.id] ?? null) != null
: false,
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId).catch(showForceReconnectError),
onDisconnect: closeHostClient,
onEdit: (hostId) => navigateToMobileHostEdit(router, hostId),
onRemove: setConfirmRemove
})}
onClose={() => setActionTarget(null)}
/>
@@ -21,8 +21,8 @@ function check(over: Partial<PRCheckDetail>): PRCheckDetail {
}
describe('checkOutcome', () => {
it('treats a completed null-conclusion check as pending, not failure', () => {
expect(checkOutcome(check({ status: 'completed', conclusion: null }))).toBe('pending')
it('treats a completed null-conclusion check as neutral, not failure', () => {
expect(checkOutcome(check({ status: 'completed', conclusion: null }))).toBe('neutral')
})
it('treats queued/in_progress as pending', () => {
expect(checkOutcome(check({ status: 'queued', conclusion: null }))).toBe('pending')
@@ -33,9 +33,12 @@ describe('checkOutcome', () => {
expect(checkOutcome(check({ conclusion: 'cancelled' }))).toBe('failure')
expect(checkOutcome(check({ conclusion: 'timed_out' }))).toBe('failure')
})
it('maps neutral/skipped to neutral (non-blocking)', () => {
it('maps a merge-blocking action_required gate to failure', () => {
expect(checkOutcome(check({ conclusion: 'action_required' }))).toBe('failure')
})
it('maps skipped to success and neutral to neutral (desktop parity)', () => {
expect(checkOutcome(check({ conclusion: 'skipped' }))).toBe('success')
expect(checkOutcome(check({ conclusion: 'neutral' }))).toBe('neutral')
expect(checkOutcome(check({ conclusion: 'skipped' }))).toBe('neutral')
})
})
@@ -112,7 +115,7 @@ describe('summarizePRChecks', () => {
it('reports a neutral-only set as neutral with a labeled count (not empty success)', () => {
const summary = summarizePRChecks([
check({ conclusion: 'neutral' }),
check({ conclusion: 'skipped' })
check({ conclusion: 'neutral' })
])
expect(summary).toMatchObject({
total: 2,
@@ -123,6 +126,15 @@ describe('summarizePRChecks', () => {
})
expect(summary.label).toBe('2 neutral')
})
it('counts skipped as passed so the sidebar matches desktop and the tasks grid', () => {
const summary = summarizePRChecks([
check({ conclusion: 'success' }),
check({ conclusion: 'success' }),
check({ conclusion: 'skipped' })
])
expect(summary).toMatchObject({ total: 3, passed: 3, outcome: 'success' })
expect(summary.label).toBe('3 passed')
})
})
describe('prCheckKey', () => {
@@ -1,4 +1,9 @@
import type { PRCheckDetail, PRState } from '../../../../src/shared/types'
import type { PRCheckDetail, PRState, ProviderCheckSummary } from '../../../../src/shared/types'
import {
classifyCheckOutcome,
summarizeProviderChecks,
type CheckOutcome as SharedCheckOutcome
} from '../../../../src/shared/provider-check-summary'
import { prStateToken } from '../pr-state-token'
// Pure presentation logic for the PR sidebar's checks + state badge. No React /
@@ -18,31 +23,18 @@ export type MobileStatusToken =
export type CheckOutcome = 'success' | 'pending' | 'failure' | 'neutral'
const FAILURE_CONCLUSIONS = new Set<PRCheckDetail['conclusion']>([
'failure',
'cancelled',
'timed_out'
])
const OUTCOME_BY_SHARED: Record<SharedCheckOutcome, CheckOutcome> = {
passed: 'success',
failed: 'failure',
pending: 'pending',
neutral: 'neutral'
}
const SUCCESS_CONCLUSIONS = new Set<PRCheckDetail['conclusion']>(['success'])
// Why: a check that is queued/in_progress, or completed with a null/`pending`
// conclusion, is still pending — never render it as a failure (U5 edge case).
// Why: delegate to the one shared classifier — a second copy here is what made mobile call a
// `skipped` check unresolved and an `action_required` gate pending while desktop called them
// green and red for the same PR.
export function checkOutcome(check: PRCheckDetail): CheckOutcome {
if (check.status !== 'completed') {
return 'pending'
}
if (check.conclusion === null || check.conclusion === 'pending') {
return 'pending'
}
if (FAILURE_CONCLUSIONS.has(check.conclusion)) {
return 'failure'
}
if (SUCCESS_CONCLUSIONS.has(check.conclusion)) {
return 'success'
}
// neutral / skipped are non-blocking — treat as neutral, not failure.
return 'neutral'
return OUTCOME_BY_SHARED[classifyCheckOutcome(check)]
}
// Sort order: failures first (most actionable), then pending, then success /
@@ -71,38 +63,21 @@ export type PRChecksSummary = {
label: string
}
const OUTCOME_BY_STATE: Record<ProviderCheckSummary['state'], CheckOutcome | 'none'> = {
success: 'success',
failure: 'failure',
pending: 'pending',
neutral: 'neutral',
none: 'none'
}
export function summarizePRChecks(checks: readonly PRCheckDetail[]): PRChecksSummary {
if (checks.length === 0) {
return { total: 0, passed: 0, pending: 0, failed: 0, outcome: 'none', label: 'No checks' }
}
let passed = 0
let pending = 0
let failed = 0
let neutral = 0
for (const check of checks) {
const outcome = checkOutcome(check)
if (outcome === 'failure') {
failed += 1
} else if (outcome === 'pending') {
pending += 1
} else if (outcome === 'success') {
passed += 1
} else {
neutral += 1
}
}
// Worst-case wins so a single failure colors the summary red even if others passed.
// A neutral-only set reads as neutral (not success) with a non-empty label.
const outcome: CheckOutcome | 'none' =
failed > 0
? 'failure'
: pending > 0
? 'pending'
: passed > 0
? 'success'
: neutral > 0
? 'neutral'
: 'none'
// Counts and the worst-case rollup come from the shared summarizer; only the label wording is mobile's.
const { total, passed, pending, failed, neutral, state } = summarizeProviderChecks(checks)
const outcome = OUTCOME_BY_STATE[state]
const parts: string[] = []
if (failed > 0) {
parts.push(`${failed} failing`)
@@ -117,7 +92,7 @@ export function summarizePRChecks(checks: readonly PRCheckDetail[]): PRChecksSum
parts.push(`${neutral} neutral`)
}
return {
total: checks.length,
total,
passed,
pending,
failed,
@@ -141,6 +116,8 @@ export function checkStatusLabel(check: PRCheckDetail): string {
return 'Cancelled'
case 'timed_out':
return 'Timed out'
case 'action_required':
return 'Action required'
case 'neutral':
return 'Neutral'
case 'skipped':
@@ -0,0 +1,12 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const homeSource = readFileSync(new URL('../app/index.tsx', import.meta.url), 'utf8')
describe('Home host edit navigation wiring', () => {
it('uses the cold-navigator-safe edit transition', () => {
expect(homeSource).toMatch(
/onEdit:\s*\(hostId\)\s*=>\s*navigateToMobileHostEdit\(router,\s*hostId\)/
)
})
})
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest'
import { getHostListActionSheetActions } from './host-list-action-sheet-actions'
import type { ConnectionState, HostProfile } from './transport/types'
vi.mock('lucide-react-native', () => ({
Edit3: vi.fn(),
PowerOff: vi.fn(),
RefreshCw: vi.fn()
}))
const HOST: HostProfile = {
id: 'host-1',
name: 'Host 1',
endpoint: 'ws://192.168.21.4:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 0
}
function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean } = {}) {
const spies = {
onDismiss: vi.fn(),
onReconnect: vi.fn(),
onDisconnect: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn()
}
const actions = getHostListActionSheetActions({
host: HOST,
state: overrides.state ?? 'connected',
hasEverConnected: overrides.hasEverConnected ?? true,
...spies
})
return { actions, spies }
}
describe('getHostListActionSheetActions', () => {
// Why: both open a second drawer. Presenting one while this sheet's native
// Modal is still up freezes the whole screen on iOS — issue #8791.
it.each(['Edit host', 'Remove'])('defers %s until the action sheet has closed', (label) => {
const { actions } = build()
expect(actions.find((action) => action.label === label)).toMatchObject({
closeBeforePress: true
})
})
it('leaves the in-place actions undeferred so they fire on tap', () => {
const { actions, spies } = build()
const disconnect = actions.find((action) => action.label === 'Disconnect')
expect(disconnect?.closeBeforePress).toBeUndefined()
disconnect?.onPress()
expect(spies.onDisconnect).toHaveBeenCalledWith(HOST.id)
expect(spies.onDismiss).toHaveBeenCalled()
})
it('hands Remove the whole host so the confirm sheet can name it', () => {
const { actions, spies } = build()
actions.find((action) => action.label === 'Remove')?.onPress()
expect(spies.onRemove).toHaveBeenCalledWith(HOST)
})
it('routes Edit host to the edit screen', () => {
const { actions, spies } = build()
actions.find((action) => action.label === 'Edit host')?.onPress()
expect(spies.onEdit).toHaveBeenCalledWith(HOST.id)
})
it('offers Disconnect only while the socket is live', () => {
expect(build({ state: 'reconnecting' }).actions.map((action) => action.label)).toEqual([
'Reconnect',
'Disconnect',
'Edit host',
'Remove'
])
expect(build({ state: 'disconnected' }).actions.map((action) => action.label)).toEqual([
'Connect',
'Edit host',
'Remove'
])
})
it('says Connect until the host has connected at least once this session', () => {
expect(build({ hasEverConnected: false }).actions[0]?.label).toBe('Connect')
expect(build({ hasEverConnected: true }).actions[0]?.label).toBe('Reconnect')
})
it('renders nothing without a target host', () => {
expect(
getHostListActionSheetActions({
host: null,
state: 'disconnected',
hasEverConnected: false,
onDismiss: vi.fn(),
onReconnect: vi.fn(),
onDisconnect: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn()
})
).toEqual([])
})
})
@@ -0,0 +1,68 @@
import { Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
import type { ActionSheetAction } from './components/ActionSheetModal'
import type { ConnectionState, HostProfile } from './transport/types'
/** Builds the home-screen host long-press menu. Edit and Remove open a second
* drawer, so both must defer until this sheet's native Modal has unmounted —
* presenting into a live one freezes the whole screen on iOS (issue #8791). */
export function getHostListActionSheetActions(args: {
host: HostProfile | null
state: ConnectionState
/** Label "Connect" (not "Reconnect") when never connected this session, so the verb matches the action. */
hasEverConnected: boolean
onDismiss: () => void
onReconnect: (hostId: string) => void
onDisconnect: (hostId: string) => void
onEdit: (hostId: string) => void
onRemove: (host: HostProfile) => void
}): ActionSheetAction[] {
const { host } = args
if (!host) {
return []
}
const isLive =
args.state === 'connected' ||
args.state === 'connecting' ||
args.state === 'handshaking' ||
args.state === 'reconnecting'
return [
{
label: args.hasEverConnected && isLive ? 'Reconnect' : 'Connect',
icon: RefreshCw,
onPress: () => {
args.onDismiss()
args.onReconnect(host.id)
}
},
...(isLive
? [
{
label: 'Disconnect',
icon: PowerOff,
onPress: () => {
args.onDismiss()
args.onDisconnect(host.id)
}
}
]
: []),
{
label: 'Edit host',
icon: Edit3,
closeBeforePress: true,
onPress: () => {
args.onDismiss()
args.onEdit(host.id)
}
},
{
label: 'Remove',
destructive: true,
closeBeforePress: true,
onPress: () => {
args.onRemove(host)
}
}
]
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { readRepoIdentity } from './github-pr-value-readers'
import { classifyCheckOutcome } from '../../../src/shared/provider-check-summary'
import { readCheckRunConclusion, readRepoIdentity } from './github-pr-value-readers'
describe('readRepoIdentity', () => {
it('parses a valid owner/repo identity', () => {
@@ -32,3 +33,23 @@ describe('readRepoIdentity', () => {
expect(readRepoIdentity({ owner: 'octo', repo: '' })).toBeUndefined()
})
})
describe('readCheckRunConclusion', () => {
it('keeps every conclusion the shared classifier can act on', () => {
for (const conclusion of ['success', 'failure', 'cancelled', 'timed_out', 'skipped']) {
expect(readCheckRunConclusion(conclusion)).toBe(conclusion)
}
})
// Why: dropping this made a merge-blocking approval gate render as a harmless pending check.
it('keeps action_required so it still classifies as a failure', () => {
const conclusion = readCheckRunConclusion('action_required')
expect(conclusion).toBe('action_required')
expect(classifyCheckOutcome({ status: 'completed', conclusion })).toBe('failed')
})
it('drops an unknown conclusion', () => {
expect(readCheckRunConclusion('wat')).toBeNull()
expect(readCheckRunConclusion(null)).toBeNull()
})
})
@@ -82,11 +82,14 @@ export function readCheckRunStatus(value: unknown): PRCheckDetail['status'] | nu
return value === 'queued' || value === 'in_progress' || value === 'completed' ? value : null
}
// Why: dropping `action_required` here rendered a merge-blocking approval gate as a pending
// check; the shared classifier counts it as a failure, so it must survive parsing.
export function readCheckRunConclusion(value: unknown): PRCheckDetail['conclusion'] {
return value === 'success' ||
value === 'failure' ||
value === 'cancelled' ||
value === 'timed_out' ||
value === 'action_required' ||
value === 'neutral' ||
value === 'skipped' ||
value === 'pending'
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { createMobileSessionHref } from './mobile-session-route'
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
describe('mobile session route', () => {
it('keeps dynamic route identities raw for Expo Router to encode', () => {
expect(
createMobileSessionHref({
hostId: 'host/one',
worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1',
name: 'Fix #1'
})
).toEqual({
pathname: '/h/[hostId]/session/[worktreeId]',
params: {
hostId: 'host/one',
worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1',
name: 'Fix #1'
}
})
})
it('routes the home Resume card through the typed dynamic href', () => {
const start = homeSource.indexOf('{/* ─── Resume card ─── */}')
const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start)
const resumeCard = homeSource.slice(start, end)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
expect(resumeCard).toContain('createMobileSessionHref({')
expect(resumeCard).not.toContain('encodeURIComponent(resumeWorktree.worktree.worktreeId)')
})
})
@@ -0,0 +1,17 @@
export type MobileSessionRouteParams = {
hostId: string
worktreeId: string
name?: string
}
export type MobileSessionHref = {
pathname: '/h/[hostId]/session/[worktreeId]'
params: MobileSessionRouteParams
}
export function createMobileSessionHref(params: MobileSessionRouteParams): MobileSessionHref {
return {
pathname: '/h/[hostId]/session/[worktreeId]',
params
}
}
@@ -13,6 +13,7 @@ export function MobileSourceControlCreatePrEntry({ action }: Props) {
return null
}
const enabled = !action.disabled
const copy = action.hint ?? action.label
return (
<View style={styles.createPrBlock}>
<Pressable
@@ -36,15 +37,17 @@ export function MobileSourceControlCreatePrEntry({ action }: Props) {
strokeWidth={2.2}
/>
)}
<Text style={[styles.createPrButtonText, !enabled && styles.createPrButtonTextDisabled]}>
{action.label}
<Text
style={[
styles.createPrButtonText,
!enabled && styles.createPrButtonTextDisabled,
action.hint && styles.createPrButtonHint
]}
numberOfLines={2}
>
{copy}
</Text>
</Pressable>
{action.hint ? (
<Text style={styles.createPrHint} numberOfLines={2}>
{action.hint}
</Text>
) : null}
</View>
)
}
@@ -4,7 +4,10 @@ import type {
HostedReviewCreationEligibility,
HostedReviewProvider
} from '../../../src/shared/hosted-review'
import { buildMobileCreatePrAction } from './mobile-create-pr-action'
import {
buildMobileCreatePrAction,
type MobileCreatePrEligibilityState
} from './mobile-create-pr-action'
function eligibility(
overrides: Partial<HostedReviewCreationEligibility> = {}
@@ -108,26 +111,21 @@ describe('buildMobileCreatePrAction', () => {
'unsupported_provider',
'detached_head',
null
])('hides %s', (blockedReason) => {
])('keeps a disabled status row for %s', (blockedReason) => {
const { descriptor } = action({
eligibility: eligibility({ canCreate: false, blockedReason })
})
expect(descriptor.visible).toBe(false)
expect(descriptor).toMatchObject({ visible: true, disabled: true })
expect(descriptor.hint).toBeTruthy()
})
it('hides cold loading, errors, and missing branches', () => {
const onCreatePr = vi.fn()
expect(
buildMobileCreatePrAction({
branch: 'feature',
eligibilityState: { kind: 'loading', eligibility: null },
busyAction: null,
onCreatePr
}).visible
).toBe(false)
expect(action({ eligibility: null }).descriptor.visible).toBe(false)
it('keeps an unavailable row after errors but hides missing branches', () => {
expect(action({ eligibility: null }).descriptor).toMatchObject({
visible: true,
disabled: true,
label: 'Review status unavailable'
})
expect(action({ branch: null }).descriptor.visible).toBe(false)
})
@@ -146,12 +144,16 @@ describe('buildMobileCreatePrAction', () => {
expect(onCreatePr).not.toHaveBeenCalled()
})
it('hides any provider that does not support hosted-review creation', () => {
it('keeps a disabled status row for providers without review creation', () => {
const { descriptor } = action({
eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider })
})
expect(descriptor.visible).toBe(false)
expect(descriptor).toMatchObject({
visible: true,
disabled: true,
label: 'Review creation unavailable for this provider'
})
})
it('keeps a creatable button visible but disabled while a newer eligibility loads', () => {
@@ -167,3 +169,76 @@ describe('buildMobileCreatePrAction', () => {
expect(descriptor).toMatchObject({ visible: true, disabled: true, loading: false })
})
})
// Issue #8411: the Create PR entry renders directly above the Stage All row and
// the changed-files list, so appearing late shifts the list down by
// createPrBlock marginTop (12) + createPrButton height (42) = 54pt while the
// user is already reading it. The row must keep its footprint across the cold
// eligibility fetch.
describe('cold-mount layout stability (issue #8411)', () => {
// useMobileHostedReviewEligibility's real cold-mount sequence for a creatable
// branch: no prior snapshot exists, so `loading` carries eligibility: null.
const coldMount: MobileCreatePrEligibilityState[] = [
{ kind: 'loading', eligibility: null },
{ kind: 'ready', eligibility: eligibility({ canCreate: true }) }
]
it('reserves the button row while the first eligibility request is in flight', () => {
const descriptors = coldMount.map((eligibilityState) =>
buildMobileCreatePrAction({
branch: 'feature',
eligibilityState,
busyAction: null,
onCreatePr: vi.fn()
})
)
const footprint = descriptors.map(({ visible }) => visible)
// On the buggy parent this was [false, true] -- the false -> true step is the jump.
expect(footprint).toEqual([true, true])
})
it.each([
[
'existing review',
{
kind: 'ready',
eligibility: eligibility({ canCreate: false, blockedReason: 'existing_review' })
} satisfies MobileCreatePrEligibilityState
],
[
'unsupported provider',
{
kind: 'ready',
eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider })
} satisfies MobileCreatePrEligibilityState
],
['eligibility error', { kind: 'error' } satisfies MobileCreatePrEligibilityState]
])('keeps the cold-load footprint when %s resolves', (_label, resolvedState) => {
const footprint = [coldMount[0], resolvedState].map((eligibilityState) => {
const descriptor = buildMobileCreatePrAction({
branch: 'feature',
eligibilityState,
busyAction: null,
onCreatePr: vi.fn()
})
return descriptor.visible
})
expect(footprint).toEqual([true, true])
})
it('does not offer a tappable action against unresolved eligibility', () => {
const descriptor = buildMobileCreatePrAction({
branch: 'feature',
eligibilityState: { kind: 'loading', eligibility: null },
busyAction: null,
onCreatePr: vi.fn()
})
// Reserving space must not make the placeholder actionable: there is no
// canCreate/pushFirst answer yet, so a tap has nothing correct to do.
expect(descriptor.disabled).toBe(true)
})
})
@@ -1,7 +1,4 @@
import type {
HostedReviewCreationBlockedReason,
HostedReviewCreationEligibility
} from '../../../src/shared/hosted-review'
import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
import { supportsHostedReviewCreation } from '../../../src/shared/hosted-review-creation-providers'
import { hostedReviewCopy } from './hosted-review-copy'
import { getMobilePrCreateBlockMessage } from './mobile-pr-create'
@@ -29,13 +26,6 @@ export type BuildMobileCreatePrActionArgs = {
onCreatePr: (pushFirst: boolean) => void
}
const HIDDEN_BLOCKED_REASONS = new Set<HostedReviewCreationBlockedReason>([
'detached_head',
'existing_review',
'unsupported_provider',
null
])
const BUSY_ACTIONS = new Set(['create-pr', 'push-create-pr'])
function hiddenAction(onPress: () => void): MobileCreatePrAction {
@@ -56,18 +46,42 @@ export function buildMobileCreatePrAction({
onCreatePr
}: BuildMobileCreatePrActionArgs): MobileCreatePrAction {
const noop = () => {}
if (!branch || eligibilityState.kind === 'idle' || eligibilityState.kind === 'error') {
if (!branch || eligibilityState.kind === 'idle') {
return hiddenAction(noop)
}
if (eligibilityState.kind === 'error') {
return {
visible: true,
label: 'Review status unavailable',
disabled: true,
loading: false,
pushFirst: false,
onPress: noop
}
}
const eligibility = eligibilityState.eligibility
if (!eligibility) {
return hiddenAction(noop)
return {
visible: true,
label: 'Checking review status…',
disabled: true,
loading: true,
pushFirst: false,
onPress: noop
}
}
// Why: mirror desktop's structural provider gate (supportsHostedReviewCreation)
// instead of relying on the host always emitting a hidden blockedReason for
// non-creatable providers like bitbucket.
if (!supportsHostedReviewCreation(eligibility.provider)) {
return hiddenAction(noop)
return {
visible: true,
label: 'Review creation unavailable for this provider',
disabled: true,
loading: false,
pushFirst: false,
onPress: noop
}
}
const copy = hostedReviewCopy(eligibility.provider)
const label = `Create ${copy.titleLabel}`
@@ -95,10 +109,6 @@ export function buildMobileCreatePrAction({
}
}
if (HIDDEN_BLOCKED_REASONS.has(eligibility.blockedReason)) {
return hiddenAction(noop)
}
const hint =
getMobilePrCreateBlockMessage({
provider: eligibility.provider,
@@ -52,14 +52,37 @@ describe('buildMobilePrChipSummary', () => {
expect(summary.stateLabel).toBe('Draft')
})
it('rolls up passed checks as passed/total', () => {
// Why: a skipped job is a deliberate "not applicable" — desktop and the tasks grid call this 3/3.
it('rolls up passed checks as passed/total, counting skipped as passed', () => {
const summary = buildMobilePrChipSummary(
ready(pr(), [check('success'), check('success'), check('skipped')])
)
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup).toEqual({ kind: 'passed', text: '2/3', token: 'statusGreen' })
expect(summary.rollup).toEqual({ kind: 'passed', text: '3/3', token: 'statusGreen' })
})
it('treats a merge-blocking action_required gate as failing, not passing', () => {
const summary = buildMobilePrChipSummary(
ready(pr(), [check('success'), check('action_required')])
)
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup).toEqual({ kind: 'failing', text: '1 failing', token: 'statusRed' })
})
it('distinguishes checks that resolved to nothing actionable from having no checks', () => {
const summary = buildMobilePrChipSummary(ready(pr(), [check('neutral')]))
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup).toEqual({
kind: 'none',
text: 'Unresolved checks',
token: 'textSecondary'
})
})
it('prefers failing over running and passing', () => {
@@ -1,8 +1,8 @@
import type { PRComment } from '../../../src/shared/types'
import type { PrSidebarState } from '../session/mobile-pr-sidebar-state'
import { summarizeProviderChecks } from '../../../src/shared/provider-check-summary'
import {
prStateBadge,
summarizePRChecks,
type MobileStatusToken
} from '../components/pr-sidebar/pr-checks-presentation'
@@ -90,7 +90,8 @@ function buildChipRollup(state: Extract<PrSidebarState, { kind: 'ready' }>): Mob
if (state.data.pr.mergeable === 'CONFLICTING') {
return { kind: 'conflict', text: 'Conflicts', token: 'statusAmber' }
}
const checks = summarizePRChecks(state.data.checks)
// Shared classifier so the chip, the Checks list and the tasks grid never disagree about the same PR.
const checks = summarizeProviderChecks(state.data.checks)
if (checks.failed > 0) {
return { kind: 'failing', text: `${checks.failed} failing`, token: 'statusRed' }
}
@@ -100,5 +101,10 @@ function buildChipRollup(state: Extract<PrSidebarState, { kind: 'ready' }>): Mob
if (checks.passed > 0) {
return { kind: 'passed', text: `${checks.passed}/${checks.total}`, token: 'statusGreen' }
}
return { kind: 'none', text: 'No checks', token: 'textSecondary' }
// Checks that exist but resolved to nothing actionable are not "no checks".
return {
kind: 'none',
text: checks.total === 0 ? 'No checks' : 'Unresolved checks',
token: 'textSecondary'
}
}
@@ -207,11 +207,10 @@ const baseStyles = StyleSheet.create({
fontWeight: '600'
},
createPrBlock: {
marginTop: spacing.md,
gap: spacing.xs
marginTop: spacing.md
},
createPrButton: {
minHeight: 42,
height: 42,
borderRadius: radii.button,
backgroundColor: colors.textPrimary,
alignItems: 'center',
@@ -236,10 +235,12 @@ const baseStyles = StyleSheet.create({
createPrButtonTextDisabled: {
color: colors.textSecondary
},
createPrHint: {
color: colors.textMuted,
createPrButtonHint: {
fontSize: typography.metaSize,
lineHeight: 16
fontWeight: '600',
lineHeight: 16,
textAlign: 'center',
flexShrink: 1
}
})
@@ -1,10 +1,48 @@
import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
import {
acceptsMobileHostedReviewEligibilityLoad,
buildMobileHostedReviewEligibilityLoadKey,
eligibilityStateAfterMobileHostedReviewError,
shouldFetchMobileHostedReviewEligibility
renderedMobileHostedReviewEligibilityState,
shouldFetchMobileHostedReviewEligibility,
useMobileHostedReviewEligibility,
type MobileHostedReviewEligibilityLoadKey,
type MobileHostedReviewEligibilityLoadSnapshot
} from './use-mobile-hosted-review-eligibility'
import type { MobileCreatePrEligibilityState } from './mobile-create-pr-action'
function eligibility(
overrides: Partial<HostedReviewCreationEligibility> = {}
): HostedReviewCreationEligibility {
return {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
defaultBaseRef: 'main',
title: 'feature',
body: '',
...overrides
}
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
function loadSnapshot(
key: MobileHostedReviewEligibilityLoadKey,
state: MobileCreatePrEligibilityState
): MobileHostedReviewEligibilityLoadSnapshot {
return { key, state }
}
describe('mobile hosted review eligibility loader core', () => {
it('does not fetch while disconnected or detached', () => {
@@ -24,8 +62,9 @@ describe('mobile hosted review eligibility loader core', () => {
).toBe(false)
})
it('accepts only the latest generation for the current worktree branch identity', () => {
it('builds distinct keys for different branches', () => {
const first = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature-a',
hasUpstream: true,
@@ -34,6 +73,7 @@ describe('mobile hosted review eligibility loader core', () => {
hasUncommittedChanges: false
})
const second = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature-b',
hasUpstream: true,
@@ -42,17 +82,281 @@ describe('mobile hosted review eligibility loader core', () => {
hasUncommittedChanges: false
})
expect(first.identity).not.toBe(second.identity)
expect(first.fetch).not.toBe(second.fetch)
})
it('scopes load identity to the paired host', () => {
const input = {
worktreeId: 'repo-1::/workspace',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: false
}
const local = buildMobileHostedReviewEligibilityLoadKey({ ...input, hostId: 'local' })
const ssh = buildMobileHostedReviewEligibilityLoadKey({ ...input, hostId: 'ssh-builder' })
expect(local.identity).not.toBe(ssh.identity)
expect(local.fetch).not.toBe(ssh.fetch)
})
it('renders a superseded same-identity snapshot as loading', () => {
const input = {
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0
}
const older = buildMobileHostedReviewEligibilityLoadKey({
...input,
hasUncommittedChanges: false
})
const newest = buildMobileHostedReviewEligibilityLoadKey({
...input,
hasUncommittedChanges: true
})
expect(older.identity).toBe(newest.identity)
expect(
acceptsMobileHostedReviewEligibilityLoad({
generation: 1,
currentGeneration: 2,
identity: first.identity,
currentIdentity: second.identity
renderedMobileHostedReviewEligibilityState({
snapshot: loadSnapshot(older, { kind: 'ready', eligibility: eligibility() }),
key: newest,
shouldFetch: true
})
).toBe(false)
).toMatchObject({ kind: 'loading', eligibility: eligibility() })
})
it('fails closed after errors', () => {
expect(eligibilityStateAfterMobileHostedReviewError()).toEqual({ kind: 'error' })
})
})
// #8411: what the hook returns is what paints. A fetch-imminent frame must not
// render as `idle` (hidden row) or the Create PR row pops in a frame later.
describe('rendered eligibility state', () => {
it('renders fetch-imminent idle as an in-flight load', () => {
const key = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: false
})
expect(
renderedMobileHostedReviewEligibilityState({
snapshot: loadSnapshot(key, { kind: 'idle' }),
key,
shouldFetch: true
})
).toEqual({ kind: 'loading', eligibility: null })
})
it('passes resolved and refetch states through untouched', () => {
const key = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: false
})
const ready = { kind: 'ready', eligibility: eligibility() } as const
const refetch = { kind: 'loading', eligibility: eligibility() } as const
const error = { kind: 'error' } as const
for (const state of [ready, refetch, error]) {
expect(
renderedMobileHostedReviewEligibilityState({
snapshot: loadSnapshot(key, state),
key,
shouldFetch: true
})
).toBe(state)
}
})
it('renders idle when a fetch is not possible, hiding stale snapshots in the same render', () => {
const key = buildMobileHostedReviewEligibilityLoadKey({
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: false
})
expect(
renderedMobileHostedReviewEligibilityState({
snapshot: loadSnapshot(key, { kind: 'ready', eligibility: eligibility() }),
key,
shouldFetch: false
})
).toEqual({ kind: 'idle' })
})
})
describe('eligibility request ordering', () => {
let renderer: ReactTestRenderer | null = null
let renderedState: MobileCreatePrEligibilityState = { kind: 'idle' }
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('does not let an older response overwrite the newest state', async () => {
type Response = { ok: true; result: HostedReviewCreationEligibility }
const requests: ReturnType<typeof deferred<Response>>[] = []
const client = {
sendRequest: vi.fn(() => {
const request = deferred<Response>()
requests.push(request)
return request.promise
})
}
const newest = eligibility({ canCreate: false, blockedReason: 'existing_review' })
const older = eligibility()
function Harness({ dirty }: { dirty: boolean }): null {
renderedState = useMobileHostedReviewEligibility({
client: client as never,
connState: 'connected',
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: dirty
})
return null
}
await act(async () => {
renderer = create(createElement(Harness, { dirty: false }))
await Promise.resolve()
})
await act(async () => {
renderer?.update(createElement(Harness, { dirty: true }))
await Promise.resolve()
})
expect(requests).toHaveLength(2)
await act(async () => {
requests[1]!.resolve({ ok: true, result: newest })
await Promise.resolve()
})
await act(async () => {
requests[0]!.resolve({ ok: true, result: older })
await Promise.resolve()
})
expect(renderedState).toMatchObject({ kind: 'ready', eligibility: newest })
})
it('disables a ready snapshot in the render that changes its fetch key', async () => {
type Response = { ok: true; result: HostedReviewCreationEligibility }
const requests: ReturnType<typeof deferred<Response>>[] = []
const client = {
sendRequest: vi.fn(() => {
const request = deferred<Response>()
requests.push(request)
return request.promise
})
}
const ready = eligibility()
function Harness({ dirty }: { dirty: boolean }): null {
renderedState = useMobileHostedReviewEligibility({
client: client as never,
connState: 'connected',
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: dirty
})
return null
}
await act(async () => {
renderer = create(createElement(Harness, { dirty: false }))
await Promise.resolve()
})
await act(async () => {
requests[0]!.resolve({ ok: true, result: ready })
await Promise.resolve()
})
expect(renderedState).toMatchObject({ kind: 'ready', eligibility: ready })
await act(async () => {
renderer?.update(createElement(Harness, { dirty: true }))
await Promise.resolve()
})
expect(renderedState).toMatchObject({ kind: 'loading', eligibility: ready })
})
it('invalidates a request when its hook instance unmounts', async () => {
type Response = { ok: true; result: HostedReviewCreationEligibility }
const requests: ReturnType<typeof deferred<Response>>[] = []
const client = {
sendRequest: vi.fn(() => {
const request = deferred<Response>()
requests.push(request)
return request.promise
})
}
const newest = eligibility({ canCreate: false, blockedReason: 'existing_review' })
function Harness(): null {
renderedState = useMobileHostedReviewEligibility({
client: client as never,
connState: 'connected',
hostId: 'host-1',
worktreeId: 'wt-1',
branch: 'feature',
hasUpstream: true,
ahead: 0,
behind: 0,
hasUncommittedChanges: false
})
return null
}
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
act(() => renderer?.unmount())
renderer = null
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
expect(requests).toHaveLength(2)
await act(async () => {
requests[1]!.resolve({ ok: true, result: newest })
await Promise.resolve()
})
await act(async () => {
requests[0]!.resolve({ ok: true, result: eligibility() })
await Promise.resolve()
})
expect(renderedState).toMatchObject({ kind: 'ready', eligibility: newest })
})
})
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -11,6 +11,7 @@ import type { MobileCreatePrEligibilityState } from './mobile-create-pr-action'
export type MobileHostedReviewEligibilityLoaderInput = {
client: RpcClient | null
connState: ConnectionState
hostId: string
worktreeId: string
branch: string | null | undefined
hasUpstream: boolean | undefined
@@ -24,13 +25,19 @@ export type MobileHostedReviewEligibilityLoadKey = {
fetch: string
}
export type MobileHostedReviewEligibilityLoadSnapshot = {
key: MobileHostedReviewEligibilityLoadKey
state: MobileCreatePrEligibilityState
}
export function buildMobileHostedReviewEligibilityLoadKey(
input: Omit<MobileHostedReviewEligibilityLoaderInput, 'client' | 'connState'>
): MobileHostedReviewEligibilityLoadKey {
const branch = input.branch ?? ''
return {
identity: `${input.worktreeId}\0${branch}`,
identity: `${input.hostId}\0${input.worktreeId}\0${branch}`,
fetch: [
input.hostId,
input.worktreeId,
branch,
String(input.hasUpstream ?? ''),
@@ -47,25 +54,42 @@ export function shouldFetchMobileHostedReviewEligibility(
return input.connState === 'connected' && input.client !== null && !!input.branch
}
export function acceptsMobileHostedReviewEligibilityLoad(args: {
generation: number
currentGeneration: number
identity: string
currentIdentity: string
}): boolean {
return args.generation === args.currentGeneration && args.identity === args.currentIdentity
}
export function eligibilityStateAfterMobileHostedReviewError(): MobileCreatePrEligibilityState {
return { kind: 'error' }
}
export function renderedMobileHostedReviewEligibilityState(args: {
snapshot: MobileHostedReviewEligibilityLoadSnapshot
key: MobileHostedReviewEligibilityLoadKey
shouldFetch: boolean
}): MobileCreatePrEligibilityState {
if (!args.shouldFetch) {
return { kind: 'idle' }
}
const { snapshot, key } = args
if (snapshot.key.identity !== key.identity) {
return { kind: 'loading', eligibility: null }
}
const { state } = snapshot
if (snapshot.key.fetch !== key.fetch) {
return {
kind: 'loading',
eligibility: state.kind === 'ready' || state.kind === 'loading' ? state.eligibility : null
}
}
if (state.kind === 'idle') {
return { kind: 'loading', eligibility: null }
}
return state
}
export function useMobileHostedReviewEligibility(
input: MobileHostedReviewEligibilityLoaderInput
): MobileCreatePrEligibilityState {
const {
client,
connState,
hostId,
worktreeId,
branch,
hasUpstream,
@@ -74,11 +98,8 @@ export function useMobileHostedReviewEligibility(
hasUncommittedChanges
} = input
const shouldFetch = shouldFetchMobileHostedReviewEligibility({ client, connState, branch })
const [state, setState] = useState<MobileCreatePrEligibilityState>({ kind: 'idle' })
const generationRef = useRef(0)
const currentIdentityRef = useRef('')
const lastResetIdentityRef = useRef('')
const key = buildMobileHostedReviewEligibilityLoadKey({
hostId,
worktreeId,
branch,
hasUpstream,
@@ -86,38 +107,35 @@ export function useMobileHostedReviewEligibility(
behind,
hasUncommittedChanges
})
if (lastResetIdentityRef.current !== key.identity) {
lastResetIdentityRef.current = key.identity
setState({ kind: 'idle' })
}
currentIdentityRef.current = key.identity
const [snapshot, setSnapshot] = useState<MobileHostedReviewEligibilityLoadSnapshot>({
key: { identity: '', fetch: '' },
state: { kind: 'idle' }
})
useEffect(() => {
const generation = generationRef.current + 1
generationRef.current = generation
const isCurrent = () =>
acceptsMobileHostedReviewEligibilityLoad({
generation,
currentGeneration: generationRef.current,
identity: key.identity,
currentIdentity: currentIdentityRef.current
})
let active = true
if (!shouldFetch) {
if (isCurrent()) {
setState({ kind: 'idle' })
setSnapshot({ key, state: { kind: 'idle' } })
return () => {
active = false
}
return
}
if (!client || !branch) {
return
return () => {
active = false
}
}
setState((prev) => ({
kind: 'loading',
eligibility: prev.kind === 'ready' ? prev.eligibility : null
}))
setSnapshot((previous) => {
const previousState = previous.state
const eligibility =
previous.key.identity === key.identity &&
(previousState.kind === 'ready' || previousState.kind === 'loading')
? previousState.eligibility
: null
return { key, state: { kind: 'loading', eligibility } }
})
const requestInput: MobileHostedReviewEligibilityInput = {
branch,
hasUncommittedChanges,
@@ -127,20 +145,23 @@ export function useMobileHostedReviewEligibility(
}
void fetchMobileHostedReviewEligibility(client, worktreeId, requestInput)
.then((eligibility: HostedReviewCreationEligibility | null) => {
if (!isCurrent()) {
if (!active) {
return
}
if (!eligibility) {
setState({ kind: 'error' })
setSnapshot({ key, state: eligibilityStateAfterMobileHostedReviewError() })
return
}
setState({ kind: 'ready', eligibility })
setSnapshot({ key, state: { kind: 'ready', eligibility } })
})
.catch(() => {
if (isCurrent()) {
setState(eligibilityStateAfterMobileHostedReviewError())
if (active) {
setSnapshot({ key, state: eligibilityStateAfterMobileHostedReviewError() })
}
})
return () => {
active = false
}
}, [
ahead,
behind,
@@ -149,6 +170,7 @@ export function useMobileHostedReviewEligibility(
connState,
hasUncommittedChanges,
hasUpstream,
hostId,
key.fetch,
key.identity,
shouldFetch,
@@ -159,5 +181,9 @@ export function useMobileHostedReviewEligibility(
// snapshot in the same render, before the effect posts `idle` — otherwise the
// Create PR button could stay enabled for one paint after the worktree is
// no longer fetchable.
return shouldFetch ? state : { kind: 'idle' }
return renderedMobileHostedReviewEligibilityState({
snapshot,
key,
shouldFetch
})
}
@@ -6,6 +6,7 @@ import type { MobileGitStatusResult } from './mobile-git-status'
type Params = {
client: Parameters<typeof useMobileHostedReviewEligibility>[0]['client']
connState: Parameters<typeof useMobileHostedReviewEligibility>[0]['connState']
hostId: string
worktreeId: string
status: MobileGitStatusResult | null
hasUncommittedChanges: boolean
@@ -16,6 +17,7 @@ type Params = {
export function useMobileSourceControlCreatePrAction({
client,
connState,
hostId,
worktreeId,
status,
hasUncommittedChanges,
@@ -26,6 +28,7 @@ export function useMobileSourceControlCreatePrAction({
const eligibilityState = useMobileHostedReviewEligibility({
client,
connState,
hostId,
worktreeId,
branch: status?.branch,
hasUpstream: upstream?.hasUpstream,
@@ -209,6 +209,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
const createPrAction = useMobileSourceControlCreatePrAction({
client,
connState,
hostId,
worktreeId,
status,
hasUncommittedChanges: entries.length > 0,
@@ -0,0 +1,206 @@
import { describe, expect, it, vi } from 'vitest'
import {
coordinateMobileTasksNavigation,
mobileTasksHostRoute,
navigateToMobileTasks,
type MobileTasksNavigationState
} from './mobile-task-navigation'
function navigationHarness(initialState: MobileTasksNavigationState) {
let stateListener = () => {}
let state = initialState
const unsubscribeState = vi.fn()
const navigation = {
addListener: vi.fn((_event: 'state', listener: () => void) => {
stateListener = listener
return unsubscribeState
}),
dispatch: vi.fn(),
getState: () => state
}
return {
navigation,
setState(nextState: MobileTasksNavigationState) {
state = nextState
stateListener()
},
unsubscribeState
}
}
describe('mobile task navigation', () => {
it('waits for the expected host commit before navigating to Tasks', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
navigateToMobileTasks(harness.navigation, { push }, 'host/1')
expect(harness.navigation.addListener.mock.invocationCallOrder[0]).toBeLessThan(
push.mock.invocationCallOrder[0]!
)
expect(push).toHaveBeenCalledWith(mobileTasksHostRoute('host/1'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState({
index: 1,
routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }]
})
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState({
index: 1,
routes: [
{ name: 'index' },
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host/1' } }]
}
}
]
})
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(harness.unsubscribeState.mock.invocationCallOrder[0]).toBeLessThan(
harness.navigation.dispatch.mock.invocationCallOrder[0]!
)
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
type: 'REPLACE',
target: '/h',
source: 'host-index',
payload: { name: '[hostId]/tasks', params: { hostId: 'host/1' } }
})
})
it('ignores unrelated state events and preserves the provider', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1', 'linear')
harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'settings' }] })
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState({
index: 0,
routes: [
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host-1' } }]
}
}
]
})
expect(harness.navigation.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
name: '[hostId]/tasks',
params: { hostId: 'host-1', taskSource: 'linear' }
}
})
)
})
it('cleanup prevents a stale navigation from replacing', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const controller = navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1')
controller.cancel()
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
})
it('reuses a pending host push and applies the latest provider', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
const first = coordinateMobileTasksNavigation(
null,
harness.navigation,
{ push },
'host-1',
'github'
)
const second = coordinateMobileTasksNavigation(
first,
harness.navigation,
{ push },
'host-1',
'linear'
)
harness.setState({
index: 0,
routes: [
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host-1' } }]
}
}
]
})
expect(second).toBe(first)
expect(push).toHaveBeenCalledOnce()
expect(harness.navigation.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
name: '[hostId]/tasks',
params: { hostId: 'host-1', taskSource: 'linear' }
}
})
)
})
it('keeps waiting through host setup and cleans up when navigation leaves', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1')
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
expect(harness.unsubscribeState).not.toHaveBeenCalled()
harness.setState({ index: 0, routes: [{ name: 'index' }] })
harness.setState({
index: 0,
routes: [
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host-1' } }]
}
}
]
})
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
})
it('unsubscribes when mounting the host throws synchronously', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const error = new Error('navigation failed')
expect(() =>
navigateToMobileTasks(
harness.navigation,
{
push: () => {
throw error
}
},
'host-1'
)
).toThrow(error)
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
})
})
+154
View File
@@ -0,0 +1,154 @@
import type { TaskProvider } from './mobile-task-providers'
export type MobileTasksHostRoute = `/h/${string}`
export type MobileTasksNavigationState = Readonly<{
key?: string
index: number
routes: readonly MobileTasksNavigationRoute[]
}>
export type MobileTasksNavigationRoute = Readonly<{
key?: string
name: string
params?: Readonly<{ hostId?: unknown }>
state?: MobileTasksNavigationState
}>
export type MobileTasksRootNavigation = {
addListener: (event: 'state', listener: () => void) => () => void
dispatch: (action: MobileTasksHostReplaceAction) => void
getState: () => MobileTasksNavigationState
}
export type MobileTasksHostReplaceAction = Readonly<{
type: 'REPLACE'
target: string
source: string
payload: Readonly<{
name: '[hostId]/tasks'
params: Readonly<{ hostId: string; taskSource?: TaskProvider }>
}>
}>
export type MobileTasksRouter = {
push: (route: MobileTasksHostRoute) => void
}
export type MobileTasksNavigationController = Readonly<{
cancel: () => void
isActive: () => boolean
selectProvider: (provider?: TaskProvider) => void
}>
export type PendingMobileTasksNavigation = Readonly<{
hostId: string
controller: MobileTasksNavigationController
}>
export function mobileTasksHostRoute(hostId: string): MobileTasksHostRoute {
return `/h/${encodeURIComponent(hostId)}`
}
function mountedHostStack(
state: MobileTasksNavigationState,
expectedHostId: string
): { key: string; routeKey: string } | null {
const hostContainer = state.routes[state.index]
const hostState = hostContainer?.state
const hostRoute = hostState?.routes[hostState.index]
if (
hostContainer?.name !== 'h' ||
!hostState?.key ||
hostRoute?.name !== '[hostId]/index' ||
!hostRoute.key ||
hostRoute.params?.hostId !== expectedHostId
) {
return null
}
return { key: hostState.key, routeKey: hostRoute.key }
}
export function navigateToMobileTasks(
navigation: MobileTasksRootNavigation,
router: MobileTasksRouter,
hostId: string,
provider?: TaskProvider
): MobileTasksNavigationController {
let active = true
let hostRouteSeen = false
let selectedProvider = provider
let unsubscribeState = () => {}
const dispose = () => {
if (!active) {
return
}
active = false
unsubscribeState()
}
// Why: cold Expo deep links resolve to index; target Tasks only after its HostStack exists.
const onState = () => {
if (!active) {
return
}
const state = navigation.getState()
const currentRoute = state.routes[state.index]
if (currentRoute?.name === 'h') {
hostRouteSeen = true
} else if (hostRouteSeen) {
dispose()
return
}
const hostStack = mountedHostStack(state, hostId)
if (!hostStack) {
return
}
dispose()
const action: MobileTasksHostReplaceAction = {
type: 'REPLACE',
target: hostStack.key,
source: hostStack.routeKey,
payload: {
name: '[hostId]/tasks',
params: selectedProvider ? { hostId, taskSource: selectedProvider } : { hostId }
}
}
navigation.dispatch(action)
}
try {
unsubscribeState = navigation.addListener('state', onState)
router.push(mobileTasksHostRoute(hostId))
} catch (error) {
dispose()
throw error
}
return {
cancel: dispose,
isActive: () => active,
selectProvider: (nextProvider) => {
if (active) {
selectedProvider = nextProvider
}
}
}
}
export function coordinateMobileTasksNavigation(
current: PendingMobileTasksNavigation | null,
navigation: MobileTasksRootNavigation,
router: MobileTasksRouter,
hostId: string,
provider?: TaskProvider
): PendingMobileTasksNavigation {
if (current?.hostId === hostId && current.controller.isActive()) {
current.controller.selectProvider(provider)
return current
}
current?.controller.cancel()
return {
hostId,
controller: navigateToMobileTasks(navigation, router, hostId, provider)
}
}
+35
View File
@@ -0,0 +1,35 @@
import { useCallback, useEffect, useRef } from 'react'
import { useNavigation, useRouter } from 'expo-router'
import {
coordinateMobileTasksNavigation,
type MobileTasksRootNavigation,
type PendingMobileTasksNavigation
} from './mobile-task-navigation'
import type { TaskProvider } from './mobile-task-providers'
export function useOpenMobileTasks(): (hostId: string, provider?: TaskProvider) => void {
const navigation = useNavigation<MobileTasksRootNavigation>()
const router = useRouter()
const pendingRef = useRef<PendingMobileTasksNavigation | null>(null)
useEffect(
() => () => {
pendingRef.current?.controller.cancel()
pendingRef.current = null
},
[]
)
return useCallback(
(hostId, provider) => {
pendingRef.current = coordinateMobileTasksNavigation(
pendingRef.current,
navigation,
router,
hostId,
provider
)
},
[navigation, router]
)
}
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { dropSharedHostListLoad, shareHostListLoad } from './host-list-load-sharing'
import type { HostProfile } from './types'
function deferred() {
let resolve: (hosts: HostProfile[]) => void = () => {}
const promise = new Promise<HostProfile[]>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('shareHostListLoad', () => {
beforeEach(() => {
dropSharedHostListLoad()
})
it('runs one pass for callers that arrive while it is still open', async () => {
const pass = deferred()
const load = vi.fn(() => pass.promise)
const first = shareHostListLoad(load)
const second = shareHostListLoad(load)
pass.resolve([])
expect(load).toHaveBeenCalledTimes(1)
expect(await first).toBe(await second)
})
it('starts a fresh pass for callers that arrive after a write dropped it', async () => {
const stale = deferred()
const fresh = deferred()
const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise)
const before = shareHostListLoad(load)
dropSharedHostListLoad()
const after = shareHostListLoad(load)
stale.resolve([])
fresh.resolve([])
expect(load).toHaveBeenCalledTimes(2)
expect(await after).not.toBe(await before)
})
it('keeps the replacement pass on offer when the dropped one settles late', async () => {
const stale = deferred()
const fresh = deferred()
const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise)
const before = shareHostListLoad(load)
dropSharedHostListLoad()
const replacement = shareHostListLoad(load)
// The dropped pass settles after its replacement was registered.
stale.resolve([])
await before
expect(shareHostListLoad(load)).toBe(replacement)
expect(load).toHaveBeenCalledTimes(2)
fresh.resolve([])
await replacement
})
})
@@ -0,0 +1,31 @@
import type { HostProfile } from './types'
// Why: concurrent callers share a slow Keychain pass; durable writes invalidate
// it so later loads cannot receive stale snapshots (#8791).
let inflight: Promise<HostProfile[]> | null = null
let revision = 0
export function getHostListLoadRevision(): number {
return revision
}
export function shareHostListLoad(load: () => Promise<HostProfile[]>): Promise<HostProfile[]> {
if (inflight) {
return inflight
}
const started = load().finally(() => {
// Why: a dropped pass can settle after its replacement started; only retire
// the entry still on offer, or the replacement is silently discarded.
if (inflight === started) {
inflight = null
}
})
inflight = started
return started
}
/** Call after every durable host write so no later read is served a pre-write pass. */
export function dropSharedHostListLoad(): void {
revision += 1
inflight = null
}
+148
View File
@@ -340,6 +340,154 @@ describe('host-store list mutations', () => {
expect(storedHostsRaw).toBe('{')
})
// Why: gates the (slow, real-device 50-200ms) Keychain pass so a load can be
// parked mid-flight while a write commits underneath it.
function gateKeychainReads(): () => void {
let release: () => void = () => {}
const gate = new Promise<void>((resolve) => {
release = resolve
})
secureStoreMock.getItemAsync.mockImplementation(async (key: string) => {
await gate
return key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null
})
return release
}
it('does not serve a pre-removal snapshot to a load issued after the removal (#8791)', async () => {
const releaseKeychain = gateKeychainReads()
const parkedLoad = loadHosts()
await vi.waitFor(() => {
expect(secureStoreMock.getItemAsync).toHaveBeenCalled()
})
await removeHost(HOST_ONE.id)
const afterRemoval = loadHosts()
releaseKeychain()
await parkedLoad
await expect(afterRemoval.then((hosts) => hosts.map((host) => host.id))).resolves.toEqual([
HOST_TWO.id
])
})
it('does not serve a pre-rename snapshot to a load issued after the rename (#8791)', async () => {
const releaseKeychain = gateKeychainReads()
const parkedLoad = loadHosts()
await vi.waitFor(() => {
expect(secureStoreMock.getItemAsync).toHaveBeenCalled()
})
await updateHostNameAndEndpoint(HOST_ONE.id, { name: 'Living Room Mac' })
const afterRename = loadHosts()
releaseKeychain()
await parkedLoad
const hosts = await afterRename
expect(hosts.find((host) => host.id === HOST_ONE.id)?.name).toBe('Living Room Mac')
})
it('does not share a host-list pass started before saveHost commits its token', async () => {
const newHost = {
id: 'host-new',
name: 'New Host',
endpoint: 'ws://127.0.0.1:3',
publicKeyB64: 'key-new',
deviceToken: 'token-new',
lastConnected: 0
}
let releaseTokenWrite: () => void = () => {}
secureStoreMock.setItemAsync.mockReturnValue(
new Promise<void>((resolve) => {
releaseTokenWrite = resolve
})
)
let resolveParkedTokenRead: (token: string | null) => void = () => {}
const parkedTokenRead = new Promise<string | null>((resolve) => {
resolveParkedTokenRead = resolve
})
let shouldParkHostOneRead = true
secureStoreMock.getItemAsync.mockImplementation(async (key: string) => {
if (key.endsWith(HOST_ONE.id) && shouldParkHostOneRead) {
shouldParkHostOneRead = false
return parkedTokenRead
}
if (key.endsWith(newHost.id)) {
return newHost.deviceToken
}
return key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null
})
const save = saveHost(newHost)
await vi.waitFor(() => {
expect(secureStoreMock.setItemAsync).toHaveBeenCalled()
})
const parkedLoad = loadHosts()
await vi.waitFor(() => {
expect(
asyncStorageMock.getItem.mock.calls.filter(([key]) => key === HOSTS_STORAGE_KEY)
).toHaveLength(2)
})
releaseTokenWrite()
await save
await vi.waitFor(() => {
expect(secureStoreMock.getItemAsync).toHaveBeenCalledWith(
expect.stringContaining(HOST_ONE.id),
expect.anything()
)
})
const afterSave = loadHosts()
resolveParkedTokenRead(null)
const [parkedHosts, savedHosts] = await Promise.all([parkedLoad, afterSave])
expect(savedHosts.map((host) => host.id)).toContain(newHost.id)
expect(savedHosts).not.toBe(parkedHosts)
})
it('does not let a late pre-write token read poison the token cache', async () => {
const newHost = {
id: 'host-new',
name: 'New Host',
endpoint: 'ws://127.0.0.1:3',
publicKeyB64: 'key-new',
deviceToken: 'token-new',
lastConnected: 0
}
storedHostsRaw = JSON.stringify([{ ...newHost, deviceToken: undefined }])
let resolvePrewriteTokenRead: (token: string) => void = () => {}
secureStoreMock.getItemAsync.mockReturnValue(
new Promise<string>((resolve) => {
resolvePrewriteTokenRead = resolve
})
)
const parkedLoad = loadHosts()
await vi.waitFor(() => {
expect(secureStoreMock.getItemAsync).toHaveBeenCalled()
})
await saveHost(newHost)
resolvePrewriteTokenRead('token-old')
await parkedLoad
const hosts = await loadHosts()
expect(hosts.find((host) => host.id === newHost.id)?.deviceToken).toBe(newHost.deviceToken)
})
it('still shares one Keychain pass across loads with no write between them', async () => {
const releaseKeychain = gateKeychainReads()
const first = loadHosts()
await vi.waitFor(() => {
expect(secureStoreMock.getItemAsync).toHaveBeenCalled()
})
const second = loadHosts()
releaseKeychain()
const [firstHosts, secondHosts] = await Promise.all([first, second])
expect(firstHosts).toBe(secondHosts)
expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(2)
})
it('resolves instead of rejecting when updateLastConnected hits unreadable storage', async () => {
// Why: callers fire updateLastConnected with `void`; a rejection here would
// surface as an unhandled promise rejection rather than a caught error.
+12 -10
View File
@@ -7,6 +7,7 @@ import {
type StoredHostProfile
} from './types'
import { getNextHostNameFromHosts } from './host-names'
import * as hostListLoads from './host-list-load-sharing'
import {
deletePairingKeychainItem,
readPairingKeychainItem,
@@ -72,7 +73,6 @@ async function deleteHostCredentials(hostId: string): Promise<void> {
// Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove.
const tokenCache = new Map<string, string>()
let inflightLoad: Promise<HostProfile[]> | null = null
// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename).
let hostListMutation: Promise<void> = Promise.resolve()
@@ -102,13 +102,7 @@ export async function loadHosts(): Promise<HostProfile[]> {
// Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list.
await hostListMutation
// Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass.
if (inflightLoad) {
return inflightLoad
}
inflightLoad = doLoadHosts().finally(() => {
inflightLoad = null
})
return inflightLoad
return hostListLoads.shareHostListLoad(doLoadHosts)
}
async function doLoadHosts(): Promise<HostProfile[]> {
@@ -130,6 +124,7 @@ async function doLoadHosts(): Promise<HostProfile[]> {
for (const stored of storedHosts) {
let token = tokenCache.get(stored.id)
if (!token) {
const readRevision = hostListLoads.getHostListLoadRevision()
let fetched: string | null
try {
fetched = await readDeviceToken(stored.id)
@@ -142,7 +137,9 @@ async function doLoadHosts(): Promise<HostProfile[]> {
continue
}
token = fetched
tokenCache.set(stored.id, token)
if (readRevision === hostListLoads.getHostListLoadRevision()) {
tokenCache.set(stored.id, token)
}
}
const overlay = overlays.get(stored.id)
out.push({
@@ -196,6 +193,7 @@ async function mutateStoredHosts(
const current = await readStoredHostsForMutation()
const next = update(current)
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next))
hostListLoads.dropSharedHostListLoad()
})
hostListMutation = mutation.catch(() => {})
return mutation
@@ -249,6 +247,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
// Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever.
await writeDeviceToken(stored.id, validated.deviceToken)
tokenCache.set(stored.id, validated.deviceToken)
hostListLoads.dropSharedHostListLoad()
if (validated.endpoints) {
await saveMobileRelayHostOverlay({
v: 2,
@@ -257,6 +256,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
relayHostId: validated.relayHostId,
relay: validated.relay
})
hostListLoads.dropSharedHostListLoad()
}
const overlayRemovalIds = [...duplicateHostIds]
if (!validated.endpoints && updatedExistingHost) {
@@ -265,6 +265,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
if (overlayRemovalIds.length > 0) {
// Why: reusing an id for direct-only re-pairing must not retain routing metadata from the previous transport state.
await removeMobileRelayHostOverlays(overlayRemovalIds)
hostListLoads.dropSharedHostListLoad()
}
for (const duplicateHostId of duplicateHostIds) {
tokenCache.delete(duplicateHostId)
@@ -281,6 +282,7 @@ export async function removeHost(hostId: string): Promise<void> {
tokenCache.delete(hostId)
try {
await removeMobileRelayHostOverlay(hostId)
hostListLoads.dropSharedHostListLoad()
} catch {
// Base removal is authoritative; a retained overlay can't resurrect the host and is cleaned on a later retry.
}
@@ -340,6 +342,6 @@ export async function updateLastConnected(hostId: string): Promise<void> {
export function resetHostStoreForTests(): void {
hostListMutation = Promise.resolve()
tokenCache.clear()
inflightLoad = null
hostListLoads.dropSharedHostListLoad()
resetPairingKeychainForTests()
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.163-rc.3",
"version": "1.4.163",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+160 -2
View File
@@ -1,13 +1,32 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
copyFileWithWindowsRetry,
removeFileAtomicallyIfUnchanged,
renameFileWithWindowsRetry,
writeFileAtomically
writeFileAtomically,
writeFileAtomicallyIfUnchanged
} from './fs-utils'
const fsMock = vi.hoisted(() => ({ linkSync: vi.fn(), renameSync: vi.fn() }))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
fsMock.linkSync.mockImplementation(actual.linkSync)
fsMock.renameSync.mockImplementation(actual.renameSync)
return { ...actual, linkSync: fsMock.linkSync, renameSync: fsMock.renameSync }
})
const originalPlatform = process.platform
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
vi.restoreAllMocks()
})
describe('writeFileAtomically', () => {
let dir: string
@@ -81,6 +100,145 @@ describe('writeFileAtomically', () => {
cleanup()
}
})
it('replaces only the expected file generation', () => {
setup()
try {
const target = join(dir, 'guarded.toml')
writeFileSync(target, 'baseline')
expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'next')).toBe(true)
expect(readFileSync(target, 'utf-8')).toBe('next')
writeFileSync(target, 'concurrent')
expect(writeFileAtomicallyIfUnchanged(target, 'next', 'stale-write')).toBe(false)
expect(readFileSync(target, 'utf-8')).toBe('concurrent')
} finally {
cleanup()
}
})
it('validates the generation moved after a Windows rename retry', () => {
setup()
try {
const target = join(dir, 'guarded.toml')
writeFileSync(target, 'baseline')
Object.defineProperty(process, 'platform', { value: 'win32' })
fsMock.renameSync.mockClear()
fsMock.renameSync.mockImplementationOnce(() => {
writeFileSync(target, 'concurrent')
throw Object.assign(new Error('busy'), { code: 'EBUSY' })
})
expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'stale-write')).toBe(false)
expect(readFileSync(target, 'utf-8')).toBe('concurrent')
expect(fsMock.renameSync).toHaveBeenCalledTimes(2)
} finally {
cleanup()
}
})
it('preserves a replacement created before guarded publication', () => {
setup()
try {
const target = join(dir, 'guarded.toml')
writeFileSync(target, 'baseline')
const rename = fsMock.renameSync.getMockImplementation()
fsMock.renameSync.mockImplementationOnce((source: string, destination: string) => {
rename!(source, destination)
writeFileSync(target, 'concurrent')
})
expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'stale-write')).toBe(false)
expect(readFileSync(target, 'utf-8')).toBe('concurrent')
} finally {
cleanup()
}
})
it('recovers an interrupted guarded operation before retrying', () => {
setup()
try {
const target = join(dir, 'guarded.toml')
const held = `${target}.orca-guarded`
writeFileSync(held, 'baseline')
expect(writeFileAtomicallyIfUnchanged(target, 'baseline', 'next')).toBe(true)
expect(readFileSync(target, 'utf-8')).toBe('next')
expect(existsSync(held)).toBe(false)
} finally {
cleanup()
}
})
it('fails before moving the target when hard-link publication is unsupported', () => {
setup()
try {
const target = join(dir, 'guarded.toml')
writeFileSync(target, 'baseline')
fsMock.linkSync.mockImplementationOnce(() => {
throw Object.assign(new Error('unsupported'), { code: 'ENOTSUP' })
})
expect(() => writeFileAtomicallyIfUnchanged(target, 'baseline', 'next')).toThrow(
'unsupported'
)
expect(readFileSync(target, 'utf-8')).toBe('baseline')
expect(existsSync(`${target}.orca-guarded`)).toBe(false)
} finally {
cleanup()
}
})
it('removes only the expected file generation', () => {
setup()
try {
const target = join(dir, 'guarded-auth.json')
writeFileSync(target, 'baseline')
expect(removeFileAtomicallyIfUnchanged(target, 'other')).toBe(false)
expect(readFileSync(target, 'utf-8')).toBe('baseline')
expect(removeFileAtomicallyIfUnchanged(target, 'baseline')).toBe(true)
expect(existsSync(target)).toBe(false)
} finally {
cleanup()
}
})
it('returns false when the target disappears during the removal probe', () => {
setup()
try {
const target = join(dir, 'guarded-auth.json')
writeFileSync(target, 'baseline')
fsMock.linkSync.mockImplementationOnce(() => {
rmSync(target)
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
})
expect(removeFileAtomicallyIfUnchanged(target, 'baseline')).toBe(false)
expect(existsSync(`${target}.orca-guarded`)).toBe(false)
} finally {
cleanup()
}
})
it('preserves a replacement created while the expected generation is quarantined', () => {
setup()
try {
const target = join(dir, 'guarded-auth.json')
writeFileSync(target, 'baseline')
const rename = fsMock.renameSync.getMockImplementation()
fsMock.renameSync.mockImplementationOnce((source: string, destination: string) => {
rename!(source, destination)
writeFileSync(target, 'concurrent')
})
expect(removeFileAtomicallyIfUnchanged(target, 'baseline')).toBe(false)
expect(readFileSync(target, 'utf-8')).toBe('concurrent')
} finally {
cleanup()
}
})
})
describe('retrying file operations', () => {
+154 -1
View File
@@ -1,7 +1,8 @@
import { randomUUID } from 'node:crypto'
import { copyFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { grantDirAcl, isPermissionError } from '../win32-utils'
import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality'
export function writeFileAtomically(
targetPath: string,
@@ -39,6 +40,158 @@ export function writeFileAtomically(
}
}
export function writeFileAtomicallyIfUnchanged(
targetPath: string,
expectedContents: string | null,
contents: string,
options?: { mode?: number }
): boolean {
try {
return attemptGuardedAtomicWrite(targetPath, expectedContents, contents, options)
} catch (error) {
if (!isPermissionError(error) || process.platform !== 'win32') {
throw error
}
grantDirAcl(dirname(targetPath))
return attemptGuardedAtomicWrite(targetPath, expectedContents, contents, options)
}
}
export function removeFileAtomicallyIfUnchanged(
targetPath: string,
expectedContents: string
): boolean {
const heldPath = getGuardedOperationHeldPath(targetPath)
recoverInterruptedGuardedOperation(heldPath, targetPath)
try {
assertHardLinkPublicationSupported(targetPath, targetPath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false
}
throw error
}
try {
renameFileWithWindowsRetry(targetPath, heldPath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false
}
throw error
}
try {
if (!nodeFileContentsEqualSync(heldPath, expectedContents)) {
restoreMovedFileWithoutOverwrite(heldPath, targetPath)
return false
}
rmSync(heldPath, { force: true })
return !existsSync(targetPath)
} catch (error) {
restoreMovedFileWithoutOverwrite(heldPath, targetPath)
throw error
}
}
export function recoverInterruptedGuardedFileOperation(targetPath: string): void {
recoverInterruptedGuardedOperation(getGuardedOperationHeldPath(targetPath), targetPath)
}
function restoreMovedFileWithoutOverwrite(sourcePath: string, targetPath: string): void {
if (!existsSync(sourcePath)) {
return
}
publishFileWithoutOverwrite(sourcePath, targetPath)
rmSync(sourcePath, { force: true })
}
function getGuardedOperationHeldPath(targetPath: string): string {
return `${targetPath}.orca-guarded`
}
function recoverInterruptedGuardedOperation(heldPath: string, targetPath: string): void {
if (!existsSync(heldPath)) {
return
}
publishFileWithoutOverwrite(heldPath, targetPath)
rmSync(heldPath, { force: true })
}
function attemptGuardedAtomicWrite(
targetPath: string,
expectedContents: string | null,
contents: string,
options?: { mode?: number }
): boolean {
const tmpPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp`
const heldPath = getGuardedOperationHeldPath(targetPath)
recoverInterruptedGuardedOperation(heldPath, targetPath)
try {
writeFileSync(tmpPath, contents, { encoding: 'utf-8', mode: options?.mode })
if (expectedContents === null) {
return publishFileWithoutOverwrite(tmpPath, targetPath)
}
assertHardLinkPublicationSupported(tmpPath, targetPath)
try {
renameFileWithWindowsRetry(targetPath, heldPath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false
}
throw error
}
if (!nodeFileContentsEqualSync(heldPath, expectedContents)) {
restoreMovedFileWithoutOverwrite(heldPath, targetPath)
return false
}
if (!publishFileWithoutOverwrite(tmpPath, targetPath)) {
rmSync(heldPath, { force: true })
return false
}
rmSync(heldPath, { force: true })
return true
} catch (error) {
restoreMovedFileWithoutOverwrite(heldPath, targetPath)
throw error
} finally {
rmSync(tmpPath, { force: true })
}
}
function assertHardLinkPublicationSupported(sourcePath: string, targetPath: string): void {
const probePath = `${targetPath}.${process.pid}.${randomUUID()}.link-probe`
try {
if (!publishFileWithoutOverwrite(sourcePath, probePath)) {
throw new Error(`Guarded file publication probe already exists: ${probePath}`)
}
} finally {
rmSync(probePath, { force: true })
}
}
function publishFileWithoutOverwrite(sourcePath: string, targetPath: string): boolean {
try {
linkSync(sourcePath, targetPath)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
return false
}
if (isPermissionError(error) && process.platform === 'win32') {
grantDirAcl(dirname(targetPath))
try {
linkSync(sourcePath, targetPath)
return true
} catch (retryError) {
if ((retryError as NodeJS.ErrnoException).code === 'EEXIST') {
return false
}
throw retryError
}
}
throw error
}
}
// Why: on Windows, file replacement and backup-copy operations can fail with
// EPERM/EACCES/EBUSY if another process (antivirus, Claude CLI, Codex CLI)
// holds the target file open. A short retry avoids transient failures without
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility'
import type * as FsUtils from './fs-utils'
const generationRace = vi.hoisted(() => ({
path: null as string | null,
beforeGuardedReplace: null as (() => void) | null
}))
vi.mock('./fs-utils', async (importOriginal) => {
const actual = await importOriginal<typeof FsUtils>()
return {
...actual,
writeFileAtomicallyIfUnchanged: (
path: string,
expectedContents: string | null,
contents: string,
options?: { mode?: number }
) => {
if (path === generationRace.path) {
generationRace.beforeGuardedReplace?.()
}
return actual.writeFileAtomicallyIfUnchanged(path, expectedContents, contents, options)
}
}
})
let root: string
let sharedRuntimeHome: string
let systemCodexHome: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-legacy-codex-home-'))
sharedRuntimeHome = join(root, 'codex-runtime-home', 'home')
systemCodexHome = join(root, 'system-home', '.codex')
mkdirSync(sharedRuntimeHome, { recursive: true })
mkdirSync(systemCodexHome, { recursive: true })
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
generationRace.path = null
generationRace.beforeGuardedReplace = null
vi.restoreAllMocks()
})
describe('legacy shared Codex config compatibility', () => {
it('refreshes retained panes with the authenticated production provider config', () => {
const staleSharedAuth = '{"tokens":{"access_token":"stale"}}\n'
const systemConfig = [
'model_provider = "codex-lb"',
'',
'[model_providers.codex-lb]',
'base_url = "https://codex-lb.example.test/v1"',
'requires_openai_auth = true',
''
].join('\n')
writeFileSync(join(systemCodexHome, 'config.toml'), systemConfig, 'utf-8')
writeFileSync(join(sharedRuntimeHome, 'auth.json'), staleSharedAuth)
writeFileSync(
join(sharedRuntimeHome, 'config.toml'),
[
'model_provider = "stale-provider"',
'',
'[hooks.state."orca:stop:0:0"]',
'enabled = true',
''
].join('\n')
)
syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome })
const sharedConfig = readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')
expect(sharedConfig).toContain('model_provider = "codex-lb"')
expect(sharedConfig).toContain('requires_openai_auth = true')
expect(sharedConfig).not.toContain('stale-provider')
expect(sharedConfig).toContain('[hooks.state."orca:stop:0:0"]')
expect(readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')).toBe(systemConfig)
expect(readFileSync(join(sharedRuntimeHome, 'auth.json'), 'utf-8')).toBe(staleSharedAuth)
})
it('does not delete config when the canonical source is transiently missing', () => {
const staleConfig = 'model_provider = "stale-provider"\n'
writeFileSync(join(sharedRuntimeHome, 'config.toml'), staleConfig, 'utf-8')
syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome })
expect(readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')).toBe(staleConfig)
})
it('does not promote retained-pane settings into the real home', () => {
writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "canonical"\n', 'utf-8')
writeFileSync(join(sharedRuntimeHome, 'config.toml'), 'model = "retained-pane-change"\n')
syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome })
expect(readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')).toBe(
'model = "canonical"\n'
)
expect(readFileSync(join(sharedRuntimeHome, 'config.toml'), 'utf-8')).toBe(
'model = "canonical"\n'
)
})
it('does not overwrite trust appended by a retained Codex during the mirror', () => {
const runtimeConfigPath = join(sharedRuntimeHome, 'config.toml')
const runtimeConfig = 'model = "runtime-before"\n'
const concurrentConfig = [
runtimeConfig.trimEnd(),
'',
'[projects."/newly-trusted"]',
'trust_level = "trusted"',
''
].join('\n')
writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "canonical"\n', 'utf-8')
writeFileSync(runtimeConfigPath, runtimeConfig, 'utf-8')
generationRace.path = runtimeConfigPath
generationRace.beforeGuardedReplace = () => {
writeFileSync(runtimeConfigPath, concurrentConfig, 'utf-8')
}
syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome })
expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe(concurrentConfig)
})
it('does not block real-home launch when compatibility config refresh fails', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "canonical"\n', 'utf-8')
mkdirSync(join(sharedRuntimeHome, 'config.toml'))
expect(() =>
syncLegacySharedCodexConfigForRetainedPanes({ sharedRuntimeHome, systemCodexHome })
).not.toThrow()
expect(warnSpy).toHaveBeenCalledWith(
'[codex-runtime-home] Failed to refresh legacy shared config:',
expect.anything()
)
})
})
@@ -0,0 +1,25 @@
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths'
import { syncSystemConfigIntoLegacySharedCodexHome } from '../codex/codex-config-mirror'
type LegacySharedCodexConfigPaths = {
sharedRuntimeHome: string
systemCodexHome: string
}
export function syncLegacySharedCodexConfigForRetainedPanes(
paths?: LegacySharedCodexConfigPaths
): void {
try {
const resolvedPaths = paths ?? {
sharedRuntimeHome: getOrcaManagedCodexHomePath(),
systemCodexHome: getSystemCodexHomePath()
}
syncSystemConfigIntoLegacySharedCodexHome({
runtimeHomePath: resolvedPaths.sharedRuntimeHome,
systemHomePath: resolvedPaths.systemCodexHome
})
} catch (error) {
// Why: compatibility for a retained pane must never block real-home Codex.
console.warn('[codex-runtime-home] Failed to refresh legacy shared config:', error)
}
}
@@ -0,0 +1,241 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { CodexManagedAccount, GlobalSettings } from '../../shared/types'
import { waitForManagedCodexAuthReady } from './managed-codex-auth-readiness'
const roots: string[] = []
const testIdToken = 'e30.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.sig'
const testChatGptAuth = {
auth_mode: 'chatgpt',
tokens: {
access_token: 'access',
id_token: testIdToken,
refresh_token: 'refresh',
account_id: 'account'
},
last_refresh: '2026-07-31T00:00:00Z'
}
afterEach(() => {
vi.useRealTimers()
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
describe('waitForManagedCodexAuthReady', () => {
it.each([
['ChatGPT', testChatGptAuth],
['ChatGPT auth tokens', { ...testChatGptAuth, auth_mode: 'chatgptAuthTokens' }],
['API key', { auth_mode: 'apikey', OPENAI_API_KEY: 'sk-test' }],
[
'agent identity',
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
}
],
[
'future agent identity storage',
{ auth_mode: 'agentIdentity', agent_identity: { future_material: 'opaque' } }
],
[
'personal access token',
{ auth_mode: 'personalAccessToken', personal_access_token: 'pat-test' }
],
[
'Bedrock API key',
{
auth_mode: 'bedrockApiKey',
bedrock_api_key: { api_key: 'bedrock-key', region: 'us-east-1' }
}
],
['future auth mode', { auth_mode: 'futureAuthMode', future_credential: { value: 'opaque' } }],
['future auth shape', { future_credential: { value: 'opaque' } }],
[
'ChatGPT with agent identity metadata',
{
...testChatGptAuth,
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
}
]
])('accepts a readable managed %s credential', (_label, auth) => {
const fixture = createFixture()
writeAuth(fixture.home, auth)
expect(waitForManagedCodexAuthReady(fixture.args)).toBeUndefined()
})
it('waits for a missing managed credential to be restored', async () => {
vi.useFakeTimers()
const fixture = createFixture()
const readiness = waitForManagedCodexAuthReady(fixture.args)
await vi.advanceTimersByTimeAsync(50)
writeAuth(fixture.home, { OPENAI_API_KEY: 'sk-test' })
await vi.runAllTimersAsync()
await expect(readiness).resolves.toBe(true)
})
it('waits for a partial managed ChatGPT credential to become complete', async () => {
vi.useFakeTimers()
const fixture = createFixture()
writeAuth(fixture.home, {
tokens: { access_token: 'access', id_token: testIdToken }
})
let resolved = false
const readiness = waitForManagedCodexAuthReady(fixture.args)
void readiness?.then(() => {
resolved = true
})
await vi.advanceTimersByTimeAsync(50)
expect(resolved).toBe(false)
writeAuth(fixture.home, testChatGptAuth)
await vi.advanceTimersByTimeAsync(25)
await expect(readiness).resolves.toBe(true)
})
it('waits for an empty managed ChatGPT refresh token to be restored', async () => {
vi.useFakeTimers()
const fixture = createFixture()
writeAuth(fixture.home, {
...testChatGptAuth,
tokens: { ...testChatGptAuth.tokens, refresh_token: '' }
})
let resolved = false
const readiness = waitForManagedCodexAuthReady(fixture.args)
void readiness?.then(() => {
resolved = true
})
await vi.advanceTimersByTimeAsync(50)
expect(resolved).toBe(false)
writeAuth(fixture.home, testChatGptAuth)
await vi.advanceTimersByTimeAsync(25)
await expect(readiness).resolves.toBe(true)
})
it.each([
[
'empty agent identity record',
{ auth_mode: 'agentIdentity', agent_identity: {} },
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
}
],
[
'agent identity missing its private key',
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: 'runtime', plan_type: 'team' }
},
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
}
],
[
'agent identity with an empty runtime id',
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: '', agent_private_key: 'private-key' }
},
{
auth_mode: 'agentIdentity',
agent_identity: { agent_runtime_id: 'runtime', agent_private_key: 'private-key' }
}
],
[
'object-valued personal access token',
{ auth_mode: 'personalAccessToken', personal_access_token: { token_id: 'pat' } },
{ auth_mode: 'personalAccessToken', personal_access_token: 'pat-test' }
],
[
'Bedrock metadata without an API key',
{ auth_mode: 'bedrockApiKey', bedrock_api_key: { region: 'us-east-1' } },
{
auth_mode: 'bedrockApiKey',
bedrock_api_key: { api_key: 'bedrock-key', region: 'us-east-1' }
}
]
])('waits for %s to become complete', async (_label, partial, complete) => {
vi.useFakeTimers()
const fixture = createFixture()
writeAuth(fixture.home, partial)
const readiness = waitForManagedCodexAuthReady(fixture.args)
await vi.advanceTimersByTimeAsync(50)
writeAuth(fixture.home, complete)
await vi.advanceTimersByTimeAsync(25)
await expect(readiness).resolves.toBe(true)
})
it('reports an unreadable managed credential after the retry window', async () => {
vi.useFakeTimers()
const fixture = createFixture()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
writeFileSync(join(fixture.home, 'auth.json'), '{', 'utf8')
const readiness = waitForManagedCodexAuthReady(fixture.args)
await vi.advanceTimersByTimeAsync(2_000)
await expect(readiness).resolves.toBe(false)
expect(warnSpy).toHaveBeenCalledWith(
'[codex-auth-readiness] Managed credential remained unavailable after 1500ms'
)
})
it('does not gate system, WSL, or unmanaged custom homes', async () => {
const fixture = createFixture()
await waitForManagedCodexAuthReady({
...fixture.args,
codexHomePath: join(fixture.root, 'custom-home')
})
await waitForManagedCodexAuthReady({
...fixture.args,
target: { runtime: 'wsl', wslDistro: 'Ubuntu' }
})
await waitForManagedCodexAuthReady({
...fixture.args,
codexHomePath: null
})
})
})
function createFixture(): {
root: string
home: string
args: Parameters<typeof waitForManagedCodexAuthReady>[0]
} {
const root = mkdtempSync(join(tmpdir(), 'orca-managed-codex-auth-'))
roots.push(root)
const home = join(root, 'account', 'home')
mkdirSync(home, { recursive: true })
const account = {
id: 'account-1',
managedHomePath: home,
managedHomeRuntime: 'host'
} as CodexManagedAccount
return {
root,
home,
args: {
codexHomePath: home,
settings: { codexManagedAccounts: [account] } as GlobalSettings,
target: { runtime: 'host' }
}
}
}
function writeAuth(home: string, auth: object): void {
writeFileSync(join(home, 'auth.json'), JSON.stringify(auth), { mode: 0o600 })
}
@@ -0,0 +1,175 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type { GlobalSettings } from '../../shared/types'
import type { CodexAccountSelectionTarget } from './runtime-selection'
const AUTH_READY_TIMEOUT_MS = 1_500
const AUTH_READY_RETRY_MS = 25
type StoredCodexAuth = {
auth_mode?: unknown
OPENAI_API_KEY?: unknown
agent_identity?: unknown
personal_access_token?: unknown
bedrock_api_key?: unknown
last_refresh?: unknown
tokens?: unknown
}
const KNOWN_CREDENTIAL_KEYS = [
'OPENAI_API_KEY',
'tokens',
'agent_identity',
'personal_access_token',
'bedrock_api_key'
] as const
const KNOWN_AGENT_IDENTITY_RECORD_KEYS = [
'agent_runtime_id',
'agent_private_key',
'plan_type',
'task_id'
] as const
export function waitForManagedCodexAuthReady(args: {
codexHomePath: string | null
settings: GlobalSettings | undefined
target: CodexAccountSelectionTarget
}): Promise<boolean> | undefined {
if (isCodexHomeAuthReadyForLaunch(args)) {
return
}
return waitForStoredCodexCredential(join(args.codexHomePath!, 'auth.json'))
}
export function isCodexHomeAuthReadyForLaunch(args: {
codexHomePath: string | null
settings: GlobalSettings | undefined
target: CodexAccountSelectionTarget
}): boolean {
return (
args.target.runtime !== 'host' ||
!args.codexHomePath ||
!isManagedHostCodexHome(args.codexHomePath, args.settings) ||
hasStoredCodexCredential(join(args.codexHomePath, 'auth.json'))
)
}
async function waitForStoredCodexCredential(authPath: string): Promise<boolean> {
const deadline = Date.now() + AUTH_READY_TIMEOUT_MS
do {
await delay(AUTH_READY_RETRY_MS)
if (hasStoredCodexCredential(authPath)) {
return true
}
} while (Date.now() < deadline)
console.warn(
`[codex-auth-readiness] Managed credential remained unavailable after ${AUTH_READY_TIMEOUT_MS}ms`
)
return false
}
function isManagedHostCodexHome(
codexHomePath: string,
settings: GlobalSettings | undefined
): boolean {
const expected = normalizeRuntimePathForComparison(codexHomePath)
return (
settings?.codexManagedAccounts?.some(
(account) =>
account.managedHomeRuntime !== 'wsl' &&
normalizeRuntimePathForComparison(account.managedHomePath) === expected
) === true
)
}
export function hasStoredCodexCredential(authPath: string): boolean {
try {
const parsed: unknown = JSON.parse(readFileSync(authPath, 'utf8'))
if (!isRecord(parsed) || Object.keys(parsed).length === 0) {
return false
}
const auth = parsed as StoredCodexAuth
return auth.auth_mode == null
? hasCredentialWithoutDeclaredMode(auth)
: hasCredentialForDeclaredMode(auth)
} catch {
return false
}
}
function hasCredentialForDeclaredMode(auth: StoredCodexAuth): boolean {
if (!isNonEmptyString(auth.auth_mode)) {
return false
}
switch (auth.auth_mode) {
case 'apikey':
return isNonEmptyString(auth.OPENAI_API_KEY)
case 'chatgpt':
case 'chatgptAuthTokens':
return hasChatGptCredential(auth.tokens)
case 'agentIdentity':
return hasAgentIdentityCredential(auth.agent_identity)
case 'personalAccessToken':
return isNonEmptyString(auth.personal_access_token)
case 'bedrockApiKey':
return hasBedrockApiKey(auth.bedrock_api_key)
default:
return true
}
}
function hasCredentialWithoutDeclaredMode(auth: StoredCodexAuth): boolean {
const knownCredentialPresent = KNOWN_CREDENTIAL_KEYS.some((key) => key in auth)
if (knownCredentialPresent) {
return (
isNonEmptyString(auth.OPENAI_API_KEY) ||
hasChatGptCredential(auth.tokens) ||
hasAgentIdentityCredential(auth.agent_identity) ||
isNonEmptyString(auth.personal_access_token) ||
hasBedrockApiKey(auth.bedrock_api_key)
)
}
return Object.keys(auth).some((key) => key !== 'auth_mode' && key !== 'last_refresh')
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function hasChatGptCredential(tokens: unknown): boolean {
if (!isRecord(tokens)) {
return false
}
return (
isNonEmptyString(tokens.access_token) &&
isNonEmptyString(tokens.id_token) &&
isNonEmptyString(tokens.refresh_token)
)
}
function hasAgentIdentityCredential(value: unknown): boolean {
if (isNonEmptyString(value)) {
return true
}
if (!isRecord(value) || Object.keys(value).length === 0) {
return false
}
return KNOWN_AGENT_IDENTITY_RECORD_KEYS.some((key) => key in value)
? isNonEmptyString(value.agent_runtime_id) && isNonEmptyString(value.agent_private_key)
: true
}
function hasBedrockApiKey(value: unknown): boolean {
return isRecord(value) && isNonEmptyString(value.api_key)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -179,9 +179,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => {
rmSync(accountAuthPath)
writeFileSync(sharedAuthPath(), laterShared, 'utf-8')
expect(service.prepareForCodexLaunch()).toBeNull()
expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath)
expect(service.prepareForRateLimitFetch()).toBe(account.managedHomePath)
expect(existsSync(accountAuthPath)).toBe(false)
expect(settings.activeCodexManagedAccountId).toBeNull()
expect(settings.activeCodexManagedAccountId).toBe(account.id)
expect(readFileSync(sharedAuthPath(), 'utf-8')).toBe(laterShared)
expect(readFileSync(systemAuthPath(), 'utf-8')).toBe('system auth sentinel\n')
})
File diff suppressed because it is too large Load Diff
+568 -87
View File
@@ -29,9 +29,15 @@ import {
} from 'node:path'
import { app } from 'electron'
import type { CodexManagedAccount } from '../../shared/types'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type { Store } from '../persistence'
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
import { writeFileAtomically } from './fs-utils'
import {
recoverInterruptedGuardedFileOperation,
removeFileAtomicallyIfUnchanged,
writeFileAtomically,
writeFileAtomicallyIfUnchanged
} from './fs-utils'
import {
getOrcaManagedCodexHomePath,
getOrcaUserDataPath,
@@ -62,9 +68,8 @@ import {
} from './runtime-selection'
import { getDefaultWslDistro, getWslHome } from '../wsl'
import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag'
import { hasCustomCodexHomeOverride } from '../codex/codex-real-home-path'
import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path'
import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker'
import { isShellStartupEnvProbeSupported, readShellStartupEnvVar } from '../pty/shell-startup-env'
import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership'
import {
codexAuthIsFresher,
@@ -72,6 +77,13 @@ import {
codexAuthMatchesSystemDefaultIdentity
} from './codex-auth-identity'
import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration'
import { hasStoredCodexCredential } from './managed-codex-auth-readiness'
import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility'
import {
hasRecordedLegacySharedCodexPane,
type CodexPaneHomeRoute
} from '../codex/codex-pane-account-registry'
import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env'
type CodexSystemDefaultSnapshot = {
authJson: string | null
@@ -82,6 +94,26 @@ type CodexRuntimeLogoutMarker = {
loggedOutAt: number
}
type CodexSharedRuntimeAuthProvenance =
| { owner: 'system-default'; authJson: string | null }
| {
owner: 'managed'
accountId: string
systemDefaultBaseline?: { authJson: string | null }
}
type CodexSharedRuntimeAuthPendingProvenance = {
owner: 'pending'
next: CodexSharedRuntimeAuthProvenance
runtimeAuthJson: string | null
}
type CodexSharedRuntimeAuthProvenanceFile =
| CodexSharedRuntimeAuthProvenance
| CodexSharedRuntimeAuthPendingProvenance
| { owner: 'fenced' }
type CodexSharedRuntimeAuthProvenanceStatus =
| { kind: 'missing' | 'fenced' }
| { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance }
type CodexRuntimeLogoutMarkerStatus =
| { kind: 'missing' }
| { kind: 'applies' }
@@ -97,18 +129,40 @@ type CodexReadBackMatch =
}
| { kind: 'none' | 'ambiguous' }
function readLaunchEnvValue(
launchEnv: NodeJS.ProcessEnv,
key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL'
): string | undefined {
return Object.prototype.hasOwnProperty.call(launchEnv, key) ? launchEnv[key] : process.env[key]
function readCodexLastRefresh(authJson: string): number | null {
try {
const parsed = JSON.parse(authJson) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null
}
const value = (parsed as Record<string, unknown>).last_refresh
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value !== 'string' || !value.trim()) {
return null
}
const timestamp = Date.parse(value)
return Number.isFinite(timestamp) ? timestamp : null
} catch {
return null
}
}
function getEffectiveCodexHomeEnv(launchEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
CODEX_HOME: readLaunchEnvValue(launchEnv, 'CODEX_HOME'),
ORCA_CODEX_HOME: readLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME')
function codexAuthIsMonotonicallyFresher(
candidateAuthJson: string,
baselineAuthJson: string
): boolean {
const candidateLastRefresh = readCodexLastRefresh(candidateAuthJson)
const baselineLastRefresh = readCodexLastRefresh(baselineAuthJson)
if (candidateLastRefresh !== null || baselineLastRefresh !== null) {
return (
candidateLastRefresh !== null &&
baselineLastRefresh !== null &&
candidateLastRefresh > baselineLastRefresh
)
}
return codexAuthIsFresher(candidateAuthJson, baselineAuthJson)
}
export class CodexRuntimeHomeService {
@@ -124,8 +178,10 @@ export class CodexRuntimeHomeService {
// Why: a flag-ON host account refreshes auth in its own home. Remember that
// provenance so a later deselect/rollback never adopts stale shared bytes.
private lastHostAccountUsedSelfContainedHome = false
private sharedAuthRefreshBlockedByManagedTransition = false
constructor(private readonly store: Store) {
this.safeRecoverInterruptedRuntimeAuthOperation()
this.safeMigrateLegacySharedAuth()
this.safeMigrateLegacyManagedState()
this.safeMigrateLegacyActiveHomePointer()
@@ -153,7 +209,8 @@ export class CodexRuntimeHomeService {
*/
prepareForCodexLaunch(
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv
launchEnv?: NodeJS.ProcessEnv,
options?: { unavailableManagedHomePath?: string }
): string | null {
if (target?.runtime === 'wsl') {
const wslTarget = this.resolveWslDefaultTarget(target)
@@ -165,22 +222,25 @@ export class CodexRuntimeHomeService {
}
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
if (selfContainedAccount) {
const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch(selfContainedAccount)
const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch(
selfContainedAccount,
options?.unavailableManagedHomePath
)
if (perAccountHome) {
return perAccountHome
}
// Why: the account's home lost its auth.json, so the selection was just
// dropped. Fall through and resolve this launch as the system default.
// Why: only an untrusted home clears the selection; fall through to the
// system default without injecting a path Orca cannot prove it owns.
}
if (this.isHostSystemDefaultRealHome(launchEnv)) {
// Why (flag ON, system default): run Codex on the user's own ~/.codex.
// Returning null tells the PTY/env layer to inject no managed CODEX_HOME;
// sessions, auth, and config all live in the native home. No system->
// managed session bridge runs, so the real home stays the single source.
// the retired mirror is refreshed only for pre-rollout PTYs.
this.reconcileLegacySharedHomeForRetainedPanes()
return null
}
this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv)
this.syncForCurrentSelection()
this.syncForCurrentSelection(target, launchEnv)
syncSystemCodexResourcesIntoManagedHome()
syncSystemConfigIntoManagedCodexHome()
// Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk.
@@ -235,19 +295,31 @@ export class CodexRuntimeHomeService {
return homes
}
private prepareSelfContainedManagedHomeForLaunch(account: CodexManagedAccount): string | null {
private prepareSelfContainedManagedHomeForLaunch(
account: CodexManagedAccount,
unavailableManagedHomePath?: string
): string | null {
const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account)
if (!perAccountHome || !existsSync(join(perAccountHome, 'auth.json'))) {
// Why: drop the selection so this and future launches resolve to the
// system default rather than a home codex cannot authenticate against.
if (!perAccountHome) {
this.clearSelfContainedManagedSelection(account)
return null
}
if (
unavailableManagedHomePath &&
normalizeRuntimePathForComparison(unavailableManagedHomePath) ===
normalizeRuntimePathForComparison(perAccountHome) &&
!hasStoredCodexCredential(join(perAccountHome, 'auth.json'))
) {
this.clearSelfContainedManagedSelection(account, 'credential remained unavailable')
return null
}
// Why: link the user's real ~/.codex resources and mirror config into THIS
// home (never symlinking into or mutating ~/.codex), so the per-account home
// is a complete CODEX_HOME. Hooks/trust are installed by the launch caller.
this.lastSyncedAccountId = account.id
this.lastHostAccountUsedSelfContainedHome = true
this.sharedAuthRefreshBlockedByManagedTransition = true
this.markSharedRuntimeAuthManaged(account.id)
syncSystemCodexResourcesIntoManagedHome(perAccountHome)
syncSystemConfigIntoManagedCodexHome({
runtimeHomePath: perAccountHome,
@@ -282,13 +354,15 @@ export class CodexRuntimeHomeService {
// Why: the per-account home is both the launch CODEX_HOME and the credential
// store, so codex reads/refreshes auth.json in place — there is no shared-home
// hot-swap or token read-back to reconcile. Only validate the credential
// survives; a vanished auth.json drops the selection to the system default.
// hot-swap or token read-back to reconcile. A trusted home remains selected
// while Codex atomically replaces auth.json.
private syncSelfContainedManagedSelection(account: CodexManagedAccount): void {
const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account)
if (perAccountHome && existsSync(join(perAccountHome, 'auth.json'))) {
if (perAccountHome) {
this.lastSyncedAccountId = account.id
this.lastHostAccountUsedSelfContainedHome = true
this.sharedAuthRefreshBlockedByManagedTransition = true
this.markSharedRuntimeAuthManaged(account.id)
// Why: selection runs well before the user restarts a pane, so history is
// already linked in by the time the newly launched Codex opens /resume.
this.startSelfContainedSessionBridgeForLaunch(perAccountHome)
@@ -314,10 +388,11 @@ export class CodexRuntimeHomeService {
}
}
private clearSelfContainedManagedSelection(account: CodexManagedAccount): void {
console.warn(
'[codex-runtime-home] Active managed account home is invalid or missing auth.json, clearing selection'
)
private clearSelfContainedManagedSelection(
account: CodexManagedAccount,
reason = 'home is invalid'
): void {
console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`)
const settings = this.store.getSettings()
if (normalizeCodexRuntimeSelection(settings).host !== account.id) {
return
@@ -407,6 +482,13 @@ export class CodexRuntimeHomeService {
: null
}
getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute {
if (this.getSelfContainedManagedHostAccount()) {
return 'account-home'
}
return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home'
}
// Why: the real-home hook installer flips this gate off when the trust-grant
// client reports the host incapable, keeping that host byte-identical to the
// managed lane instead of shipping status-blind panes.
@@ -423,33 +505,26 @@ export class CodexRuntimeHomeService {
const settings = this.store.getSettings()
if (
!isCodexSystemDefaultRealHomeEnabled() ||
normalizeCodexRuntimeSelection(settings).host !== null
normalizeCodexRuntimeSelection(settings).host !== null ||
!isShellStartupEnvProbeSupported()
) {
return false
}
// Why: PTY callers can overlay environment values that the Electron main
// process never inherited. Those custom homes must keep the managed lane.
const effectiveEnv = launchEnv ? getEffectiveCodexHomeEnv(launchEnv) : process.env
if (hasCustomCodexHomeOverride(effectiveEnv)) {
return false
}
if (!isShellStartupEnvProbeSupported()) {
return false
}
// Why: Finder/Dock launches do not inherit shell exports, but the login
// shell can re-export a custom home after spawn and bypass the trusted lane.
const shellCodexHome = readShellStartupEnvVar(
'CODEX_HOME',
launchEnv ? readLaunchEnvValue(launchEnv, 'HOME') : process.env.HOME,
launchEnv ? readLaunchEnvValue(launchEnv, 'SHELL') : process.env.SHELL
)
return !hasCustomCodexHomeOverride({ CODEX_HOME: shellCodexHome })
return !hasCustomCodexHomeOverrideForLaunch(launchEnv)
}
isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean {
return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate()
}
reconcileLegacySharedHomeForRetainedPanes(): void {
if (!this.isHostSystemDefaultRealHome() || !hasRecordedLegacySharedCodexPane()) {
return
}
this.syncLegacySharedSystemDefaultAuthForRetainedPanes()
syncLegacySharedCodexConfigForRetainedPanes()
}
syncActiveWslSelectionsBeforeRestart(): void {
if (process.platform !== 'win32') {
return
@@ -516,14 +591,9 @@ export class CodexRuntimeHomeService {
const selfContainedHome = selfContainedAccount
? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount)
: null
if (
selfContainedAccount &&
selfContainedHome &&
existsSync(join(selfContainedHome, 'auth.json'))
) {
if (selfContainedAccount && selfContainedHome) {
// Why: the quota fetch reads the account's own auth.json in place; no
// shared-home hot-swap, and no per-poll resource relink (that is launch
// prep). Config was mirrored on add/select and refreshed at launch.
// shared-home hot-swap or per-poll resource relink (that is launch prep).
return selfContainedHome
}
if (selfContainedAccount) {
@@ -534,6 +604,9 @@ export class CodexRuntimeHomeService {
// CODEX_HOME before ~/.codex. Nested Orca launches can inherit the
// managed home, restarting the background OAuth conflict (#5370), so
// pin this non-interactive lane to the native home explicitly.
if (hasRecordedLegacySharedCodexPane()) {
this.syncLegacySharedSystemDefaultAuthForRetainedPanes()
}
return getSystemCodexHomePath()
}
this.syncForCurrentSelection()
@@ -542,7 +615,10 @@ export class CodexRuntimeHomeService {
return this.getRuntimeHomePath()
}
syncForCurrentSelection(target?: CodexAccountSelectionTarget): void {
syncForCurrentSelection(
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv
): void {
if (target?.runtime === 'wsl') {
this.syncWslRuntimeForCurrentSelection(target)
return
@@ -555,7 +631,6 @@ export class CodexRuntimeHomeService {
this.syncSelfContainedManagedSelection(selfContainedAccount)
return
}
const settings = this.store.getSettings()
if (this.lastHostAccountUsedSelfContainedHome) {
// Why: E auth is already canonical in the per-account home. Reset the
@@ -564,10 +639,20 @@ export class CodexRuntimeHomeService {
this.lastHostAccountUsedSelfContainedHome = false
this.lastSyncedAccountId = null
this.lastWrittenAuthJson = null
if (this.isHostSystemDefaultRealHome()) {
if (this.isHostSystemDefaultRealHome(launchEnv)) {
return
}
}
if (this.isHostSystemDefaultRealHome(launchEnv)) {
// Why: retained daemon panes may own shared auth from a managed launch;
// compatibility reconciliation runs later with durable provenance.
if (this.lastSyncedAccountId !== null) {
this.sharedAuthRefreshBlockedByManagedTransition = true
this.lastSyncedAccountId = null
this.lastWrittenAuthJson = null
}
return
}
const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath())
if (this.lastSyncedAccountId === null) {
this.captureSystemDefaultSnapshot({ force: false })
@@ -689,7 +774,10 @@ export class CodexRuntimeHomeService {
this.skipNextReadBackForAccountId = null
}
this.lastSyncedAccountId = activeAccount.id
this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'))
this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'), {
owner: 'managed',
accountId: activeAccount.id
})
}
// Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens.
@@ -823,6 +911,14 @@ export class CodexRuntimeHomeService {
}
}
private safeRecoverInterruptedRuntimeAuthOperation(): void {
try {
recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath())
} catch (error) {
console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error)
}
}
private getActiveAccount(
accounts: CodexManagedAccount[],
activeAccountId: string | null
@@ -1232,6 +1328,10 @@ export class CodexRuntimeHomeService {
return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json')
}
private getSharedRuntimeAuthProvenancePath(): string {
return join(this.getRuntimeMetadataDir(), 'shared-runtime-auth-provenance.json')
}
private getRuntimeMetadataDir(): string {
const metadataDir = join(app.getPath('userData'), 'codex-runtime-home')
mkdirSync(metadataDir, { recursive: true })
@@ -1522,14 +1622,41 @@ export class CodexRuntimeHomeService {
try {
const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8')
const provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus()
const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null
if (provenance?.owner === 'managed') {
this.captureSystemDefaultSnapshot({ force: true })
if (!existsSync(systemDefaultAuthPath)) {
this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath)
return
}
this.writeRuntimeAuth(readFileSync(systemDefaultAuthPath, 'utf-8'), {
owner: 'system-default'
})
return
}
const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())
const snapshotAuth = snapshot?.authJson ?? null
const preProvenanceRuntimeRefreshProven =
provenanceStatus.kind === 'missing' &&
snapshotAuth !== null &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) &&
codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth)
const systemDefaultOwnershipProven =
provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven
const mirroredSystemDefaultAuth =
provenance?.owner === 'system-default'
? provenance.authJson
: provenanceStatus.kind === 'missing'
? (this.lastWrittenAuthJson ?? snapshotAuth)
: null
if (!existsSync(systemDefaultAuthPath)) {
const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())
const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null
if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) {
this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath)
return
}
if (
systemDefaultOwnershipProven &&
mirroredSystemDefaultAuth !== null &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth)
) {
@@ -1538,29 +1665,154 @@ export class CodexRuntimeHomeService {
return
}
const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8')
if (runtimeAuth !== systemDefaultAuth) {
const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())
const mirroredSystemDefaultAuth = this.lastWrittenAuthJson ?? snapshot?.authJson ?? null
if (
mirroredSystemDefaultAuth !== null &&
systemDefaultAuth === mirroredSystemDefaultAuth &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth)
) {
// Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones.
this.writeSystemDefaultAuth(runtimeAuth)
this.captureSystemDefaultSnapshot({ force: true })
this.lastWrittenAuthJson = runtimeAuth
return
}
// Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state.
this.captureSystemDefaultSnapshot({ force: true })
this.writeRuntimeAuth(systemDefaultAuth)
if (runtimeAuth === systemDefaultAuth) {
this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' })
return
}
if (
systemDefaultOwnershipProven &&
mirroredSystemDefaultAuth !== null &&
systemDefaultAuth === mirroredSystemDefaultAuth &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth)
) {
// Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones.
this.writeSystemDefaultAuth(runtimeAuth)
this.captureSystemDefaultSnapshot({ force: true })
this.writeRuntimeAuth(runtimeAuth, { owner: 'system-default' })
return
}
// Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state.
this.captureSystemDefaultSnapshot({ force: true })
this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' })
} catch (error) {
console.warn('[codex-runtime-home] Failed to sync system-default auth:', error)
}
}
private syncLegacySharedSystemDefaultAuthForRetainedPanes(): void {
if (this.sharedAuthRefreshBlockedByManagedTransition || this.lastSyncedAccountId !== null) {
this.sharedAuthRefreshBlockedByManagedTransition = false
return
}
const runtimeAuthPath = this.getRuntimeAuthPath()
try {
let provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus()
if (
provenanceStatus.kind === 'committed' &&
provenanceStatus.provenance.owner === 'managed'
) {
const restoredProvenance = this.restoreUntouchedSystemDefaultProvenance(
provenanceStatus.provenance
)
if (restoredProvenance) {
provenanceStatus = { kind: 'committed', provenance: restoredProvenance }
}
}
if (
provenanceStatus.kind === 'fenced' ||
(provenanceStatus.kind === 'committed' && provenanceStatus.provenance.owner === 'managed')
) {
return
}
const systemAuth = this.readSystemDefaultAuth()
if (!existsSync(runtimeAuthPath)) {
const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus()
const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())
const knownSystemAuthBaseline =
provenanceStatus.kind === 'committed' &&
provenanceStatus.provenance.owner === 'system-default'
? provenanceStatus.provenance.authJson
: provenanceStatus.kind === 'missing'
? (this.lastWrittenAuthJson ?? snapshot?.authJson)
: undefined
if (systemAuth === null) {
if (
provenanceStatus.kind === 'committed' &&
provenanceStatus.provenance.owner === 'system-default' &&
provenanceStatus.provenance.authJson === null &&
logoutMarkerStatus.kind === 'applies' &&
snapshot?.authJson === null
) {
this.lastWrittenAuthJson = null
return
}
// Why: commit a crashed logout before a managed transition can discard its recovery baseline.
this.captureSystemDefaultSnapshot({ force: true })
this.persistRuntimeLogoutMarker(null)
this.lastWrittenAuthJson = null
this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null })
return
}
if (
logoutMarkerStatus.kind === 'system-default-changed' ||
(knownSystemAuthBaseline !== undefined && knownSystemAuthBaseline !== systemAuth)
) {
const replaced = this.writeRuntimeAuth(
systemAuth,
{
owner: 'system-default'
},
{ expectedContents: null }
)
if (replaced) {
this.captureSystemDefaultSnapshot({ force: true })
}
}
return
}
const runtimeAuthBeforeSync = readFileSync(runtimeAuthPath, 'utf-8')
const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())
const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null
const knownSharedAuth =
provenance?.owner === 'system-default'
? provenance.authJson
: provenanceStatus.kind === 'missing'
? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null)
: null
// Why: only bytes Orca can prove it wrote belong to the compatibility
// mirror; retained Codex or a managed transition owns every other value.
if (knownSharedAuth === null) {
return
}
const sharedAuthOwnedBySystemDefault =
runtimeAuthBeforeSync === knownSharedAuth ||
(provenance?.owner === 'system-default' &&
systemAuth === null &&
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthBeforeSync, knownSharedAuth))
if (!sharedAuthOwnedBySystemDefault) {
return
}
if (systemAuth === null) {
removeFileAtomicallyIfUnchanged(runtimeAuthPath, runtimeAuthBeforeSync)
if (existsSync(runtimeAuthPath)) {
this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' })
return
}
this.captureSystemDefaultSnapshot({ force: true })
this.persistRuntimeLogoutMarker(null)
this.lastWrittenAuthJson = null
this.persistSharedRuntimeAuthProvenance({
owner: 'system-default',
authJson: null
})
return
}
if (runtimeAuthBeforeSync !== knownSharedAuth) {
return
}
const replaced = this.writeRuntimeAuth(
systemAuth,
{ owner: 'system-default' },
{ expectedContents: runtimeAuthBeforeSync }
)
if (replaced) {
this.captureSystemDefaultSnapshot({ force: true })
}
} catch (error) {
console.warn('[codex-runtime-home] Failed to refresh retained-pane auth:', error)
}
}
private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void {
const snapshotPath = this.getSystemDefaultSnapshotPath()
const runtimeAuthPath = this.getRuntimeAuthPath()
@@ -1568,7 +1820,7 @@ export class CodexRuntimeHomeService {
if (existsSync(systemDefaultAuthPath)) {
const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8')
this.captureSystemDefaultSnapshot({ force: true })
this.writeRuntimeAuth(systemDefaultAuth)
this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' })
return
}
@@ -1576,6 +1828,7 @@ export class CodexRuntimeHomeService {
// Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot.
this.persistRuntimeLogoutMarker()
this.lastWrittenAuthJson = null
this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null })
return
}
@@ -1585,6 +1838,7 @@ export class CodexRuntimeHomeService {
this.captureSystemDefaultSnapshot({ force: true })
this.persistRuntimeLogoutMarker()
this.lastWrittenAuthJson = null
this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null })
return
}
@@ -1601,22 +1855,25 @@ export class CodexRuntimeHomeService {
if (!refreshedSnapshot) {
rmSync(runtimeAuthPath, { force: true })
this.lastWrittenAuthJson = null
this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null })
return
}
if (refreshedSnapshot.authJson === null) {
rmSync(runtimeAuthPath, { force: true })
this.lastWrittenAuthJson = null
this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null })
return
}
this.writeRuntimeAuth(refreshedSnapshot.authJson)
this.writeRuntimeAuth(refreshedSnapshot.authJson, { owner: 'system-default' })
return
}
if (snapshot.authJson === null) {
rmSync(runtimeAuthPath, { force: true })
this.lastWrittenAuthJson = null
this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null })
return
}
this.writeRuntimeAuth(snapshot.authJson)
this.writeRuntimeAuth(snapshot.authJson, { owner: 'system-default' })
}
private writeSystemDefaultAuth(contents: string): void {
@@ -1632,6 +1889,10 @@ export class CodexRuntimeHomeService {
this.captureSystemDefaultSnapshot({ force: true })
this.persistRuntimeLogoutMarker()
this.lastWrittenAuthJson = null
this.persistSharedRuntimeAuthProvenance({
owner: 'system-default',
authJson: null
})
}
private readSystemDefaultAuth(): string | null {
@@ -1639,16 +1900,55 @@ export class CodexRuntimeHomeService {
return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null
}
private writeRuntimeAuth(contents: string): void {
private writeRuntimeAuth(
contents: string,
owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string },
options?: { expectedContents: string | null }
): boolean {
// Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it.
this.clearRuntimeLogoutMarker()
if (this.fileContentsEqual(this.getRuntimeAuthPath(), contents)) {
this.ensureOwnerOnlyMode(this.getRuntimeAuthPath())
const runtimeAuthPath = this.getRuntimeAuthPath()
if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) {
return false
}
const provenance: CodexSharedRuntimeAuthProvenance =
owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner
const runtimeAuthAlreadyMatches = this.fileContentsEqual(runtimeAuthPath, contents)
if (
runtimeAuthAlreadyMatches &&
this.sharedRuntimeAuthProvenanceMatches(
this.resolveSharedRuntimeAuthProvenanceStatus(),
provenance
)
) {
this.ensureOwnerOnlyMode(runtimeAuthPath)
this.lastWrittenAuthJson = contents
return
this.clearRuntimeLogoutMarker()
return true
}
this.persistSharedRuntimeAuthProvenance({
owner: 'pending',
next: provenance,
runtimeAuthJson: contents
})
if (runtimeAuthAlreadyMatches) {
this.ensureOwnerOnlyMode(runtimeAuthPath)
this.lastWrittenAuthJson = contents
this.persistSharedRuntimeAuthProvenance(provenance)
this.clearRuntimeLogoutMarker()
return true
}
const replaced = options
? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, {
mode: 0o600
})
: (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true)
if (!replaced) {
return false
}
writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 })
this.lastWrittenAuthJson = contents
this.persistSharedRuntimeAuthProvenance(provenance)
this.clearRuntimeLogoutMarker()
return true
}
private writeRuntimeAuthAtPath(authPath: string, contents: string): void {
@@ -1668,6 +1968,13 @@ export class CodexRuntimeHomeService {
}
}
private fileContentsMatchExpected(targetPath: string, expectedContents: string | null): boolean {
if (expectedContents === null) {
return !existsSync(targetPath)
}
return this.fileContentsEqual(targetPath, expectedContents)
}
private ensureOwnerOnlyMode(targetPath: string): void {
if (process.platform === 'win32') {
return
@@ -1732,6 +2039,180 @@ export class CodexRuntimeHomeService {
rmSync(this.getRuntimeLogoutMarkerPath(), { force: true })
}
private persistSharedRuntimeAuthProvenance(
provenance: CodexSharedRuntimeAuthProvenanceFile
): void {
writeFileAtomically(
this.getSharedRuntimeAuthProvenancePath(),
`${JSON.stringify(provenance, null, 2)}\n`,
{ mode: 0o600 }
)
}
private markSharedRuntimeAuthManaged(accountId: string): void {
const status = this.resolveSharedRuntimeAuthProvenanceStatus()
if (
status.kind === 'committed' &&
status.provenance.owner === 'managed' &&
status.provenance.accountId === accountId
) {
return
}
const runtimeAuthJson = this.readRuntimeAuthForProvenance()
const systemDefaultBaseline = this.getUntouchedSystemDefaultBaseline(status, runtimeAuthJson)
const provenance: CodexSharedRuntimeAuthProvenance = {
owner: 'managed',
accountId,
...(systemDefaultBaseline ? { systemDefaultBaseline } : {})
}
this.persistSharedRuntimeAuthProvenance({
owner: 'pending',
next: provenance,
runtimeAuthJson
})
if (this.readRuntimeAuthForProvenance() === runtimeAuthJson) {
this.persistSharedRuntimeAuthProvenance(provenance)
}
}
private getUntouchedSystemDefaultBaseline(
status: CodexSharedRuntimeAuthProvenanceStatus,
runtimeAuthJson: string | null
): { authJson: string | null } | null {
if (status.kind !== 'committed') {
return null
}
const baseline =
status.provenance.owner === 'system-default'
? { authJson: status.provenance.authJson }
: status.provenance.systemDefaultBaseline
return baseline && runtimeAuthJson === baseline.authJson ? baseline : null
}
private restoreUntouchedSystemDefaultProvenance(
provenance: Extract<CodexSharedRuntimeAuthProvenance, { owner: 'managed' }>
): Extract<CodexSharedRuntimeAuthProvenance, { owner: 'system-default' }> | null {
const baseline = provenance.systemDefaultBaseline
if (!baseline || this.readRuntimeAuthForProvenance() !== baseline.authJson) {
return null
}
const restored = { owner: 'system-default' as const, authJson: baseline.authJson }
this.persistSharedRuntimeAuthProvenance({
owner: 'pending',
next: restored,
runtimeAuthJson: baseline.authJson
})
if (this.readRuntimeAuthForProvenance() !== baseline.authJson) {
return null
}
this.persistSharedRuntimeAuthProvenance(restored)
return restored
}
private sharedRuntimeAuthProvenanceMatches(
status: CodexSharedRuntimeAuthProvenanceStatus,
expected: CodexSharedRuntimeAuthProvenance
): boolean {
if (status.kind !== 'committed' || status.provenance.owner !== expected.owner) {
return false
}
return expected.owner === 'system-default'
? status.provenance.owner === 'system-default' &&
status.provenance.authJson === expected.authJson
: status.provenance.owner === 'managed' && status.provenance.accountId === expected.accountId
}
private resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus {
const provenancePath = this.getSharedRuntimeAuthProvenancePath()
if (!existsSync(provenancePath)) {
return { kind: 'missing' }
}
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(provenancePath, 'utf-8')) as unknown
} catch {
return { kind: 'fenced' }
}
const committed = this.parseSharedRuntimeAuthProvenance(parsed)
if (committed) {
return { kind: 'committed', provenance: committed }
}
const pending = this.parsePendingSharedRuntimeAuthProvenance(parsed)
if (!pending || this.readRuntimeAuthForProvenance() !== pending.runtimeAuthJson) {
return { kind: 'fenced' }
}
try {
this.persistSharedRuntimeAuthProvenance(pending.next)
return { kind: 'committed', provenance: pending.next }
} catch {
return { kind: 'fenced' }
}
}
private parseSharedRuntimeAuthProvenance(
value: unknown
): CodexSharedRuntimeAuthProvenance | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const provenance = value as Record<string, unknown>
if (
provenance.owner === 'system-default' &&
(typeof provenance.authJson === 'string' || provenance.authJson === null)
) {
return { owner: 'system-default', authJson: provenance.authJson }
}
if (
provenance.owner !== 'managed' ||
typeof provenance.accountId !== 'string' ||
provenance.accountId.length === 0
) {
return null
}
const baseline = this.parseSystemDefaultBaseline(provenance.systemDefaultBaseline)
if ('systemDefaultBaseline' in provenance && !baseline) {
return null
}
return {
owner: 'managed',
accountId: provenance.accountId,
...(baseline ? { systemDefaultBaseline: baseline } : {})
}
}
private parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const baseline = value as Record<string, unknown>
return typeof baseline.authJson === 'string' || baseline.authJson === null
? { authJson: baseline.authJson }
: null
}
private parsePendingSharedRuntimeAuthProvenance(
value: unknown
): CodexSharedRuntimeAuthPendingProvenance | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const pending = value as Record<string, unknown>
const next = this.parseSharedRuntimeAuthProvenance(pending.next)
return pending.owner === 'pending' &&
next &&
(typeof pending.runtimeAuthJson === 'string' || pending.runtimeAuthJson === null)
? { owner: 'pending', next, runtimeAuthJson: pending.runtimeAuthJson }
: null
}
private readRuntimeAuthForProvenance(): string | null {
try {
return readFileSync(this.getRuntimeAuthPath(), 'utf-8')
} catch {
return null
}
}
private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null {
let rawContents: string
try {
@@ -26,6 +26,7 @@ vi.mock('node:os', async () => {
import {
prepareSystemConfigForFreshRuntimeMirror,
resolveCodexConfigMirrorSourceDirectory,
syncSystemConfigIntoLegacySharedCodexHome,
syncSystemConfigIntoManagedCodexHome
} from './codex-config-mirror'
@@ -749,6 +750,23 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
})
})
describe('syncSystemConfigIntoLegacySharedCodexHome', () => {
it('recovers an interrupted runtime config when the system source is missing', () => {
const runtimeConfigPath = getRuntimeConfigPath()
const heldConfigPath = `${runtimeConfigPath}.orca-guarded`
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
writeFileSync(heldConfigPath, 'model = "retained"\n', 'utf-8')
syncSystemConfigIntoLegacySharedCodexHome({
runtimeHomePath: join(userDataDir, 'codex-runtime-home', 'home'),
systemHomePath: getSystemCodexHomePath()
})
expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "retained"\n')
expect(existsSync(heldConfigPath)).toBe(false)
})
})
describe('prepareSystemConfigForFreshRuntimeMirror', () => {
it('uses the Linux-side directory for WSL UNC source homes', () => {
const sourceDir = resolveCodexConfigMirrorSourceDirectory(
+47 -1
View File
@@ -1,7 +1,11 @@
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { readAgentStateFileSync } from '../agent-state-file-reader'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import {
recoverInterruptedGuardedFileOperation,
writeFileAtomically,
writeFileAtomicallyIfUnchanged
} from '../codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite'
import { normalizeDeprecatedCodexHookFeatureFlag } from './config-toml-deprecated-hook-flag'
@@ -87,6 +91,48 @@ export function syncSystemConfigIntoManagedCodexHome(
)
}
/**
* Refreshes the retired shared home for PTYs that survived real-home rollout.
*
* This is deliberately one-way: a retained PTY may hold pre-rollout settings,
* so treating that home as a promotion source could overwrite the live config.
*/
export function syncSystemConfigIntoLegacySharedCodexHome(
homes: CodexSettingsPromotionHomes = {
runtimeHomePath: getOrcaManagedCodexHomePath(),
systemHomePath: getSystemCodexHomePath()
}
): void {
const systemConfigPath = join(homes.systemHomePath, 'config.toml')
const runtimeConfigPath = join(homes.runtimeHomePath, 'config.toml')
recoverInterruptedGuardedFileOperation(runtimeConfigPath)
const rawSystemConfig = existsSync(systemConfigPath)
? readAgentStateFileSync(systemConfigPath)
: ''
// Why: a missing cloud-synced source is not proof the user cleared config.
if (rawSystemConfig.trim() === '') {
return
}
const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(homes.systemHomePath)
const runtimeConfigBeforeMirror = existsSync(runtimeConfigPath)
? readAgentStateFileSync(runtimeConfigPath)
: null
const nextRuntimeConfig =
runtimeConfigBeforeMirror !== null
? mergeSystemCodexConfigIntoRuntime(
runtimeConfigBeforeMirror,
prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir)
)
: prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
if (runtimeConfigBeforeMirror === nextRuntimeConfig) {
return
}
// Why: stage first, then compare immediately before replace so a retained
// Codex trust write during mirror preparation wins.
writeFileAtomicallyIfUnchanged(runtimeConfigPath, runtimeConfigBeforeMirror, nextRuntimeConfig)
}
type CodexConfigMirrorResult =
| { status: 'skipped-missing-source' }
| { status: 'mirrored'; preservedConflictKeys: ReadonlySet<string> }
+117 -4
View File
@@ -1,6 +1,10 @@
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { getOrcaUserDataPath } from './codex-home-paths'
import type {
CodexEnvironmentHomeOverride,
CodexShellStartupHomeOverride
} from './codex-real-home-path'
/**
* Remembers which Codex account each live PTY was launched under.
@@ -12,15 +16,28 @@ import { getOrcaUserDataPath } from './codex-home-paths'
* the old account and the user is stuck there with no prompt to escape it.
*/
export type CodexPaneHomeRoute =
| 'real-home'
| 'shared-home'
| 'account-home'
| 'custom-home'
| 'wsl-home'
export type CodexPaneAccountRecord = {
/** 'host' or 'wsl:<distro>' — the selection lane this pane launched from. */
selectionKey: string
/** Managed account id, or null for the system-default account. */
accountId: string | null
/** Absent only on records written before route provenance was introduced. */
homeRoute?: CodexPaneHomeRoute
/** Rechecked when CODEX_HOME came from process-global shell startup. */
shellStartupHomeOverride?: CodexShellStartupHomeOverride
/** Rechecked after restart when CODEX_HOME came from the process environment. */
environmentHomeOverride?: CodexEnvironmentHomeOverride
}
type RegistryFile = {
version: 1
version: 2
panes: Record<string, CodexPaneAccountRecord>
}
@@ -51,7 +68,7 @@ function readRegistryFile(): unknown {
}
function parseRegistry(parsed: unknown): RegistryFile {
const empty: RegistryFile = { version: 1, panes: {} }
const empty: RegistryFile = { version: 2, panes: {} }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return empty
}
@@ -61,12 +78,44 @@ function parseRegistry(parsed: unknown): RegistryFile {
}
for (const [ptyId, record] of Object.entries(panes)) {
if (isPaneAccountRecord(record)) {
empty.panes[ptyId] = { selectionKey: record.selectionKey, accountId: record.accountId }
empty.panes[ptyId] = {
selectionKey: record.selectionKey,
accountId: record.accountId,
...(isPaneHomeRoute(record.homeRoute) ? { homeRoute: record.homeRoute } : {}),
...(isShellStartupHomeOverride(record.shellStartupHomeOverride)
? { shellStartupHomeOverride: record.shellStartupHomeOverride }
: {}),
...(isEnvironmentHomeOverride(record.environmentHomeOverride)
? { environmentHomeOverride: record.environmentHomeOverride }
: {})
}
}
}
return empty
}
function isEnvironmentHomeOverride(value: unknown): value is CodexEnvironmentHomeOverride {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const context = value as Partial<CodexEnvironmentHomeOverride>
return typeof context.codexHome === 'string' && context.codexHome.length > 0
}
function isShellStartupHomeOverride(value: unknown): value is CodexShellStartupHomeOverride {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
const context = value as Partial<CodexShellStartupHomeOverride>
return (
typeof context.home === 'string' &&
context.home.length > 0 &&
(context.shell === undefined || typeof context.shell === 'string') &&
typeof context.codexHome === 'string' &&
context.codexHome.length > 0
)
}
function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
@@ -78,6 +127,16 @@ function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord {
)
}
function isPaneHomeRoute(value: unknown): value is CodexPaneHomeRoute {
return (
value === 'real-home' ||
value === 'shared-home' ||
value === 'account-home' ||
value === 'custom-home' ||
value === 'wsl-home'
)
}
function writeRegistry(registry: RegistryFile): void {
const registryPath = getRegistryPath()
const temporaryPath = `${registryPath}.${process.pid}.tmp`
@@ -112,7 +171,16 @@ export function recordCodexPaneAccount(ptyId: string, record: CodexPaneAccountRe
return
}
const existing = registry.panes[ptyId]
if (existing?.selectionKey === record.selectionKey && existing.accountId === record.accountId) {
if (
existing?.selectionKey === record.selectionKey &&
existing.accountId === record.accountId &&
existing.homeRoute === record.homeRoute &&
shellStartupHomeOverridesEqual(
existing.shellStartupHomeOverride,
record.shellStartupHomeOverride
) &&
environmentHomeOverridesEqual(existing.environmentHomeOverride, record.environmentHomeOverride)
) {
return
}
registry.panes[ptyId] = record
@@ -125,6 +193,24 @@ export function recordCodexPaneAccount(ptyId: string, record: CodexPaneAccountRe
writeRegistry(registry)
}
function environmentHomeOverridesEqual(
left: CodexEnvironmentHomeOverride | undefined,
right: CodexEnvironmentHomeOverride | undefined
): boolean {
return left?.codexHome === right?.codexHome
}
function shellStartupHomeOverridesEqual(
left: CodexShellStartupHomeOverride | undefined,
right: CodexShellStartupHomeOverride | undefined
): boolean {
return (
left?.home === right?.home &&
left?.shell === right?.shell &&
left?.codexHome === right?.codexHome
)
}
/**
* Drops a PTY's record once it exits.
*/
@@ -159,6 +245,33 @@ export function listRecordedCodexPaneLanes(ptyIds: readonly string[]): Record<st
return lanesByPtyId
}
/** True when a retained host pane may still read the retired shared CODEX_HOME. */
export function hasRecordedLegacySharedCodexPane(): boolean {
return Object.values(readRegistry().panes).some(
(record) =>
record.selectionKey === 'host' &&
(record.homeRoute === undefined ||
record.homeRoute === 'shared-home' ||
record.homeRoute === 'custom-home')
)
}
/** Drops records whose daemon PTYs are authoritatively absent. */
export function reconcileCodexPaneAccountsWithLivePtys(livePtyIds: readonly string[]): void {
const registry = readRegistry()
const livePtyIdSet = new Set(livePtyIds)
let changed = false
for (const ptyId of Object.keys(registry.panes)) {
if (!livePtyIdSet.has(ptyId)) {
delete registry.panes[ptyId]
changed = true
}
}
if (changed) {
writeRegistry(registry)
}
}
export const _internals = {
resetCache: (): void => {
cachedRegistry = null
@@ -35,10 +35,22 @@ describe('resolveCodexPaneLaunchAccount', () => {
pinnedByResume: false,
launchCodexHomePath: '/data/codex-accounts/account-b/home',
systemCodexHomePath: SYSTEM_HOME,
settings: settings({ host: 'account-b' }),
settings: settings({
host: 'account-b',
accounts: [
managedAccount({
id: 'account-b',
managedHomePath: '/data/codex-accounts/account-b/home'
})
]
}),
target: { runtime: 'host' }
})
).toEqual({ selectionKey: 'host', accountId: 'account-b' })
).toEqual({
selectionKey: 'host',
accountId: 'account-b',
homeRoute: 'account-home'
})
})
it('records the origin account a resume pinned the pane to, not the selection', () => {
@@ -55,7 +67,39 @@ describe('resolveCodexPaneLaunchAccount', () => {
settings: settings({ host: 'account-b', accounts }),
target: { runtime: 'host' }
})
).toEqual({ selectionKey: 'host', accountId: 'account-a' })
).toEqual({
selectionKey: 'host',
accountId: 'account-a',
homeRoute: 'account-home'
})
})
it('records a non-comparable route for a pane-local custom home', () => {
expect(
resolveCodexPaneLaunchAccount({
pinnedByResume: false,
launchCodexHomePath: '/data/codex-runtime-home/home',
recordComparableHomeRoute: false,
systemCodexHomePath: SYSTEM_HOME,
settings: settings({ host: null }),
target: { runtime: 'host' }
})
).toEqual({ selectionKey: 'host', accountId: null, homeRoute: 'custom-home' })
})
it('keeps an account-owned route comparable when a pane override is ignored', () => {
const accounts = [managedAccount({ id: 'account-a' })]
expect(
resolveCodexPaneLaunchAccount({
pinnedByResume: false,
launchCodexHomePath: '/data/codex-accounts/account-a/home',
recordComparableHomeRoute: false,
systemCodexHomePath: SYSTEM_HOME,
settings: settings({ host: 'account-a', accounts }),
target: { runtime: 'host' }
})
).toEqual({ selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' })
})
it('records the same account a resume pinned to when it is already selected', () => {
@@ -71,7 +115,11 @@ describe('resolveCodexPaneLaunchAccount', () => {
settings: settings({ host: 'account-a', accounts }),
target: { runtime: 'host' }
})
).toEqual({ selectionKey: 'host', accountId: 'account-a' })
).toEqual({
selectionKey: 'host',
accountId: 'account-a',
homeRoute: 'account-home'
})
})
it('maps a resume redirected to the real system home to the system-default account', () => {
@@ -86,7 +134,7 @@ describe('resolveCodexPaneLaunchAccount', () => {
}),
target: { runtime: 'host' }
})
).toEqual({ selectionKey: 'host', accountId: null })
).toEqual({ selectionKey: 'host', accountId: null, homeRoute: 'real-home' })
})
it('maps a resume that injects no CODEX_HOME to the system-default account', () => {
@@ -98,7 +146,7 @@ describe('resolveCodexPaneLaunchAccount', () => {
settings: settings({ host: 'account-a', accounts: [managedAccount({ id: 'account-a' })] }),
target: { runtime: 'host' }
})
).toEqual({ selectionKey: 'host', accountId: null })
).toEqual({ selectionKey: 'host', accountId: null, homeRoute: 'real-home' })
})
it('refuses to attribute a resume home no account owns', () => {
@@ -150,7 +198,11 @@ describe('resolveCodexPaneLaunchAccount', () => {
settings: settings({ wsl: { Ubuntu: 'other-wsl-account' }, accounts }),
target: { runtime: 'wsl', wslDistro: 'Ubuntu' }
})
).toEqual({ selectionKey: 'wsl:Ubuntu', accountId: 'wsl-account' })
).toEqual({
selectionKey: 'wsl:Ubuntu',
accountId: 'wsl-account',
homeRoute: 'wsl-home'
})
})
it('tolerates settings that carry no managed account roster', () => {
+59 -3
View File
@@ -6,7 +6,11 @@ import {
getSelectedCodexAccountIdForTarget,
type CodexAccountSelectionTarget
} from '../codex-accounts/runtime-selection'
import type { CodexPaneAccountRecord } from './codex-pane-account-registry'
import type { CodexPaneAccountRecord, CodexPaneHomeRoute } from './codex-pane-account-registry'
import type {
CodexEnvironmentHomeOverride,
CodexShellStartupHomeOverride
} from './codex-real-home-path'
type CodexPaneLaunchAccountSettings = Pick<
GlobalSettings,
@@ -28,19 +32,71 @@ type CodexPaneLaunchAccountSettings = Pick<
export function resolveCodexPaneLaunchAccount(args: {
pinnedByResume: boolean
launchCodexHomePath: string | null
recordComparableHomeRoute?: boolean
shellStartupHomeOverride?: CodexShellStartupHomeOverride
environmentHomeOverride?: CodexEnvironmentHomeOverride
systemCodexHomePath: string
settings: CodexPaneLaunchAccountSettings
target: CodexAccountSelectionTarget
}): CodexPaneAccountRecord | null {
const selectionKey = getCodexSelectionLaneKey(args.target)
const resolvedHomeRoute = resolveCodexPaneHomeRoute(args)
const homeRoute =
args.recordComparableHomeRoute === false && resolvedHomeRoute === 'shared-home'
? 'custom-home'
: resolvedHomeRoute
if (!args.pinnedByResume) {
return {
selectionKey,
accountId: getSelectedCodexAccountIdForTarget(args.settings, args.target)
accountId: getSelectedCodexAccountIdForTarget(args.settings, args.target),
...(homeRoute ? { homeRoute } : {}),
...(args.shellStartupHomeOverride
? { shellStartupHomeOverride: args.shellStartupHomeOverride }
: {}),
...(args.environmentHomeOverride
? { environmentHomeOverride: args.environmentHomeOverride }
: {})
}
}
const accountId = resolveCodexHomeOwnerAccountId(args)
return accountId === undefined ? null : { selectionKey, accountId }
return accountId === undefined
? null
: {
selectionKey,
accountId,
...(homeRoute ? { homeRoute } : {}),
...(args.shellStartupHomeOverride
? { shellStartupHomeOverride: args.shellStartupHomeOverride }
: {}),
...(args.environmentHomeOverride
? { environmentHomeOverride: args.environmentHomeOverride }
: {})
}
}
function resolveCodexPaneHomeRoute(args: {
launchCodexHomePath: string | null
systemCodexHomePath: string
settings: CodexPaneLaunchAccountSettings
target: CodexAccountSelectionTarget
}): CodexPaneHomeRoute {
if (args.target.runtime === 'wsl') {
return 'wsl-home'
}
if (
!args.launchCodexHomePath ||
normalizeRuntimePathForComparison(args.launchCodexHomePath) ===
normalizeRuntimePathForComparison(args.systemCodexHomePath)
) {
return 'real-home'
}
const launchHome = normalizeRuntimePathForComparison(args.launchCodexHomePath)
const accountOwnsHome = args.settings.codexManagedAccounts?.some(
(account) =>
getCodexSelectionTargetForAccount(account).runtime === 'host' &&
normalizeRuntimePathForComparison(account.managedHomePath) === launchHome
)
return accountOwnsHome ? 'account-home' : 'shared-home'
}
/** undefined when no account owns the home; null means the system-default account. */
+56 -3
View File
@@ -1,6 +1,23 @@
import { sep } from 'node:path'
import { describe, expect, it } from 'vitest'
import { hasCustomCodexHomeOverride } from './codex-real-home-path'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
getCustomCodexHomeOverrideForLaunch,
hasCustomCodexHomeOverride,
hasCustomCodexHomeOverrideForLaunch,
shellStartupCodexHomeOverrideMatches
} from './codex-real-home-path'
import { __resetShellStartupEnvCache } from '../pty/shell-startup-env'
const temporaryHomes: string[] = []
afterEach(() => {
__resetShellStartupEnvCache()
for (const path of temporaryHomes.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('hasCustomCodexHomeOverride', () => {
it('recognizes normalized aliases of Orca-owned CODEX_HOME', () => {
@@ -22,4 +39,40 @@ describe('hasCustomCodexHomeOverride', () => {
})
).toBe(true)
})
it('captures explicit environment provenance for restart comparison', () => {
const codexHome = join(process.cwd(), 'custom-codex-home')
expect(getCustomCodexHomeOverrideForLaunch({ CODEX_HOME: codexHome })).toEqual({
source: 'environment',
context: { codexHome }
})
})
it.skipIf(process.platform === 'win32')(
'detects a pane-local shell startup override from its launch HOME',
() => {
const paneHome = mkdtempSync(join(tmpdir(), 'orca-codex-pane-home-'))
temporaryHomes.push(paneHome)
writeFileSync(join(paneHome, '.zshrc'), 'export CODEX_HOME="$HOME/custom-codex-home"\n')
expect(hasCustomCodexHomeOverrideForLaunch({ HOME: paneHome, SHELL: '/bin/zsh' })).toBe(true)
const override = getCustomCodexHomeOverrideForLaunch({
HOME: paneHome,
SHELL: '/bin/zsh'
})
expect(override).toEqual({
source: 'shell-startup',
context: {
home: paneHome,
shell: '/bin/zsh',
codexHome: join(paneHome, 'custom-codex-home')
}
})
expect(
override?.source === 'shell-startup' &&
shellStartupCodexHomeOverrideMatches(override.context)
).toBe(true)
}
)
})
+94
View File
@@ -1,5 +1,20 @@
import { resolve } from 'node:path'
import { getSystemCodexHomePath } from './codex-home-paths'
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
export type CodexShellStartupHomeOverride = {
home: string
shell?: string
codexHome: string
}
export type CodexEnvironmentHomeOverride = {
codexHome: string
}
export type CustomCodexHomeOverrideForLaunch =
| { source: 'environment'; context: CodexEnvironmentHomeOverride }
| { source: 'shell-startup'; context: CodexShellStartupHomeOverride }
/** True when the user points Codex outside its standard native home. */
export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env): boolean {
@@ -18,6 +33,85 @@ export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env)
)
}
export function hasCustomCodexHomeOverrideForLaunch(launchEnv?: NodeJS.ProcessEnv): boolean {
return getCustomCodexHomeOverrideForLaunch(launchEnv) !== null
}
export function getCustomCodexHomeOverrideForLaunch(
launchEnv?: NodeJS.ProcessEnv
): CustomCodexHomeOverrideForLaunch | null {
const effectiveEnv = launchEnv
? {
CODEX_HOME: getLaunchEnvValue(launchEnv, 'CODEX_HOME'),
ORCA_CODEX_HOME: getLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME')
}
: process.env
if (hasCustomCodexHomeOverride(effectiveEnv)) {
return {
source: 'environment',
context: { codexHome: effectiveEnv.CODEX_HOME!.trim() }
}
}
const home = launchEnv ? getLaunchEnvValue(launchEnv, 'HOME') : process.env.HOME
const shell = launchEnv ? getLaunchEnvValue(launchEnv, 'SHELL') : process.env.SHELL
const shellCodexHome = readShellStartupEnvVar('CODEX_HOME', home, shell)
if (!home || !shellCodexHome || !hasCustomCodexHomeOverride({ CODEX_HOME: shellCodexHome })) {
return null
}
return {
source: 'shell-startup',
context: {
home,
...(shell ? { shell } : {}),
codexHome: shellCodexHome
}
}
}
export function environmentCodexHomeOverrideContextsEqual(
left: CodexEnvironmentHomeOverride,
right: CodexEnvironmentHomeOverride
): boolean {
return normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome)
}
export function shellStartupCodexHomeOverrideMatches(
context: CodexShellStartupHomeOverride,
currentContext: CodexShellStartupHomeOverride = context
): boolean {
if (!shellStartupCodexHomeOverrideContextsEqual(context, currentContext)) {
return false
}
const currentCodexHome = readShellStartupEnvVar(
'CODEX_HOME',
currentContext.home,
currentContext.shell
)
return Boolean(
currentCodexHome &&
hasCustomCodexHomeOverride({ CODEX_HOME: currentCodexHome }) &&
normalizePathForComparison(currentCodexHome) === normalizePathForComparison(context.codexHome)
)
}
export function shellStartupCodexHomeOverrideContextsEqual(
left: CodexShellStartupHomeOverride,
right: CodexShellStartupHomeOverride
): boolean {
return (
normalizePathForComparison(left.home) === normalizePathForComparison(right.home) &&
left.shell === right.shell &&
normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome)
)
}
function getLaunchEnvValue(
launchEnv: NodeJS.ProcessEnv,
key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL'
): string | undefined {
return Object.prototype.hasOwnProperty.call(launchEnv, key) ? launchEnv[key] : process.env[key]
}
function normalizePathForComparison(value: string): string {
const normalized = resolve(value)
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { GlobalSettings } from '../../shared/types'
@@ -7,9 +7,12 @@ import {
_internals,
forgetCodexPaneAccount,
getCodexPaneAccount,
hasRecordedLegacySharedCodexPane,
reconcileCodexPaneAccountsWithLivePtys,
recordCodexPaneAccount
} from './codex-pane-account-registry'
import { forgetStaleCodexPanes, listStaleCodexPanes } from './codex-stale-pane-accounts'
import { __resetShellStartupEnvCache } from '../pty/shell-startup-env'
let userDataPath: string
let previousUserDataPath: string | undefined
@@ -32,6 +35,7 @@ beforeEach(() => {
})
afterEach(() => {
__resetShellStartupEnvCache()
rmSync(userDataPath, { recursive: true, force: true })
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
@@ -43,11 +47,105 @@ afterEach(() => {
describe('codex pane account registry', () => {
it('survives a process restart so a daemon-backed shell stays attributable', () => {
recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' })
recordCodexPaneAccount('pty-1', {
selectionKey: 'host',
accountId: 'account-a',
homeRoute: 'account-home',
shellStartupHomeOverride: {
home: '/pane-home',
shell: '/bin/zsh',
codexHome: '/pane-home/custom-codex-home'
}
})
_internals.resetCache()
expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: 'account-a' })
expect(getCodexPaneAccount('pty-1')).toEqual({
selectionKey: 'host',
accountId: 'account-a',
homeRoute: 'account-home',
shellStartupHomeOverride: {
home: '/pane-home',
shell: '/bin/zsh',
codexHome: '/pane-home/custom-codex-home'
}
})
})
it('keeps pre-route records readable without inventing provenance', () => {
writeFileSync(
join(userDataPath, 'codex-pane-accounts.json'),
JSON.stringify({
version: 1,
panes: { 'pty-1': { selectionKey: 'host', accountId: null } }
})
)
_internals.resetCache()
expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: null })
expect(hasRecordedLegacySharedCodexPane()).toBe(true)
})
it('runs legacy reconciliation only for host panes that may use the shared home', () => {
recordCodexPaneAccount('pty-real', {
selectionKey: 'host',
accountId: null,
homeRoute: 'real-home'
})
recordCodexPaneAccount('pty-account', {
selectionKey: 'host',
accountId: 'account-a',
homeRoute: 'account-home'
})
recordCodexPaneAccount('pty-wsl', {
selectionKey: 'wsl:Ubuntu',
accountId: null,
homeRoute: 'wsl-home'
})
expect(hasRecordedLegacySharedCodexPane()).toBe(false)
recordCodexPaneAccount('pty-custom', {
selectionKey: 'host',
accountId: null,
homeRoute: 'custom-home'
})
expect(hasRecordedLegacySharedCodexPane()).toBe(true)
forgetCodexPaneAccount('pty-custom')
expect(hasRecordedLegacySharedCodexPane()).toBe(false)
recordCodexPaneAccount('pty-shared', {
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home'
})
expect(hasRecordedLegacySharedCodexPane()).toBe(true)
})
it('drops leaked records that are absent from the authoritative daemon inventory', () => {
recordCodexPaneAccount('pty-live', {
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home'
})
recordCodexPaneAccount('pty-dead', {
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home'
})
reconcileCodexPaneAccountsWithLivePtys(['pty-live'])
_internals.resetCache()
expect(getCodexPaneAccount('pty-live')).not.toBeNull()
expect(getCodexPaneAccount('pty-dead')).toBeNull()
expect(hasRecordedLegacySharedCodexPane()).toBe(true)
reconcileCodexPaneAccountsWithLivePtys([])
expect(hasRecordedLegacySharedCodexPane()).toBe(false)
})
it('forgets a PTY so a reused id cannot inherit a dead pane account', () => {
@@ -125,7 +223,14 @@ describe('listStaleCodexPanes', () => {
ptyIds: ['pty-1'],
settings: settingsWithSelection('account-b')
})
).toEqual([{ ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: 'account-b' }])
).toEqual([
{
ptyId: 'pty-1',
launchAccountId: 'account-a',
activeAccountId: 'account-b',
reason: 'account-change'
}
])
})
it('reports a managed pane after the selection drops to the system default', () => {
@@ -133,7 +238,14 @@ describe('listStaleCodexPanes', () => {
expect(
listStaleCodexPanes({ ptyIds: ['pty-1'], settings: settingsWithSelection(null) })
).toEqual([{ ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: null }])
).toEqual([
{
ptyId: 'pty-1',
launchAccountId: 'account-a',
activeAccountId: null,
reason: 'account-change'
}
])
})
it('leaves a pane alone when its launch account is still selected', () => {
@@ -144,6 +256,184 @@ describe('listStaleCodexPanes', () => {
).toEqual([])
})
it('reports a system-default pane after its home route changes', () => {
recordCodexPaneAccount('pty-1', {
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home'
})
expect(
listStaleCodexPanes({
ptyIds: ['pty-1'],
settings: settingsWithSelection(null),
activeHostHomeRoute: 'real-home'
})
).toEqual([
{
ptyId: 'pty-1',
launchAccountId: null,
activeAccountId: null,
reason: 'home-route-change'
}
])
})
it('keeps account-switch copy when the account and home route both change', () => {
recordCodexPaneAccount('pty-1', {
selectionKey: 'host',
accountId: null,
homeRoute: 'real-home'
})
expect(
listStaleCodexPanes({
ptyIds: ['pty-1'],
settings: settingsWithSelection('account-a'),
activeHostHomeRoute: 'account-home'
})
).toEqual([
{
ptyId: 'pty-1',
launchAccountId: null,
activeAccountId: 'account-a',
reason: 'account-change'
}
])
})
it('does not guess a route for panes recorded before route provenance', () => {
recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: null })
expect(
listStaleCodexPanes({
ptyIds: ['pty-1'],
settings: settingsWithSelection(null),
activeHostHomeRoute: 'real-home'
})
).toEqual([])
})
it('does not compare a pane-local custom home with the selected host route', () => {
recordCodexPaneAccount('pty-1', {
selectionKey: 'host',
accountId: null,
homeRoute: 'custom-home'
})
expect(
listStaleCodexPanes({
ptyIds: ['pty-1'],
settings: settingsWithSelection(null),
activeHostHomeRoute: 'real-home'
})
).toEqual([])
})
it('does not report a custom-home spelling change that keeps the shared route', () => {
recordCodexPaneAccount('pty-1', {
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home',
environmentHomeOverride: { codexHome: '/custom/codex-a' }
})
expect(
listStaleCodexPanes({
ptyIds: ['pty-1'],
settings: settingsWithSelection(null),
activeHostHomeRoute: 'shared-home'
})
).toEqual([])
})
it('leaves a retained pane alone while its process CODEX_HOME is unchanged', () => {
recordCodexPaneAccount('pty-1', {
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home',
environmentHomeOverride: { codexHome: '/custom/codex-home' }
})
expect(
listStaleCodexPanes({
ptyIds: ['pty-1'],
settings: settingsWithSelection(null),
activeHostHomeRoute: 'shared-home'
})
).toEqual([])
})
it('reports when removing a custom home changes the resolved route', () => {
recordCodexPaneAccount('pty-1', {
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home',
environmentHomeOverride: { codexHome: '/custom/codex-home' }
})
expect(
listStaleCodexPanes({
ptyIds: ['pty-1'],
settings: settingsWithSelection(null),
activeHostHomeRoute: 'real-home'
})
).toEqual([
{
ptyId: 'pty-1',
launchAccountId: null,
activeAccountId: null,
reason: 'home-route-change'
}
])
})
it.skipIf(process.platform === 'win32')(
'reports a retained pane after its shell startup CODEX_HOME is removed',
() => {
const paneHome = join(userDataPath, 'pane-home')
mkdirSync(paneHome, { recursive: true })
const startupPath = join(paneHome, '.zshrc')
const customHome = join(paneHome, 'custom-codex-home')
writeFileSync(startupPath, 'export CODEX_HOME="$HOME/custom-codex-home"\n')
recordCodexPaneAccount('pty-1', {
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home',
shellStartupHomeOverride: {
home: paneHome,
shell: '/bin/zsh',
codexHome: customHome
}
})
expect(
listStaleCodexPanes({
ptyIds: ['pty-1'],
settings: settingsWithSelection(null),
activeHostHomeRoute: 'shared-home'
})
).toEqual([])
writeFileSync(startupPath, '')
__resetShellStartupEnvCache()
expect(
listStaleCodexPanes({
ptyIds: ['pty-1'],
settings: settingsWithSelection(null),
activeHostHomeRoute: 'real-home'
})
).toEqual([
{
ptyId: 'pty-1',
launchAccountId: null,
activeAccountId: null,
reason: 'home-route-change'
}
])
}
)
it('never reports an unrecorded PTY, so an upgrade cannot invent a prompt', () => {
expect(
listStaleCodexPanes({ ptyIds: ['pty-unknown'], settings: settingsWithSelection('account-b') })
@@ -163,7 +453,14 @@ describe('listStaleCodexPanes', () => {
ptyIds: ['pty-1', 'pty-2'],
settings: settingsWithSelection('account-b')
})
).toEqual([{ ptyId: 'pty-2', launchAccountId: 'account-a', activeAccountId: 'account-b' }])
).toEqual([
{
ptyId: 'pty-2',
launchAccountId: 'account-a',
activeAccountId: 'account-b',
reason: 'account-change'
}
])
})
it('compares a WSL pane against its own distro selection', () => {
@@ -177,6 +474,13 @@ describe('listStaleCodexPanes', () => {
// distro's switch must not restart another distro's panes.
settings: settingsWithSelection('account-b', { Ubuntu: 'account-a', Debian: 'account-d' })
})
).toEqual([{ ptyId: 'pty-2', launchAccountId: 'account-c', activeAccountId: 'account-d' }])
).toEqual([
{
ptyId: 'pty-2',
launchAccountId: 'account-c',
activeAccountId: 'account-d',
reason: 'account-change'
}
])
})
})
+21 -3
View File
@@ -1,11 +1,16 @@
import type { GlobalSettings } from '../../shared/types'
import { getSelectedCodexAccountIdForTarget } from '../codex-accounts/runtime-selection'
import { forgetCodexPaneAccount, getCodexPaneAccount } from './codex-pane-account-registry'
import {
forgetCodexPaneAccount,
getCodexPaneAccount,
type CodexPaneHomeRoute
} from './codex-pane-account-registry'
export type StaleCodexPane = {
ptyId: string
launchAccountId: string | null
activeAccountId: string | null
reason: 'account-change' | 'home-route-change'
}
/**
@@ -15,6 +20,7 @@ export type StaleCodexPane = {
export function listStaleCodexPanes(args: {
ptyIds: readonly string[]
settings: GlobalSettings
activeHostHomeRoute?: CodexPaneHomeRoute
}): StaleCodexPane[] {
const stalePanes: StaleCodexPane[] = []
for (const ptyId of args.ptyIds) {
@@ -26,8 +32,20 @@ export function listStaleCodexPanes(args: {
args.settings,
parseSelectionLaneKey(record.selectionKey)
)
if (record.accountId !== activeAccountId) {
stalePanes.push({ ptyId, launchAccountId: record.accountId, activeAccountId })
const homeRouteChanged =
record.selectionKey === 'host' &&
record.homeRoute !== undefined &&
record.homeRoute !== 'custom-home' &&
args.activeHostHomeRoute !== undefined &&
record.homeRoute !== args.activeHostHomeRoute
const accountChanged = record.accountId !== activeAccountId
if (accountChanged || homeRouteChanged) {
stalePanes.push({
ptyId,
launchAccountId: record.accountId,
activeAccountId,
reason: accountChanged ? 'account-change' : 'home-route-change'
})
}
}
return stalePanes
+20
View File
@@ -830,6 +830,26 @@ export function getDaemonProvider(): DaemonProvider | null {
return adapter
}
/** Returns null unless every daemon generation supplied an authoritative inventory. */
export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
if (!adapter) {
return null
}
const adapters =
adapter instanceof DaemonPtyRouter || adapter instanceof DegradedDaemonPtyProvider
? adapter.getAllAdapters()
: [adapter]
const inventories = await Promise.allSettled(
adapters.map((daemonAdapter) => daemonAdapter.listProcesses())
)
if (inventories.some((inventory) => inventory.status === 'rejected')) {
return null
}
return inventories.flatMap((inventory) =>
inventory.status === 'fulfilled' ? inventory.value.map((process) => process.id) : []
)
}
// Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference.
export function replaceDaemonProvider(newAdapter: DaemonProvider): void {
adapter = newAdapter
+26 -8
View File
@@ -28,14 +28,20 @@ import {
registerPaneKeyTeardownListener,
getLocalPtyProvider,
getSshPtyProvider,
registerHeadlessPtyRuntime
registerHeadlessPtyRuntime,
type CodexHomeLaunchContext
} from './ipc/pty'
import {
initDaemonPtyProvider,
disconnectDaemon,
getDaemonProvider,
listLiveDaemonPtyIds,
shutdownDaemon
} from './daemon/daemon-init'
import {
hasRecordedLegacySharedCodexPane,
reconcileCodexPaneAccountsWithLivePtys
} from './codex/codex-pane-account-registry'
import { closeAllWatchers } from './ipc/filesystem-watcher'
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
import { registerCoreHandlers } from './ipc/register-core-handlers'
@@ -98,7 +104,7 @@ import {
resolveUpdateInstallMode
} from './updater'
import { configureRemoteServerUpdater } from './runtime/remote-server-updater'
import type { TuiAgent, UpdateCheckOptions } from '../shared/types'
import type { UpdateCheckOptions } from '../shared/types'
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
import {
installServeSupervisorDisconnectQuit,
@@ -843,6 +849,14 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
await initDaemonPtyProvider(signal, {
macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode
})
if (codexRuntimeHome?.isHostSystemDefaultRealHome() && hasRecordedLegacySharedCodexPane()) {
const livePtyIds = await listLiveDaemonPtyIds()
if (livePtyIds) {
reconcileCodexPaneAccountsWithLivePtys(livePtyIds)
}
}
// Why: retained shells can invoke Codex immediately after the startup gate.
codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()
logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' })
},
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect.
@@ -890,7 +904,7 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
function prepareCodexRuntimeHomeForLaunch(
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv,
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
launchContext?: CodexHomeLaunchContext
): string | null {
if (
target?.runtime !== 'wsl' &&
@@ -922,14 +936,18 @@ function prepareCodexRuntimeHomeForLaunch(
return true
}
let realHomeHooksPrepared = ensureRealHomeHooksIfSelected()
let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv)
let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, {
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
})
if (runtimeHomePath === null && !realHomeHooksPrepared) {
// Why: a managed home can lose auth during launch prep, which clears its
// selection and falls through to real home. Establish hook capability for
// that newly selected lane, then re-resolve if the capability gate rejects it.
// Why: launch prep can reject an untrusted managed home and clear its
// selection. Establish hook capability for that newly selected lane, then
// re-resolve if the capability gate rejects it.
realHomeHooksPrepared = ensureRealHomeHooksIfSelected()
if (realHomeHooksPrepared) {
runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv)
runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, {
unavailableManagedHomePath: launchContext?.unavailableManagedHomePath
})
}
}
if (runtimeHomePath === null && target?.runtime !== 'wsl') {
+2 -1
View File
@@ -16,7 +16,8 @@ export function registerCodexAccountHandlers(
}
return listStaleCodexPanes({
ptyIds: args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string'),
settings
settings,
activeHostHomeRoute: codexAccounts.runtimeHomeService.getSelectedHostCodexHomeRoute()
})
})
ipcMain.handle('codexAccounts:listRecordedPaneLanes', (_event, args: { ptyIds?: unknown }) => {
+639 -5
View File
@@ -226,6 +226,7 @@ import {
setPtyOwnership,
setLocalPtyProvider,
rebindLocalProviderListeners,
resolveCodexHomeAfterManagedAuthReadiness,
unregisterSshPtyProvider,
getLocalPtyProvider,
isCurrentPtyExit,
@@ -248,6 +249,7 @@ import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl'
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
import { acquireWatcherRemovalGate } from './watcher-removal-gate'
import { __resetShellStartupEnvCache } from '../pty/shell-startup-env'
import {
acceptSshPtyOutputData,
acceptSshPtyOutputExit,
@@ -269,6 +271,15 @@ const TEST_CODEX_HOME =
process.platform === 'win32'
? 'C:\\Users\\test\\AppData\\Roaming\\orca\\codex-runtime-home\\home'
: '/tmp/orca-codex-home'
const TEST_CODEX_AUTH_JSON = JSON.stringify({
tokens: {
access_token: 'access',
id_token: 'e30.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20ifQ.sig',
refresh_token: 'refresh',
account_id: 'account'
},
last_refresh: '2026-07-31T00:00:00Z'
})
function makeDisposable() {
return { dispose: vi.fn() }
@@ -376,6 +387,7 @@ describe('registerPtyHandlers', () => {
mainWindow.webContents.removeListener.mockReset()
// Why: hidden-delivery gate state is module-level (PTY-keyed), so tests must not leak hidden bits across cases.
_resetHiddenRendererPtyDeliveryGateForTest()
__resetShellStartupEnvCache()
// Why: mirror real Electron — ipcMain.handle throws on a duplicate channel, catching re-registration that forgot removeHandler.
handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
@@ -2768,6 +2780,70 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_CODEX_HOME).toBe(TEST_CODEX_HOME)
})
it('waits for managed Codex auth before spawning a local PTY', async () => {
vi.useFakeTimers()
let authReady = false
readFileSyncMock.mockImplementation((filePath: string) => {
if (!filePath.endsWith('auth.json')) {
return ''
}
if (!authReady) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return TEST_CODEX_AUTH_JSON
})
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
await vi.advanceTimersByTimeAsync(0)
expect(spawnMock).not.toHaveBeenCalled()
authReady = true
await vi.advanceTimersByTimeAsync(25)
await spawnPromise
expect(spawnMock.mock.calls.at(-1)?.[2].env).toMatchObject({
CODEX_HOME: TEST_CODEX_HOME,
ORCA_CODEX_HOME: TEST_CODEX_HOME
})
})
it('does not gate a bare local shell on managed Codex auth', async () => {
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
expect(spawnMock).toHaveBeenCalledOnce()
})
it('leaves an inherited CODEX_HOME untouched for system default when the flag is OFF', async () => {
// Why: flag OFF must stay byte-identical to today. With no managed home
// selected (resolver null) and the real-home flag off, no CODEX_HOME
@@ -3016,6 +3092,371 @@ describe('registerPtyHandlers', () => {
).env
}
it('waits for managed Codex auth before spawning a daemon PTY', async () => {
vi.useFakeTimers()
let authReady = false
readFileSyncMock.mockImplementation((filePath: string) => {
if (!filePath.endsWith('auth.json')) {
return ''
}
if (!authReady) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return TEST_CODEX_AUTH_JSON
})
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
await vi.advanceTimersByTimeAsync(0)
expect(daemonSpawn).not.toHaveBeenCalled()
authReady = true
await vi.advanceTimersByTimeAsync(25)
await spawnPromise
expect(daemonSpawn.mock.calls.at(-1)?.[0].env).toMatchObject({
CODEX_HOME: TEST_CODEX_HOME,
ORCA_CODEX_HOME: TEST_CODEX_HOME
})
})
it('resolves valid managed Codex auth synchronously', () => {
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
const resolveCurrent = vi.fn(() => TEST_CODEX_HOME)
const resolveAfterUnavailable = vi.fn(() => null)
const settings = {
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
}
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
selectedCodexHomePath: TEST_CODEX_HOME,
getSettings: () => settings as never,
target: { runtime: 'host' },
resolveCurrent,
resolveAfterUnavailable
})
expect(resolution).toBe(TEST_CODEX_HOME)
expect(resolveCurrent).not.toHaveBeenCalled()
expect(resolveAfterUnavailable).not.toHaveBeenCalled()
})
it('uses the current account when the original auth recovers after a switch', async () => {
vi.useFakeTimers()
const nextHome = '/managed/next/home'
let originalAuthReady = false
let selectedHome = TEST_CODEX_HOME
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath === join(TEST_CODEX_HOME, 'auth.json') && !originalAuthReady) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
if (filePath.endsWith('auth.json')) {
return TEST_CODEX_AUTH_JSON
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
const resolveHome = vi.fn(() => selectedHome)
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, resolveHome, (() => ({
codexManagedAccounts: [TEST_CODEX_HOME, nextHome].map((managedHomePath, index) => ({
id: `account-${index + 1}`,
managedHomePath,
managedHomeRuntime: 'host'
}))
})) as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
await vi.advanceTimersByTimeAsync(0)
selectedHome = nextHome
originalAuthReady = true
await vi.advanceTimersByTimeAsync(25)
await spawnPromise
expect(resolveHome).toHaveBeenCalledTimes(2)
expect(daemonSpawn.mock.calls[0]?.[0].env).toMatchObject({
CODEX_HOME: nextHome,
ORCA_CODEX_HOME: nextHome
})
})
it('does not gate a non-Codex daemon PTY on managed Codex auth', async () => {
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'claude'
})
expect(daemonSpawn).toHaveBeenCalledOnce()
})
it('does not gate a Codex daemon reattach on current managed auth', async () => {
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex',
sessionId: 'retained-codex'
})
expect(daemonSpawn).toHaveBeenCalledOnce()
})
it('does not gate a runtime-created Codex reattach on current managed auth', async () => {
type RuntimeSpawnController = {
spawn(args: {
cols: number
rows: number
launchAgent: 'codex'
sessionId: string
}): Promise<{ id: string }>
}
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never, () => TEST_CODEX_HOME, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
await controller.spawn({
cols: 80,
rows: 24,
launchAgent: 'codex',
sessionId: 'retained-runtime-codex'
})
expect(daemonSpawn).toHaveBeenCalledOnce()
})
it('falls back when managed Codex auth stays unavailable', async () => {
vi.useFakeTimers()
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
const resolveHome = vi.fn(
(
_target?: unknown,
_env?: NodeJS.ProcessEnv,
context?: { unavailableManagedHomePath?: string }
) => (context?.unavailableManagedHomePath ? null : TEST_CODEX_HOME)
)
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, resolveHome, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
await vi.advanceTimersByTimeAsync(2_000)
await spawnPromise
expect(resolveHome).toHaveBeenCalledTimes(2)
expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({
unavailableManagedHomePath: TEST_CODEX_HOME
})
expect(daemonSpawn).toHaveBeenCalledOnce()
expect(daemonSpawn.mock.calls[0]?.[0].env).not.toHaveProperty('CODEX_HOME')
})
it('rejects when account changes keep resolving unavailable managed homes', async () => {
vi.useFakeTimers()
const secondHome = '/managed/second/home'
const thirdHome = '/managed/third/home'
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
const resolveHome = vi.fn(
(
_target?: unknown,
_env?: NodeJS.ProcessEnv,
context?: { unavailableManagedHomePath?: string }
) =>
!context?.unavailableManagedHomePath
? TEST_CODEX_HOME
: context.unavailableManagedHomePath === TEST_CODEX_HOME
? secondHome
: thirdHome
)
handlers.clear()
registerPtyHandlers(mainWindow as never, undefined, resolveHome, (() => ({
codexManagedAccounts: [TEST_CODEX_HOME, secondHome, thirdHome].map(
(managedHomePath, index) => ({
id: `account-${index + 1}`,
managedHomePath,
managedHomeRuntime: 'host'
})
)
})) as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
launchAgent: 'codex'
})
const rejection = expect(spawnPromise).rejects.toThrow(
'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.'
)
await vi.advanceTimersByTimeAsync(4_000)
await rejection
expect(resolveHome.mock.calls.map((call) => call[2]?.unavailableManagedHomePath)).toEqual([
undefined,
TEST_CODEX_HOME,
secondHome
])
expect(vi.getTimerCount()).toBe(0)
expect(daemonSpawn).not.toHaveBeenCalled()
})
it('falls back for a runtime-created Codex launch when auth stays unavailable', async () => {
type RuntimeSpawnController = {
spawn(args: { cols: number; rows: number; launchAgent: 'codex' }): Promise<{ id: string }>
}
vi.useFakeTimers()
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const daemonSpawn = setupDaemonAdapter()
const resolveHome = vi.fn(
(
_target?: unknown,
_env?: NodeJS.ProcessEnv,
context?: { unavailableManagedHomePath?: string }
) => (context?.unavailableManagedHomePath ? null : TEST_CODEX_HOME)
)
const runtime = {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never, resolveHome, (() => ({
codexManagedAccounts: [
{
id: 'account-1',
managedHomePath: TEST_CODEX_HOME,
managedHomeRuntime: 'host'
}
]
})) as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
const spawnPromise = controller.spawn({ cols: 80, rows: 24, launchAgent: 'codex' })
await vi.advanceTimersByTimeAsync(0)
expect(daemonSpawn).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(2_000)
await spawnPromise
expect(resolveHome).toHaveBeenCalledTimes(2)
expect(resolveHome.mock.calls[1]?.[2]).toMatchObject({
unavailableManagedHomePath: TEST_CODEX_HOME
})
expect(daemonSpawn).toHaveBeenCalledOnce()
expect(daemonSpawn.mock.calls[0]?.[0].env).not.toHaveProperty('CODEX_HOME')
})
it('injects OpenCode plugin env (OPENCODE_CONFIG_DIR) on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
OPENCODE_CONFIG_DIR: undefined
@@ -14563,17 +15004,203 @@ describe('registerPtyHandlers', () => {
const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-a' })
registerPtyHandlers(mainWindow as never, undefined, undefined, getSettings as never)
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, sessionId: 'pty-reattached' })
const nativeCodexEnv = { CODEX_HOME: '', ORCA_CODEX_HOME: '' }
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: nativeCodexEnv })
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: nativeCodexEnv,
sessionId: 'pty-reattached'
})
// Why: a reattached shell keeps the CODEX_HOME baked in at its original
// spawn, so re-recording it under the current selection would erase the only
// evidence that the pane is stale.
expect(recordCodexPaneAccountMock.mock.calls).toEqual([
['pty-fresh', { selectionKey: 'host', accountId: 'account-a' }]
['pty-fresh', { selectionKey: 'host', accountId: 'account-a', homeRoute: 'real-home' }]
])
})
posixOnlyIt(
'does not guess route provenance for a pane-local shell startup CODEX_HOME',
async () => {
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'pty-custom-home' })),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
readFileSyncMock.mockImplementation((path: string) =>
path === '/pane-home/.zshrc' ? 'export CODEX_HOME="$HOME/custom-codex-home"\n' : ''
)
const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null })
registerPtyHandlers(
mainWindow as never,
undefined,
() => TEST_CODEX_HOME,
getSettings as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: {
CODEX_HOME: '',
ORCA_CODEX_HOME: '',
HOME: '/pane-home',
SHELL: '/bin/zsh'
}
})
expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-custom-home', {
selectionKey: 'host',
accountId: null,
homeRoute: 'custom-home'
})
}
)
it('records route provenance for a process-wide CODEX_HOME', async () => {
const previousCodexHome = process.env.CODEX_HOME
process.env.CODEX_HOME = '/process/custom-codex-home'
try {
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'pty-process-home' })),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null })
registerPtyHandlers(
mainWindow as never,
undefined,
() => TEST_CODEX_HOME,
getSettings as never
)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: { CODEX_HOME: '/process/custom-codex-home' }
})
expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-process-home', {
selectionKey: 'host',
accountId: null,
homeRoute: 'shared-home',
environmentHomeOverride: { codexHome: '/process/custom-codex-home' }
})
} finally {
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = previousCodexHome
}
}
})
it('does not guess route provenance for a pane-local environment CODEX_HOME', async () => {
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'pty-pane-env-home' })),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null })
registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, getSettings as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
env: { CODEX_HOME: '/pane/custom-codex-home' }
})
expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-pane-env-home', {
selectionKey: 'host',
accountId: null,
homeRoute: 'custom-home'
})
})
it('does not resume under another account when the origin auth stays unavailable', async () => {
vi.useFakeTimers()
const spawn = vi.fn(async () => ({ id: 'pty-must-not-spawn' }))
setLocalPtyProvider({
spawn,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
listProcesses: vi.fn(async () => []),
getForegroundProcess: vi.fn(async () => null)
} as never)
readFileSyncMock.mockImplementation((filePath: string) => {
if (filePath.endsWith('auth.json')) {
throw Object.assign(new Error('missing auth'), { code: 'ENOENT' })
}
return ''
})
const resolveHome = vi.fn(() => '/managed/current/home')
registerPtyHandlers(
mainWindow as never,
undefined,
resolveHome,
(() => ({
codexManagedAccounts: [
{ id: 'account-a', managedHomePath: '/managed/origin/home' },
{ id: 'account-b', managedHomePath: '/managed/current/home' }
]
})) as never,
undefined,
undefined,
{
prepareCodexSessionResume: async () => ({
outcome: 'resume' as const,
codexHomePath: '/managed/origin/home'
})
}
)
const launch = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
command: 'codex resume session-a',
envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'],
launchAgent: 'codex',
resumeProviderSession: {
key: 'session_id',
id: 'session-a',
transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl'
}
})
const rejection = expect(launch).rejects.toThrow(
'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.'
)
await vi.advanceTimersByTimeAsync(2_000)
await rejection
expect(resolveHome).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
expect(recordCodexPaneAccountMock).not.toHaveBeenCalled()
})
it('records the origin account a resumed Codex pane is pinned to', async () => {
setLocalPtyProvider({
spawn: vi.fn(async () => ({ id: 'pty-resumed' })),
@@ -14607,6 +15234,7 @@ describe('registerPtyHandlers', () => {
})
}
)
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
await handlers.get('pty:spawn')!(null, {
cols: 80,
@@ -14623,8 +15251,9 @@ describe('registerPtyHandlers', () => {
// Why: the resume deliberately overrides the selection, so the pane really
// is on account-a. Recording that is what makes the restart prompt appear.
expect(recordCodexPaneAccountMock.mock.calls).toEqual([
['pty-resumed', { selectionKey: 'host', accountId: 'account-a' }]
['pty-resumed', { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }]
])
expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8')
expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled()
})
@@ -14726,6 +15355,7 @@ describe('registerPtyHandlers', () => {
}
)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
readFileSyncMock.mockReturnValue(TEST_CODEX_AUTH_JSON)
await controller.spawn({
cols: 80,
@@ -14741,8 +15371,12 @@ describe('registerPtyHandlers', () => {
})
expect(recordCodexPaneAccountMock.mock.calls).toEqual([
['pty-runtime-resumed', { selectionKey: 'host', accountId: 'account-a' }]
[
'pty-runtime-resumed',
{ selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }
]
])
expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8')
expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled()
})
+224 -11
View File
@@ -187,6 +187,10 @@ import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-st
import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes'
import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import {
isCodexHomeAuthReadyForLaunch,
waitForManagedCodexAuthReady
} from '../codex-accounts/managed-codex-auth-readiness'
import {
forgetCodexPaneAccount,
recordCodexPaneAccount
@@ -194,6 +198,11 @@ import {
import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account'
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag'
import {
environmentCodexHomeOverrideContextsEqual,
getCustomCodexHomeOverrideForLaunch,
shellStartupCodexHomeOverrideContextsEqual
} from '../codex/codex-real-home-path'
import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home'
import { dropUnverifiedCodexResumeArgv } from '../codex/codex-unverified-resume-launch'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
@@ -823,10 +832,16 @@ function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record<string, string>): stri
return keysToDelete
}
export type CodexHomeLaunchContext = {
workspacePath?: string
launchAgent?: TuiAgent
unavailableManagedHomePath?: string
}
export type GetSelectedCodexHomePath = (
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv,
launchContext?: { workspacePath?: string; launchAgent?: TuiAgent }
launchContext?: CodexHomeLaunchContext
) => string | null
export type PrepareCodexSessionResume = (args: {
providerSession: AgentProviderSessionMetadata
@@ -866,6 +881,113 @@ function getCompatibleSelectedCodexHomePath(
: selectedCodexHomePath
}
const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE =
'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.'
const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE =
'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.'
type ManagedCodexAuthResolutionArgs = {
selectedCodexHomePath: string | null
getSettings: () => GlobalSettings | undefined
requiredCodexHomePath?: string
target: CodexAccountSelectionTarget
resolveCurrent: () => string | null
resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null
}
export function resolveCodexHomeAfterManagedAuthReadiness(
args: ManagedCodexAuthResolutionArgs
): string | null | Promise<string | null> {
const selectedCodexHomePath = args.selectedCodexHomePath
if (
args.requiredCodexHomePath &&
!codexHomePathsEqual(selectedCodexHomePath, args.requiredCodexHomePath)
) {
throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE)
}
const readiness = waitForManagedCodexAuthReady({
codexHomePath: selectedCodexHomePath,
settings: args.getSettings(),
target: args.target
})
return readiness
? continueCodexHomeAfterManagedAuthWait(args, selectedCodexHomePath, readiness)
: selectedCodexHomePath
}
async function continueCodexHomeAfterManagedAuthWait(
args: ManagedCodexAuthResolutionArgs,
initialCodexHomePath: string | null,
initialReadiness: Promise<boolean>
): Promise<string | null> {
let selectedCodexHomePath = initialCodexHomePath
let readiness = initialReadiness
for (let attempt = 0; attempt < 2; attempt += 1) {
if (await readiness) {
if (args.requiredCodexHomePath) {
return selectedCodexHomePath
}
const currentCodexHomePath = args.resolveCurrent()
if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) {
return selectedCodexHomePath
}
selectedCodexHomePath = currentCodexHomePath
if (attempt === 1) {
break
}
const nextReadiness = waitForManagedCodexAuthReady({
codexHomePath: selectedCodexHomePath,
settings: args.getSettings(),
target: args.target
})
if (!nextReadiness) {
return selectedCodexHomePath
}
readiness = nextReadiness
continue
}
if (args.requiredCodexHomePath) {
throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE)
}
selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!)
if (attempt === 1) {
break
}
const nextReadiness = waitForManagedCodexAuthReady({
codexHomePath: selectedCodexHomePath,
settings: args.getSettings(),
target: args.target
})
if (!nextReadiness) {
return selectedCodexHomePath
}
readiness = nextReadiness
}
if (
isCodexHomeAuthReadyForLaunch({
codexHomePath: selectedCodexHomePath,
settings: args.getSettings(),
target: args.target
})
) {
return selectedCodexHomePath
}
throw new Error(MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE)
}
function codexHomeSelectionsEqual(left: string | null, right: string | null): boolean {
return (
left === right ||
(left !== null &&
right !== null &&
normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right))
)
}
function codexHomePathsEqual(left: string | null, right: string): boolean {
return codexHomeSelectionsEqual(left, right)
}
// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon
// keeps that shell alive across app restarts, so the launch account is the only
// way to tell later that a pane still runs Codex as the previously selected
@@ -878,16 +1000,47 @@ function recordCodexPaneAccountForSpawn(args: {
isReattach: boolean
pinnedByResume: boolean
launchCodexHomePath: string | null
launchEnv?: NodeJS.ProcessEnv
target: CodexAccountSelectionTarget
settings: GlobalSettings | undefined
}): void {
if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) {
return
}
const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv)
const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null
const recheckableEnvironmentOverride =
customHomeOverride?.source === 'environment' &&
processHomeOverride?.source === 'environment' &&
environmentCodexHomeOverrideContextsEqual(
customHomeOverride.context,
processHomeOverride.context
)
? customHomeOverride.context
: undefined
const recheckableShellStartupOverride =
customHomeOverride?.source === 'shell-startup' &&
processHomeOverride?.source === 'shell-startup' &&
shellStartupCodexHomeOverrideContextsEqual(
customHomeOverride.context,
processHomeOverride.context
)
? customHomeOverride.context
: undefined
const record = args.settings
? resolveCodexPaneLaunchAccount({
pinnedByResume: args.pinnedByResume,
launchCodexHomePath: args.launchCodexHomePath,
// Why: pane-local overrides cannot be re-derived when a restart builds
// a fresh launch env, so route prompts would guess and block valid input.
recordComparableHomeRoute:
args.pinnedByResume ||
((customHomeOverride?.source !== 'environment' ||
recheckableEnvironmentOverride !== undefined) &&
(customHomeOverride?.source !== 'shell-startup' ||
recheckableShellStartupOverride !== undefined)),
shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride,
environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride,
systemCodexHomePath: getSystemCodexHomePath(),
settings: args.settings,
target: args.target
@@ -3775,7 +3928,7 @@ export function registerPtyHandlers(
if (args.preAllocatedHandle) {
env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle }
}
const selectedCodexHomePath = isDaemonHostSpawn
let selectedCodexHomePath = !args.connectionId
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
codexResumeHome
@@ -3786,6 +3939,35 @@ export function registerPtyHandlers(
}) ?? null)
)
: null
if (args.launchAgent === 'codex' && callerRequestedSessionId === undefined) {
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
selectedCodexHomePath,
getSettings: () => getSettings?.(),
requiredCodexHomePath: codexResumeHome?.codexHomePath,
target: codexSelectionTarget,
resolveCurrent: () =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
workspacePath: cwd,
launchAgent: 'codex'
}) ?? null
),
resolveAfterUnavailable: (unavailableManagedHomePath) =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, env, {
workspacePath: cwd,
launchAgent: 'codex',
unavailableManagedHomePath
}) ?? null
)
})
selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
}
const codexResumeHomeSelected = Boolean(
codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath)
)
const skipCodexHomeEnv =
isDaemonHostSpawn &&
shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) &&
@@ -3836,8 +4018,8 @@ export function registerPtyHandlers(
env,
...(isMintedSessionId ? { isNewSession: true } : {})
}
if (!isDaemonHostSpawn && codexResumeHome) {
spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath }
if (!args.connectionId && !isDaemonHostSpawn) {
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
}
const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args)
if (startupTerminalColorQueryReplyColors) {
@@ -3873,7 +4055,7 @@ export function registerPtyHandlers(
'ORCA_CODEX_HOME'
])
}
if (codexResumeHome?.codexHomePath) {
if (codexResumeHomeSelected) {
spawnOptions.envToDelete = removeCodexHomeDeletionRequests(spawnOptions.envToDelete)
}
deleteRequestedEnvKeys(env, spawnOptions.envToDelete)
@@ -4213,8 +4395,9 @@ export function registerPtyHandlers(
ptyId: result.id,
isDaemonHostSpawn,
isReattach: result.isReattach === true,
pinnedByResume: Boolean(codexResumeHome),
pinnedByResume: codexResumeHomeSelected,
launchCodexHomePath: selectedCodexHomePath,
launchEnv: args.env,
target: codexSelectionTarget,
settings: getSettings?.()
})
@@ -4953,7 +5136,7 @@ export function registerPtyHandlers(
// Why: declared after the strip so a local-provider spawn cannot capture the
// pre-strip env — only the daemon branch below re-derives this from baseEnv.
let env: Record<string, string> | undefined = baseEnv
const selectedCodexHomePath = isDaemonHostSpawn
let selectedCodexHomePath = !args.connectionId
? getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
codexResumeHome
@@ -4964,6 +5147,35 @@ export function registerPtyHandlers(
}) ?? null)
)
: null
if (args.launchAgent === 'codex' && args.sessionId === undefined) {
const resolution = resolveCodexHomeAfterManagedAuthReadiness({
selectedCodexHomePath,
getSettings: () => getSettings?.(),
requiredCodexHomePath: codexResumeHome?.codexHomePath,
target: codexSelectionTarget,
resolveCurrent: () =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: cwd,
launchAgent: 'codex'
}) ?? null
),
resolveAfterUnavailable: (unavailableManagedHomePath) =>
getCompatibleSelectedCodexHomePath(
codexSelectionTarget,
getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, {
workspacePath: cwd,
launchAgent: 'codex',
unavailableManagedHomePath
}) ?? null
)
})
selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution
}
const codexResumeHomeSelected = Boolean(
codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath)
)
const skipCodexHomeEnv =
isDaemonHostSpawn &&
shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) &&
@@ -5038,7 +5250,7 @@ export function registerPtyHandlers(
// main cannot safely decide ownership for a process it may not parent.
stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : []
)
if (codexResumeHome?.codexHomePath) {
if (codexResumeHomeSelected) {
combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete)
}
deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete)
@@ -5050,8 +5262,8 @@ export function registerPtyHandlers(
env: spawnEnv,
...(isMintedSessionId ? { isNewSession: true } : {})
}
if (!isDaemonHostSpawn && codexResumeHome) {
spawnOptions.codexHomePathOverride = { value: codexResumeHome.codexHomePath }
if (!args.connectionId && !isDaemonHostSpawn) {
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
}
if (combinedEnvToDelete) {
spawnOptions.envToDelete = combinedEnvToDelete
@@ -5268,8 +5480,9 @@ export function registerPtyHandlers(
ptyId: result.id,
isDaemonHostSpawn,
isReattach: result.isReattach === true,
pinnedByResume: Boolean(codexResumeHome),
pinnedByResume: codexResumeHomeSelected,
launchCodexHomePath: selectedCodexHomePath,
launchEnv: baseEnv,
target: codexSelectionTarget,
settings: getSettings?.()
})
+48
View File
@@ -498,6 +498,54 @@ describe('registerSettingsHandlers', () => {
)
})
it('normalizes custom mobile pairing addresses before persistence', async () => {
store.getSettings.mockReturnValue({
mobilePairingCustomAddress: null,
mobilePairingCustomAddresses: []
})
store.updateSettings.mockReturnValue({
mobilePairingCustomAddress: '100.126.117.25:6768',
mobilePairingCustomAddresses: ['first.example:6768']
})
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
_event: unknown,
args: unknown
) => Promise<unknown>
await handler(settingsInvokeEvent, {
mobilePairingCustomAddress: ' 100.126.117.25:6768 ',
mobilePairingCustomAddresses: [' first.example:6768 ', 'host:99999', 'first.example:6768']
})
expect(store.updateSettings).toHaveBeenCalledWith(
{
mobilePairingCustomAddress: '100.126.117.25:6768',
mobilePairingCustomAddresses: ['first.example:6768']
},
{ notifyListeners: true, originWebContentsId: 1 }
)
})
it('clears malformed custom mobile pairing addresses before persistence', async () => {
store.getSettings.mockReturnValue({ mobilePairingCustomAddress: '100.126.117.25:6768' })
store.updateSettings.mockReturnValue({ mobilePairingCustomAddress: null })
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
_event: unknown,
args: unknown
) => Promise<unknown>
await handler(settingsInvokeEvent, { mobilePairingCustomAddress: 'host:99999' })
expect(store.updateSettings).toHaveBeenCalledWith(
{ mobilePairingCustomAddress: null },
{ notifyListeners: true, originWebContentsId: 1 }
)
})
it('normalizes custom terminal themes from renderer settings IPC', async () => {
store.getSettings.mockReturnValue({ terminalCustomThemes: [] })
store.updateSettings.mockReturnValue({ terminalCustomThemes: [] })
+14
View File
@@ -25,6 +25,10 @@ import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base
import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import { haveSameDisabledTuiAgents } from '../../shared/tui-agent-selection'
import {
normalizeMobilePairingCustomAddress,
normalizeMobilePairingCustomAddresses
} from '../../shared/mobile-pairing-custom-address'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@@ -135,6 +139,16 @@ export function registerSettingsHandlers(
if ('uiLanguage' in args) {
sanitizedArgs.uiLanguage = normalizeUiLanguage(args.uiLanguage)
}
if ('mobilePairingCustomAddress' in args) {
sanitizedArgs.mobilePairingCustomAddress = normalizeMobilePairingCustomAddress(
args.mobilePairingCustomAddress
)
}
if ('mobilePairingCustomAddresses' in args) {
sanitizedArgs.mobilePairingCustomAddresses = normalizeMobilePairingCustomAddresses(
args.mobilePairingCustomAddresses
)
}
if (args.theme) {
nativeTheme.themeSource = args.theme
}
+3
View File
@@ -273,6 +273,9 @@ describe('SSH IPC handlers', () => {
const handlers = new Map<string, (_event: unknown, args: unknown) => unknown>()
const mockStore = {
getRepos: () => [],
getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null),
upsertSshPtyConsumerRecovery: vi.fn(),
removeSshPtyConsumerRecovery: vi.fn(),
getSshRemotePtyLeases: vi.fn().mockReturnValue([]),
markSshRemotePtyLease: vi.fn(),
markSshRemotePtyLeases: vi.fn(),
+128
View File
@@ -5389,6 +5389,59 @@ describe('Store', () => {
expect(updated.prBotAuthorOverrides[499]).toBe('bot-0499')
})
it('normalizes custom mobile pairing addresses on load and every settings write', async () => {
writeDataFile({
settings: {
mobilePairingCustomAddress: 'host:99999',
mobilePairingCustomAddresses: [' first.example:6768 ', 'host:99999', 'first.example:6768']
}
})
const store = await createStore()
expect(store.getSettings().mobilePairingCustomAddress).toBeNull()
expect(store.getSettings().mobilePairingCustomAddresses).toEqual(['first.example:6768'])
store.flush()
expect(
(readDataFile() as { settings?: GlobalSettings }).settings?.mobilePairingCustomAddress
).toBeNull()
const updated = store.updateSettings({
mobilePairingCustomAddress: ' 100.126.117.25:6768 '
})
expect(updated.mobilePairingCustomAddress).toBe('100.126.117.25:6768')
expect(updated.mobilePairingCustomAddresses).toEqual([
'first.example:6768',
'100.126.117.25:6768'
])
store.flush()
expect(
(readDataFile() as { settings?: GlobalSettings }).settings?.mobilePairingCustomAddress
).toBe('100.126.117.25:6768')
expect(
store.updateSettings({ mobilePairingCustomAddress: 42 as never }).mobilePairingCustomAddress
).toBeNull()
expect(
store.updateSettings({
mobilePairingCustomAddresses: [' second.example ', 'host:99999', 'second.example']
}).mobilePairingCustomAddresses
).toEqual(['second.example'])
expect(
store.updateSettings({
mobilePairingCustomAddress: 'active.example:6768',
mobilePairingCustomAddresses: ['second.example']
}).mobilePairingCustomAddresses
).toEqual(['second.example', 'active.example:6768'])
expect(
store.updateSettings({
mobilePairingCustomAddresses: ['third.example']
}).mobilePairingCustomAddresses
).toEqual(['third.example', 'active.example:6768'])
})
it('notifies settings listeners with changed keys only', async () => {
const store = await createStore()
const listener = vi.fn()
@@ -6744,6 +6797,81 @@ describe('Store', () => {
expect(reloaded.getUI().browserKagiSessionLink).toBe(sessionLink)
})
it('durably encrypts SSH PTY consumer ownership for process restart recovery', async () => {
const store = await createStore()
store.setGitHubCache({ pr: { 'o/r#1': { fetchedAt: 1 } as never }, issue: {} })
store.upsertSshPtyConsumerRecovery({
targetId: 'ssh-1',
clientInstanceId: 'client-1',
serverBuildId: 'relay-build-1',
clientGeneration: 3,
ownerGeneration: 5,
ownerLease: 'secret-owner-lease',
outputFlowControl: { version: 1, windowSu: 256 * 1024 }
})
const persisted = readDataFile() as {
sshPtyConsumerRecoveries: { ownerLease: string }[]
}
expect(persisted.sshPtyConsumerRecoveries[0]?.ownerLease).not.toBe('secret-owner-lease')
expect(existsSync(join(testState.dir, 'orca-github-cache.json'))).toBe(false)
const reloaded = await createStore()
expect(reloaded.getSshPtyConsumerRecovery('ssh-1')).toEqual({
targetId: 'ssh-1',
clientInstanceId: 'client-1',
serverBuildId: 'relay-build-1',
clientGeneration: 3,
ownerGeneration: 5,
ownerLease: 'secret-owner-lease',
outputFlowControl: { version: 1, windowSu: 256 * 1024 }
})
})
it('drops decrypted SSH PTY owner leases that exceed the relay protocol bound', async () => {
const oversizedLease = 'x'.repeat(513)
writeDataFile({
...getDefaultPersistedState(testState.dir),
sshPtyConsumerRecoveries: [
{
targetId: 'ssh-1',
clientInstanceId: 'client-1',
serverBuildId: 'relay-build-1',
clientGeneration: 3,
ownerGeneration: 5,
ownerLease: Buffer.from(`encrypted:${oversizedLease}`, 'utf-8').toString('base64')
}
]
})
const store = await createStore()
expect(store.getSshPtyConsumerRecovery('ssh-1')).toBeNull()
})
it('removes persisted SSH PTY consumer ownership with its target', async () => {
const store = await createStore()
store.addSshTarget({
id: 'ssh-1',
label: 'SSH 1',
host: 'example.test',
port: 22,
username: 'orca'
})
store.upsertSshPtyConsumerRecovery({
targetId: 'ssh-1',
clientInstanceId: 'client-1',
serverBuildId: 'relay-build-1',
clientGeneration: 3,
ownerGeneration: 5,
ownerLease: 'secret-owner-lease'
})
store.removeSshTarget('ssh-1')
expect(store.getSshPtyConsumerRecovery('ssh-1')).toBeNull()
})
it('keeps plaintext Kagi session links readable for migration from older builds', async () => {
const sessionLink = 'https://kagi.com/search?token=secret'
writeDataFile({
+183 -2
View File
@@ -97,6 +97,7 @@ import { hardenExistingSecureFile } from '../shared/secure-file'
import {
LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
type RemovedSshTargetTombstone,
type SshPtyConsumerRecovery,
type SshRemotePtyLease,
type SshTarget
} from '../shared/ssh-types'
@@ -169,6 +170,11 @@ import {
import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-commands'
import { normalizeTaskProviderSettings } from '../shared/task-providers'
import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../shared/auto-rename-branch-from-work-settings'
import {
addMobilePairingCustomAddress,
normalizeMobilePairingCustomAddress,
normalizeMobilePairingCustomAddresses
} from '../shared/mobile-pairing-custom-address'
import { normalizeOpenInApplications } from '../shared/open-in-applications'
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order'
@@ -1585,6 +1591,57 @@ function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null {
}
}
const SSH_PTY_OWNER_LEASE_MAX_LENGTH = 512
const ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH = 4096
function normalizeSshPtyConsumerRecovery(
value: unknown,
ownerLeaseMaxLength = SSH_PTY_OWNER_LEASE_MAX_LENGTH
): SshPtyConsumerRecovery | null {
if (!value || typeof value !== 'object') {
return null
}
const raw = value as Partial<SshPtyConsumerRecovery>
const clientGeneration = raw.clientGeneration
const ownerGeneration = raw.ownerGeneration
if (
typeof raw.targetId !== 'string' ||
raw.targetId.length === 0 ||
raw.targetId.length > 512 ||
typeof raw.clientInstanceId !== 'string' ||
raw.clientInstanceId.length === 0 ||
raw.clientInstanceId.length > 512 ||
typeof raw.serverBuildId !== 'string' ||
raw.serverBuildId.length === 0 ||
raw.serverBuildId.length > 512 ||
typeof clientGeneration !== 'number' ||
!Number.isSafeInteger(clientGeneration) ||
clientGeneration <= 0 ||
typeof ownerGeneration !== 'number' ||
!Number.isSafeInteger(ownerGeneration) ||
ownerGeneration <= 0 ||
typeof raw.ownerLease !== 'string' ||
raw.ownerLease.length === 0 ||
raw.ownerLease.length > ownerLeaseMaxLength
) {
return null
}
const flow = raw.outputFlowControl
const outputFlowControl =
flow?.version === 1 && Number.isSafeInteger(flow.windowSu) && flow.windowSu > 0
? { version: 1 as const, windowSu: flow.windowSu }
: undefined
return {
targetId: raw.targetId,
clientInstanceId: raw.clientInstanceId,
serverBuildId: raw.serverBuildId,
clientGeneration,
ownerGeneration,
ownerLease: raw.ownerLease,
...(outputFlowControl ? { outputFlowControl } : {})
}
}
type LayoutLeafNormalization = {
snapshot: TerminalLayoutSnapshot
changed: boolean
@@ -2931,6 +2988,16 @@ export class Store {
if (parsed.ui?.browserKagiSessionLink) {
parsed.ui.browserKagiSessionLink = decryptOptionalSecret(parsed.ui.browserKagiSessionLink)
}
parsed.sshPtyConsumerRecoveries = (
Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : []
)
.map((record) =>
normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH)
)
.filter((record): record is SshPtyConsumerRecovery => record !== null)
.map((record) => ({ ...record, ownerLease: decrypt(record.ownerLease) }))
.map((record) => normalizeSshPtyConsumerRecovery(record))
.filter((record): record is SshPtyConsumerRecovery => record !== null)
// Merge with defaults in case new fields were added
const homeDir = homedir()
@@ -3146,6 +3213,34 @@ export class Store {
parsed.settings?.compactWorktreeCards ??
parsed.settings?.experimentalCompactWorktreeCards ??
defaults.settings.compactWorktreeCards
const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress(
parsed.settings?.mobilePairingCustomAddress
)
const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses
const mobilePairingCustomAddresses = mobilePairingCustomAddress
? addMobilePairingCustomAddress(
normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses),
mobilePairingCustomAddress
)
: normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses)
if (
parsed.settings?.mobilePairingCustomAddress !== undefined &&
parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress
) {
this.loadNeedsSave = true
}
const customAddressesMatch =
Array.isArray(rawMobilePairingCustomAddresses) &&
rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length &&
rawMobilePairingCustomAddresses.every(
(address, index) => address === mobilePairingCustomAddresses[index]
)
if (
(rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) &&
!customAddressesMatch
) {
this.loadNeedsSave = true
}
const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder(
parsed.settings?.sourceControlGroupOrder
)
@@ -3229,6 +3324,8 @@ export class Store {
parsed.settings?.terminalCustomThemes
),
appIcon: normalizeAppIconId(parsed.settings?.appIcon),
mobilePairingCustomAddress,
mobilePairingCustomAddresses,
// Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true.
minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true,
// Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect.
@@ -3460,6 +3557,7 @@ export class Store {
sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? [])
.map(normalizeSshRemotePtyLease)
.filter((lease): lease is SshRemotePtyLease => lease !== null),
sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries,
claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds),
migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries(
parsed.migrationUnsupportedPtyEntries
@@ -3712,6 +3810,10 @@ export class Store {
// Why: clone before encrypting secrets so in-memory this.state stays plaintext.
const stateToSave = {
...this.getDurableState(),
sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({
...record,
ownerLease: encryptToSentinel(record.ownerLease)
})),
settings: {
...this.state.settings,
opencodeSessionCookie: encryptToSentinel(this.state.settings.opencodeSessionCookie),
@@ -5523,6 +5625,33 @@ export class Store {
updates.prBotAuthorOverrides
)
}
if ('mobilePairingCustomAddress' in updates) {
sanitizedUpdates.mobilePairingCustomAddress = normalizeMobilePairingCustomAddress(
updates.mobilePairingCustomAddress
)
}
if ('mobilePairingCustomAddresses' in updates) {
sanitizedUpdates.mobilePairingCustomAddresses = normalizeMobilePairingCustomAddresses(
updates.mobilePairingCustomAddresses
)
}
if (
'mobilePairingCustomAddress' in sanitizedUpdates ||
'mobilePairingCustomAddresses' in sanitizedUpdates
) {
const mobilePairingCustomAddress =
'mobilePairingCustomAddress' in sanitizedUpdates
? sanitizedUpdates.mobilePairingCustomAddress
: this.state.settings.mobilePairingCustomAddress
if (mobilePairingCustomAddress) {
sanitizedUpdates.mobilePairingCustomAddresses = addMobilePairingCustomAddress(
sanitizedUpdates.mobilePairingCustomAddresses ??
this.state.settings.mobilePairingCustomAddresses ??
[],
mobilePairingCustomAddress
)
}
}
const historyWithPreviousLayout = buildWorkspaceDirHistoryForUpdate(
this.state.settings,
sanitizedUpdates
@@ -6378,10 +6507,15 @@ export class Store {
}
removeSshTarget(id: string): void {
if (!this.state.sshTargets) {
const targets = this.state.sshTargets ?? []
const recoveries = this.state.sshPtyConsumerRecoveries ?? []
const nextTargets = targets.filter((target) => target.id !== id)
const nextRecoveries = recoveries.filter((record) => record.targetId !== id)
if (nextTargets.length === targets.length && nextRecoveries.length === recoveries.length) {
return
}
this.state.sshTargets = this.state.sshTargets.filter((t) => t.id !== id)
this.state.sshTargets = nextTargets
this.state.sshPtyConsumerRecoveries = nextRecoveries
this.scheduleSave()
}
@@ -6528,6 +6662,12 @@ export class Store {
carrierChanged = true
}
}
const recoveries = this.state.sshPtyConsumerRecoveries ?? []
const retainedRecoveries = recoveries.filter((record) => record.targetId !== oldTargetId)
if (retainedRecoveries.length !== recoveries.length) {
this.state.sshPtyConsumerRecoveries = retainedRecoveries
carrierChanged = true
}
let setupsChanged = false
const keptSetups: ProjectHostSetup[] = []
for (const setup of this.state.projectHostSetups) {
@@ -6562,6 +6702,47 @@ export class Store {
return [...repoIds]
}
// ── SSH PTY Consumer Recovery ──────────────────────────────────────
getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null {
const record = (this.state.sshPtyConsumerRecoveries ?? []).find(
(candidate) => candidate.targetId === targetId
)
return record ? structuredClone(record) : null
}
upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): void {
const normalized = normalizeSshPtyConsumerRecovery(record)
if (!normalized) {
throw new Error('Invalid SSH PTY consumer recovery record')
}
const recoveries = this.state.sshPtyConsumerRecoveries ?? []
this.state.sshPtyConsumerRecoveries = [
...recoveries.filter((candidate) => candidate.targetId !== normalized.targetId),
normalized
]
this.flushSshPtyConsumerRecovery()
}
removeSshPtyConsumerRecovery(targetId: string): void {
const recoveries = this.state.sshPtyConsumerRecoveries ?? []
const next = recoveries.filter((record) => record.targetId !== targetId)
if (next.length === recoveries.length) {
return
}
this.state.sshPtyConsumerRecoveries = next
this.flushSshPtyConsumerRecovery()
}
private flushSshPtyConsumerRecovery(): void {
try {
// Why: ownership must be durable before relay setup continues, but active-view and GitHub sidecars are unrelated startup work.
this.flushOrThrow()
} catch (err) {
console.error('[persistence] Failed to flush SSH PTY consumer recovery:', err)
}
}
// ── SSH Remote PTY Leases ──────────────────────────────────────────
getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] {
@@ -177,4 +177,37 @@ describe('fetchCodexRateLimits auth errors', () => {
error: authError
})
})
it('stops a PTY probe when Codex renders its sign-in screen', async () => {
const ptyHandlers: { onData?: (data: string) => void } = {}
const ptyWrite = vi.fn()
const ptyKill = vi.fn()
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write: ptyWrite,
kill: ptyKill
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
ptyHandlers.onData?.('\u001b[2JSign in with ChatGPT\r\n')
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
session: null,
weekly: null,
status: 'error',
error: 'Sign in with ChatGPT'
})
expect(ptyWrite).not.toHaveBeenCalled()
expect(ptyKill).toHaveBeenCalledOnce()
})
})
+23
View File
@@ -1035,6 +1035,29 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
output = output.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH)
}
const authError = extractCodexAuthError(output)
if (authError) {
resolved = true
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (settleTimer) {
clearTimeout(settleTimer)
settleTimer = null
}
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
resolve({
provider: 'codex',
session: null,
weekly: null,
updatedAt: Date.now(),
error: authError,
status: 'error'
})
return
}
armStatusNudge()
// Wait for prompt, then send /status
+574 -6
View File
@@ -1409,14 +1409,16 @@ function makeRuntimeStoreWithWorkspaceSession(initialSession: WorkspaceSessionSt
persistPtyBinding: ReturnType<typeof vi.fn>
}
getSession: () => WorkspaceSessionState
setSession: (next: WorkspaceSessionState) => void
} {
let session = initialSession
const setSession = (next: WorkspaceSessionState): void => {
session = next
}
const runtimeStore = {
...store,
getWorkspaceSession: () => session,
setWorkspaceSession: vi.fn((next: WorkspaceSessionState) => {
session = next
}),
setWorkspaceSession: vi.fn(setSession),
persistPtyBinding: vi.fn(
(args: { worktreeId: string; tabId: string; leafId: string; ptyId: string }) => {
const tabs = session.tabsByWorktree[args.worktreeId] ?? []
@@ -1446,7 +1448,7 @@ function makeRuntimeStoreWithWorkspaceSession(initialSession: WorkspaceSessionSt
}
)
}
return { runtimeStore, getSession: () => session }
return { runtimeStore, getSession: () => session, setSession }
}
function makeWorkspaceSessionWithHeadlessTerminal(
@@ -24830,6 +24832,267 @@ describe('OrcaRuntimeService', () => {
])
})
it('leases renderer publication for a paired create and preserves host-owned inventory', async () => {
const leafId = '91919191-9191-4919-8919-919191919191'
const spawn = vi.fn()
const setBackgroundThrottling = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
const webContents: {
isDestroyed: () => boolean
setBackgroundThrottling: typeof setBackgroundThrottling
send: ReturnType<typeof vi.fn>
} = {
isDestroyed: () => false,
setBackgroundThrottling,
send: vi.fn()
}
webContents.send.mockImplementation((_channel: string, payload: { requestId: string }) => {
expect(setBackgroundThrottling).toHaveBeenCalledWith(false)
runtime.registerPty('pty-paired-headed', TEST_WORKTREE_ID, null, {
tabId: 'tab-paired-headed',
leafId
})
ipcMain.emit(
'terminal:tabCreateReply',
{ sender: webContents },
{ requestId: payload.requestId, tabId: 'tab-paired-headed', title: 'Terminal' }
)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-paired-headed',
worktreeId: TEST_WORKTREE_ID,
title: 'Terminal',
activeLeafId: leafId,
layout: null
}
],
leaves: [
{
tabId: 'tab-paired-headed',
worktreeId: TEST_WORKTREE_ID,
leafId,
paneRuntimeId: 1,
ptyId: 'pty-paired-headed'
}
],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer:paired-headed',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: null,
activeTabType: null,
tabs: [
{
type: 'terminal',
id: `tab-paired-headed::${leafId}`,
parentTabId: 'tab-paired-headed',
leafId,
ptyId: 'pty-paired-headed',
title: 'Terminal',
viewMode: 'chat',
isActive: false
}
]
}
]
})
})
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents
})
const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
clientNavigationId: 'device-a',
navigation: 'caller',
activate: false,
select: false,
viewMode: 'chat'
})
expect(webContents.send).toHaveBeenCalledWith(
'terminal:requestTabCreate',
expect.objectContaining({ source: 'runtime-session', viewMode: 'chat' })
)
expect(spawn).not.toHaveBeenCalled()
expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]])
expect(result.tab).toMatchObject({
type: 'terminal',
status: 'ready',
ptyId: 'pty-paired-headed',
terminal: expect.stringMatching(/^term_/),
viewMode: 'chat'
})
const host = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const clientA = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`, 'device-a')
const clientB = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`, 'device-b')
const inventory = (snapshot: RuntimeMobileSessionTabsResult) =>
snapshot.tabs.map((tab) =>
tab.type === 'terminal'
? {
id: tab.id,
type: tab.type,
leafId: tab.leafId,
parentTabId: tab.parentTabId,
ptyId: tab.ptyId
}
: { id: tab.id, type: tab.type }
)
expect(inventory(clientA)).toEqual(inventory(host))
expect(inventory(clientB)).toEqual(inventory(host))
runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID)
expect(runtime['syncMobileSessionTabs']([])).toEqual(new Set([TEST_WORKTREE_ID]))
expect(runtime['mobileSessionTabsByWorktree'].get(TEST_WORKTREE_ID)?.tabs).toEqual([
expect.objectContaining({
parentTabId: 'tab-paired-headed',
leafId,
ptyId: 'pty-paired-headed'
})
])
const restoredHost = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const restoredClientA = await runtime.listMobileSessionTabs(
`id:${TEST_WORKTREE_ID}`,
'device-a'
)
const restoredClientB = await runtime.listMobileSessionTabs(
`id:${TEST_WORKTREE_ID}`,
'device-b'
)
expect(inventory(restoredHost)).toEqual(inventory(host))
expect(inventory(restoredClientA)).toEqual(inventory(host))
expect(inventory(restoredClientB)).toEqual(inventory(host))
expect(spawn).not.toHaveBeenCalled()
})
it.each([
['paired', 'reloading'],
['paired', 'unavailable'],
['unpaired', 'reloading'],
['unpaired', 'unavailable']
] as const)(
'does not spawn a %s terminal while the headed renderer graph is %s',
async (caller, graphStatus) => {
const spawn = vi.fn()
const send = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send, setBackgroundThrottling: vi.fn() }
})
if (graphStatus === 'reloading') {
runtime.markRendererReloading(1)
} else {
runtime.markGraphUnavailable(1)
}
await expect(
runtime.createMobileSessionTerminal(
`id:${TEST_WORKTREE_ID}`,
caller === 'paired'
? { clientNavigationId: 'device-a', navigation: 'caller' as const }
: {}
)
).rejects.toThrow('runtime_unavailable')
expect(spawn).not.toHaveBeenCalled()
expect(send).not.toHaveBeenCalled()
}
)
it('rejects a paired create if renderer authority changes across async resolution', async () => {
const spawn = vi.fn()
const send = vi.fn()
const setBackgroundThrottling = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send, setBackgroundThrottling }
})
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
let resolutionStarted = (): void => {}
const started = new Promise<void>((resolve) => {
resolutionStarted = resolve
})
let releaseResolution = (): void => {}
vi.mocked(listWorktrees).mockImplementationOnce(
() =>
new Promise((resolve) => {
releaseResolution = () => resolve(MOCK_GIT_WORKTREES)
resolutionStarted()
})
)
const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
clientNavigationId: 'device-a',
navigation: 'caller'
})
await started
expect(spawn).not.toHaveBeenCalled()
runtime.markRendererReloading(1)
releaseResolution()
await expect(create).rejects.toThrow('runtime_unavailable')
expect(send).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
expect(setBackgroundThrottling).not.toHaveBeenCalled()
})
it('cancels a paired renderer-owned create before publication when its client disconnects', async () => {
const spawn = vi.fn()
const send = vi.fn()
const setBackgroundThrottling = vi.fn()
const abort = new AbortController()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { isDestroyed: () => false, send, setBackgroundThrottling }
})
abort.abort()
await expect(
runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
clientNavigationId: 'device-a',
signal: abort.signal
})
).rejects.toThrow('client_disconnected')
expect(spawn).not.toHaveBeenCalled()
expect(send).not.toHaveBeenCalled()
expect(setBackgroundThrottling).not.toHaveBeenCalled()
})
it('selects a created terminal only for the paired caller', async () => {
let spawnIndex = 0
const runtime = new OrcaRuntimeService(store)
@@ -25790,6 +26053,172 @@ describe('OrcaRuntimeService', () => {
})
})
it('leases renderer publication until a paired whole-tab close is acknowledged', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
)
const acknowledged = makeDeferred()
const closeTerminalTab = vi.fn(() => acknowledged.promise)
const setBackgroundThrottling = vi.fn()
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => []
})
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
title: 'Durable',
activeLeafId: HEADLESS_LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
leafId: HEADLESS_LEAF_ID,
paneRuntimeId: 1,
ptyId: 'persisted-pty'
}
],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer:paired-close',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: `host-tab::${HEADLESS_LEAF_ID}`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: `host-tab::${HEADLESS_LEAF_ID}`,
parentTabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
ptyId: 'persisted-pty',
title: 'Durable',
isActive: true
}
]
}
]
})
runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
setBackgroundThrottling
}
})
const pending = runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
reason: 'user',
clientNavigationId: 'device-a'
})
await vi.waitFor(() => expect(closeTerminalTab).toHaveBeenCalledWith('host-tab'))
expect(setBackgroundThrottling.mock.calls).toEqual([[false]])
acknowledged.resolve()
await expect(pending).resolves.toEqual({ closed: true })
expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]])
})
it('accepts a paired close after the renderer already persisted its removal', async () => {
const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
)
const acknowledged = makeDeferred()
const closeTerminalTab = vi.fn(() => acknowledged.promise)
const kill = vi.fn(() => true)
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setNotifier({ closeTerminal: vi.fn(), closeTerminalTab } as never)
runtime.setPtyController({
write: () => true,
kill,
getForegroundProcess: async () => null,
listProcesses: async () => []
})
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
title: 'Durable',
activeLeafId: HEADLESS_LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: 'host-tab',
worktreeId: TEST_WORKTREE_ID,
leafId: HEADLESS_LEAF_ID,
paneRuntimeId: 1,
ptyId: 'persisted-pty'
}
],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer:paired-close',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: `host-tab::${HEADLESS_LEAF_ID}`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: `host-tab::${HEADLESS_LEAF_ID}`,
parentTabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
ptyId: 'persisted-pty',
title: 'Durable',
isActive: true
}
]
}
]
})
runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, {
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID
})
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
setBackgroundThrottling: vi.fn()
}
})
const pending = runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab', {
reason: 'user',
clientNavigationId: 'device-a'
})
await vi.waitFor(() => expect(closeTerminalTab).toHaveBeenCalledWith('host-tab'))
const session = getSession()
setSession({
...session,
tabsByWorktree: { ...session.tabsByWorktree, [TEST_WORKTREE_ID]: [] },
terminalLayoutsByTabId: {}
})
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
acknowledged.resolve()
await expect(pending).resolves.toEqual({ closed: true })
expect(kill).toHaveBeenCalledWith('persisted-pty')
})
it('reuses pane close for live PTYs that do not own a renderer tab', async () => {
const kill = vi.fn(() => true)
const closeTerminalTab = vi.fn(async () => {})
@@ -26515,6 +26944,86 @@ describe('OrcaRuntimeService', () => {
expect(secondMerge.publicationEpoch.match(/:headless-merge:/g) ?? []).toHaveLength(1)
})
it('keeps the graph ready when a mobile snapshot references a removed folder workspace', () => {
const runtime = new OrcaRuntimeService({
...store,
getFolderWorkspaces: () => []
} as never)
expect(() =>
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: 'folder:removed-folder',
publicationEpoch: 'stale-folder-publication',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}
]
})
).not.toThrow()
expect(runtime.getStatus().graphStatus).toBe('ready')
})
it('scans ordinary persisted sessions once instead of once per graph workspace', () => {
const tabsByWorktree = Object.fromEntries(
Array.from({ length: 100 }, (_, index) => [
`${TEST_REPO_ID}::/tmp/worktree-${index}`,
[{ id: `tab-${index}`, ptyId: `${TEST_REPO_ID}::/tmp/worktree-${index}@@pty` }]
])
)
const session = { tabsByWorktree, terminalLayoutsByTabId: {} }
const getWorkspaceSession = vi.fn(() => session)
const runtime = new OrcaRuntimeService({
...store,
getWorkspaceSession,
getWorkspaceSessionHostIds: () => ['local']
} as never)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: Object.keys(tabsByWorktree).map((worktree) => ({
worktree,
publicationEpoch: 'large-profile',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabs: []
}))
})
expect(getWorkspaceSession).toHaveBeenCalledTimes(1)
expect(runtime.getStatus().graphStatus).toBe('ready')
})
it('hydrates runtime-owned candidates from one host-session read', () => {
const tabsByWorktree = Object.fromEntries(
Array.from({ length: 100 }, (_, index) => [
`${TEST_REPO_ID}::/tmp/runtime-worktree-${index}`,
[{ id: `runtime-tab-${index}`, ptyId: `serve-runtime-${index}` }]
])
)
const session = { tabsByWorktree, terminalLayoutsByTabId: {} }
const getWorkspaceSession = vi.fn(() => session)
const runtime = new OrcaRuntimeService({
...store,
getWorkspaceSession,
getWorkspaceSessionHostIds: () => ['local']
} as never)
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
expect(getWorkspaceSession).toHaveBeenCalledTimes(1)
expect(runtime.getStatus().graphStatus).toBe('ready')
})
it('briefly preserves abnormal SSH exits for paired pane recovery', async () => {
vi.useFakeTimers()
try {
@@ -26751,7 +27260,8 @@ describe('OrcaRuntimeService', () => {
getWorkspaceSession
} as never)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
expect(getWorkspaceSession).toHaveBeenCalledTimes(2)
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs).toEqual([
expect.objectContaining({
@@ -36036,6 +36546,7 @@ describe('OrcaRuntimeService', () => {
expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toMatchObject({
taskId: 'task-1',
dispatchId: 'ctx-1',
dispatchStatus: 'dispatched',
taskTitle: 'Dispatch prompt work',
displayName: 'Review dispatch prompts and make worker labels distinct',
parentPaneKey: coordinatorPaneKey,
@@ -36113,6 +36624,7 @@ describe('OrcaRuntimeService', () => {
expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toMatchObject({
taskId: 'task-done',
dispatchId: 'ctx-done',
dispatchStatus: 'completed',
parentPaneKey: coordinatorPaneKey,
parentTerminalHandle: coordinatorHandle
})
@@ -36170,10 +36682,66 @@ describe('OrcaRuntimeService', () => {
expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toEqual({
taskId: 'task-done',
dispatchId: 'ctx-done'
dispatchId: 'ctx-done',
dispatchStatus: 'completed'
})
})
it.each(['failed', 'circuit_broken'] as const)(
'returns recent %s orchestration context without an active coordinator',
(dispatchStatus) => {
const runtime = new OrcaRuntimeService(store)
const workerLeafId = '66666666-6666-4666-8666-666666666666'
const workerPaneKey = makePaneKey('tab-worker', workerLeafId)
const workerHandle = runtime.preAllocateHandleForPty('pty-worker')
const getActiveCoordinatorRun = vi.fn(() => ({
id: 'run-unrelated',
coordinator_handle: 'term_unrelated'
}))
runtime.setOrchestrationDb({
getActiveDispatchForTerminal: vi.fn(() => undefined),
getLatestDispatchForTerminal: vi.fn(() => ({
id: 'ctx-settled',
task_id: 'task-settled',
assignee_handle: workerHandle,
status: dispatchStatus,
completed_at: new Date(Date.now()).toISOString()
})),
getActiveCoordinatorRun
} as never)
runtime.attachWindow(1)
const result = runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-worker',
worktreeId: TEST_WORKTREE_ID,
title: 'Claude Code',
activeLeafId: workerLeafId,
layout: null
}
],
leaves: [
{
tabId: 'tab-worker',
worktreeId: TEST_WORKTREE_ID,
leafId: workerLeafId,
paneRuntimeId: 1,
ptyId: 'pty-worker',
paneTitle: null
}
]
})
expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toEqual({
taskId: 'task-settled',
dispatchId: 'ctx-settled',
dispatchStatus
})
expect(getActiveCoordinatorRun).not.toHaveBeenCalled()
}
)
it('does not return stale completed orchestration context for renderer-synced terminal leaves', () => {
const runtime = new OrcaRuntimeService(store)
const workerLeafId = '77777777-7777-4777-8777-777777777777'
+410 -181
View File
@@ -541,6 +541,7 @@ import {
import { joinWorktreeRelativePath } from './runtime-relative-paths'
import { collectMemorySnapshot } from '../memory/collector'
import { app, BrowserWindow, ipcMain, Notification } from 'electron'
import { RendererPublicationThrottle } from '../window/renderer-publication-throttle'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { BrowserBackend } from '../browser/browser-backend'
import { BrowserError } from '../browser/cdp-bridge'
@@ -2640,6 +2641,8 @@ export class OrcaRuntimeService {
private rendererGraphEpoch = 0
private graphStatus: RuntimeGraphStatus = 'unavailable'
private authoritativeWindowId: number | null = null
// Why: paired graph transactions need foreground timer cadence only until their publication settles.
private readonly rendererPublicationThrottle = new RendererPublicationThrottle()
private tabs = new Map<string, RuntimeSyncedTab>()
private mobileSessionTabsByWorktree = new Map<string, RuntimeMobileSessionTabsSnapshot>()
// Why: renderer publication ordering must be judged against the renderer's
@@ -2689,6 +2692,7 @@ export class OrcaRuntimeService {
string,
{
activate: boolean
paired: boolean
selectIfNoActiveTab: boolean
viewMode?: 'terminal' | 'chat'
}
@@ -2804,6 +2808,7 @@ export class OrcaRuntimeService {
// iterates them all. Listeners are cleaned up via subscriptionCleanups.
private notificationListeners = new Set<(event: MobileNotificationEvent) => void>()
private ptysById = new Map<string, RuntimePtyWorktreeRecord>()
private readonly pairedRendererSessionOwnedPtyIds = new Set<string>()
private wslDistroByPtyId = new Map<string, string>()
private titleObservationSequence = 0
private headlessTerminals = new Map<string, RuntimeHeadlessTerminal>()
@@ -4556,14 +4561,14 @@ export class OrcaRuntimeService {
return this.startedAt
}
private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId {
private tryGetWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId | null {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type === 'folder') {
const workspace = this.store
?.getFolderWorkspaces?.()
.find((entry) => entry.id === scope.folderWorkspaceId)
if (!workspace) {
throw new Error('folder_workspace_not_found')
return null
}
const connectionId = this.resolveFolderWorkspaceConnectionId(workspace)
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
@@ -4573,11 +4578,17 @@ export class OrcaRuntimeService {
return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID
}
private getWorkspaceSessionHostIdForWorktree(worktreeId: string): ExecutionHostId {
const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId)
if (!hostId) {
throw new Error('folder_workspace_not_found')
}
return hostId
}
private getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null {
return (
this.store?.getWorkspaceSession?.(this.getWorkspaceSessionHostIdForWorktree(worktreeId)) ??
null
)
const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId)
return hostId ? (this.store?.getWorkspaceSession?.(hostId) ?? null) : null
}
private setWorkspaceSessionForWorktree(worktreeId: string, session: WorkspaceSessionState): void {
@@ -4606,6 +4617,56 @@ export class OrcaRuntimeService {
return worktreeIds
}
private getWorkspaceSessionHydrationTargets(
includeAllPersistedWorktrees: boolean
): Map<string, WorkspaceSessionState> {
const repos = this.store?.getRepos?.() ?? []
const repoHostIdByRepoId = new Map(
repos.map((repo) => [repo.id, getRepoExecutionHostId(repo)] as const)
)
const folderHostIdByWorkspaceId = new Map(
(this.store?.getFolderWorkspaces?.() ?? []).map((workspace) => {
const connectionId = this.resolveFolderWorkspaceConnectionId(workspace)
return [
workspace.id,
connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
] as const
})
)
const hostIds = new Set<ExecutionHostId>(['local'])
for (const repo of repos) {
hostIds.add(getRepoExecutionHostId(repo))
}
for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) {
hostIds.add(hostId)
}
const targets = new Map<string, WorkspaceSessionState>()
for (const hostId of hostIds) {
const session = this.store?.getWorkspaceSession?.(hostId)
if (!session) {
continue
}
for (const [worktreeId, tabs] of Object.entries(session.tabsByWorktree ?? {})) {
const scope = parseWorkspaceKey(worktreeId)
const ownerHostId =
scope?.type === 'folder'
? (folderHostIdByWorkspaceId.get(scope.folderWorkspaceId) ?? null)
: (repoHostIdByRepoId.get(
getRepoIdFromWorktreeId(scope?.type === 'worktree' ? scope.worktreeId : worktreeId)
) ?? LOCAL_EXECUTION_HOST_ID)
if (
ownerHostId === hostId &&
(includeAllPersistedWorktrees ||
this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs))
) {
targets.set(worktreeId, session)
}
}
}
return targets
}
getStatus(): RuntimeStatus {
// Why: browser panes need a backend that can create and stream a page. A
// desktop renderer provides one via <webview>; a headless serve provides one
@@ -5431,6 +5492,7 @@ export class OrcaRuntimeService {
})
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(explicitWorktreeId)
await this.refreshMobileSessionPtyRecords(explicitWorktreeId)
this.restoreLivePairedRendererSessionOwnedMobileTerminals(explicitWorktreeId)
return this.getMobileSessionTabsForWorktree(explicitWorktreeId, clientNavigationId)
}
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
@@ -5440,6 +5502,7 @@ export class OrcaRuntimeService {
})
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktree.id)
await this.refreshMobileSessionPtyRecords()
this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktree.id)
return this.getMobileSessionTabsForWorktree(worktree.id, clientNavigationId)
}
@@ -5468,6 +5531,8 @@ export class OrcaRuntimeService {
force?: boolean
allowAttachedWindow?: boolean
onlyRuntimeOwnedTerminals?: boolean
runtimeOwnedTerminalCandidateKnown?: boolean
workspaceSession?: WorkspaceSessionState
} = {}
): Set<string> {
// Why: report which worktrees were reconciled in place so callers don't
@@ -5476,9 +5541,11 @@ export class OrcaRuntimeService {
if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) {
return reconciledWorktreeIds
}
const session = worktreeId
? this.getWorkspaceSessionForWorktree(worktreeId)
: this.store?.getWorkspaceSession?.()
const session =
options.workspaceSession ??
(worktreeId
? this.getWorkspaceSessionForWorktree(worktreeId)
: this.store?.getWorkspaceSession?.())
if (!session) {
return reconciledWorktreeIds
}
@@ -5490,7 +5557,14 @@ export class OrcaRuntimeService {
if (
options.onlyRuntimeOwnedTerminals === true &&
!this.offscreenBrowserBackend &&
!this.workspaceSessionHasRuntimeOwnedPtyCandidate(session)
options.runtimeOwnedTerminalCandidateKnown !== true &&
!(worktreeId
? this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(
session,
worktreeId,
session.tabsByWorktree[worktreeId] ?? []
)
: this.workspaceSessionHasRuntimeOwnedPtyCandidate(session))
) {
return reconciledWorktreeIds
}
@@ -5535,7 +5609,8 @@ export class OrcaRuntimeService {
}
const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs(
entryWorktreeId,
persistedTabs
persistedTabs,
session
).filter(
(tab) =>
options.onlyRuntimeOwnedTerminals !== true ||
@@ -5847,26 +5922,30 @@ export class OrcaRuntimeService {
}
private workspaceSessionHasRuntimeOwnedPtyCandidate(session: WorkspaceSessionState): boolean {
for (const tabs of Object.values(session.tabsByWorktree ?? {})) {
for (const tab of tabs) {
if (this.isServeOrSshOwnedPtyId(tab.ptyId)) {
return true
}
const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId
if (
leafPtyIds &&
Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId))
) {
return true
}
}
}
// Why: expiry clears the stale PTY id but retains pane coordinates so paired viewers can ask the HUB for a fresh shell.
return Object.entries(session.tabsByWorktree ?? {}).some(([worktreeId, tabs]) =>
tabs.some((tab) => this.getRecentExpiredSshLease(worktreeId, tab.id, undefined) !== null)
this.workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(session, worktreeId, tabs)
)
}
private workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate(
session: WorkspaceSessionState,
worktreeId: string,
tabs: WorkspaceSessionState['tabsByWorktree'][string]
): boolean {
return tabs.some((tab) => {
if (this.isServeOrSshOwnedPtyId(tab.ptyId)) {
return true
}
const leafPtyIds = session.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId
return (
(leafPtyIds &&
Object.values(leafPtyIds).some((ptyId) => this.isServeOrSshOwnedPtyId(ptyId))) ||
// Why: expiry keeps pane coordinates so paired viewers can request a fresh shell.
this.getRecentExpiredSshLease(worktreeId, tab.id, undefined) !== null
)
})
}
private getRecentExpiredSshLease(
worktreeId: string,
tabId: string,
@@ -6008,6 +6087,7 @@ export class OrcaRuntimeService {
const pty = this.ptysById.get(ptyId)
if (pty?.worktreeId === worktreeId && pty.tabId === parentTabId) {
pty.runtimeSessionOwned = false
this.setPairedRendererSessionOwnership(pty.ptyId, false)
}
}
}
@@ -6154,6 +6234,7 @@ export class OrcaRuntimeService {
startupCwd?: string
viewMode?: 'terminal' | 'chat'
split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' }
notify?: boolean
}
): void {
if (
@@ -6262,7 +6343,9 @@ export class OrcaRuntimeService {
tabs
}
this.mobileSessionTabsByWorktree.set(worktreeId, next)
this.notifyMobileSessionTabsChanged(worktreeId)
if (args.notify !== false) {
this.notifyMobileSessionTabsChanged(worktreeId)
}
}
private touchMobileSessionSnapshotsForPty(
@@ -6471,12 +6554,9 @@ export class OrcaRuntimeService {
private buildHeadlessMobileSessionTerminalTabs(
worktreeId: string,
persistedTabs: readonly TerminalTab[]
persistedTabs: readonly TerminalTab[],
session: WorkspaceSessionState
): RuntimeMobileSessionTerminalTab[] {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
if (!session) {
return []
}
return [...persistedTabs]
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt)
.flatMap((tab, index) => {
@@ -6508,7 +6588,13 @@ export class OrcaRuntimeService {
...(tab.color != null ? { color: tab.color } : {}),
...(tab.isPinned ? { isPinned: true } : {}),
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
isActive: this.isPersistedTerminalLeafActive(worktreeId, tab.id, leafId, layout)
isActive: this.isPersistedTerminalLeafActive(
session,
worktreeId,
tab.id,
leafId,
layout
)
}
]
})
@@ -6697,13 +6783,13 @@ export class OrcaRuntimeService {
}
private isPersistedTerminalLeafActive(
session: WorkspaceSessionState,
worktreeId: string,
tabId: string,
leafId: string,
layout: TerminalLayoutSnapshot | undefined
): boolean {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
const activeTabId = session?.activeTabIdByWorktree?.[worktreeId] ?? session?.activeTabId
const activeTabId = session.activeTabIdByWorktree?.[worktreeId] ?? session.activeTabId
return activeTabId === tabId && (!layout?.activeLeafId || layout.activeLeafId === leafId)
}
@@ -6879,7 +6965,11 @@ export class OrcaRuntimeService {
}
}
private removePersistedHeadlessTerminalTab(worktreeId: string, parentTabId: string): string[] {
private removePersistedHeadlessTerminalTab(
worktreeId: string,
parentTabId: string,
options: { allowMissing?: boolean } = {}
): string[] {
const session = this.getWorkspaceSessionForWorktree(worktreeId)
if (!session || !this.store?.setWorkspaceSession) {
throw new Error('workspace_session_unavailable')
@@ -6889,6 +6979,9 @@ export class OrcaRuntimeService {
throw new Error('terminal_tab_pinned')
}
if (!result.closed) {
if (options.allowMissing) {
return []
}
throw new Error('tab_not_found')
}
this.setWorkspaceSessionForWorktree(
@@ -7049,7 +7142,7 @@ export class OrcaRuntimeService {
}
}
try {
await this.createHeadlessMobileSessionTerminal(worktreeId, targetsHost, undefined, {
await this.createRuntimeOwnedMobileSessionTerminal(worktreeId, targetsHost, undefined, {
identity: {
tabId: tab.parentTabId,
leafId: tab.leafId,
@@ -7317,13 +7410,19 @@ export class OrcaRuntimeService {
reason?: RuntimeSessionTabCloseReason
expectedPublicationEpoch?: string
expectedTerminalHandle?: string
clientNavigationId?: string
} = {}
): Promise<RuntimeMobileSessionTabCloseResult> {
const graphEpoch = options.clientNavigationId ? this.captureReadyGraphEpoch() : null
const explicitWorktreeId = this.getValidatedExplicitWorktreeIdSelector(worktreeSelector)
const worktreeId =
explicitWorktreeId ?? (await this.resolveWorktreeSelector(worktreeSelector)).id
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
const observedPtyIds = await this.refreshMobileSessionPtyRecords()
if (graphEpoch !== null) {
this.assertStableReadyGraph(graphEpoch)
}
this.restoreLivePairedRendererSessionOwnedMobileTerminals(worktreeId)
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (options.reason !== undefined && options.reason !== 'user' && observedPtyIds === null) {
// Why: keep-on-unknown must also restore the mirror the caller already pruned.
@@ -7455,7 +7554,19 @@ export class OrcaRuntimeService {
if (closingWholeParent && this.notifier?.closeTerminalTab) {
// Why: whole-tab close is a lifecycle transaction. The renderer reply
// arrives only after canonical retirement and a forced session flush.
await this.notifier.closeTerminalTab(tab.parentTabId)
const win = this.getAvailableAuthoritativeWindow()
if (win?.webContents.isDestroyed?.()) {
throw new Error('runtime_unavailable')
}
const releasePublicationThrottle =
options.clientNavigationId && win
? this.rendererPublicationThrottle.acquire(win.webContents)
: () => {}
try {
await this.notifier.closeTerminalTab(tab.parentTabId)
} finally {
releasePublicationThrottle()
}
const remainingSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
const remainingTab = remainingSnapshot?.tabs.find(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
@@ -7467,7 +7578,10 @@ export class OrcaRuntimeService {
this.isRuntimeOwnedHeadlessMobileTab(worktreeId, remainingTab)
) {
// Why: after relay recovery the renderer can acknowledge a tab it no longer mirrors; the HUB must still retire its SSH-owned surface.
this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab)
this.closeHeadlessMobileTerminalTab(worktreeId, remainingSnapshot, remainingTab, {
// Why: the renderer may already have durably removed the tab before acknowledging.
allowMissingPersistedTab: true
})
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
this.store?.flushOrThrow?.()
}
@@ -7641,11 +7755,13 @@ export class OrcaRuntimeService {
worktreeId: string,
snapshot: RuntimeMobileSessionTabsSnapshot,
tab: RuntimeMobileSessionTerminalTab,
options: { killPtys?: boolean } = {}
options: { allowMissingPersistedTab?: boolean; killPtys?: boolean } = {}
): void {
const closedParentTabId = tab.parentTabId
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, closedParentTabId)
const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId)
const projectedPtyIds = this.removePersistedHeadlessTerminalTab(worktreeId, closedParentTabId, {
allowMissing: options.allowMissingPersistedTab
})
// Why: local provider ids can be reused after restart, so a dormant
// persisted id is not kill authority. SSH relay ids remain durable exact
// identities even before pane metadata reconnects.
@@ -12847,6 +12963,7 @@ export class OrcaRuntimeService {
if (pty) {
pty.connected = false
pty.runtimeSessionOwned = false
this.setPairedRendererSessionOwnership(pty.ptyId, false)
pty.disconnectedAt = Date.now()
pty.lastExitCode = exitCode
this.resolvePtyExitWaiters(pty, ptyId)
@@ -24651,11 +24768,13 @@ export class OrcaRuntimeService {
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
activate?: boolean
clientNavigationId?: string
clientMutationId?: string
signal?: AbortSignal
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
this.assertGraphReady()
const pairedCreate = Boolean(opts.clientNavigationId)
const graphEpoch = this.captureReadyGraphEpoch()
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
const worktreeId = workspace.id
const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd)
@@ -24670,10 +24789,13 @@ export class OrcaRuntimeService {
afterDesktopTabId = anchor.type === 'terminal' ? anchor.parentTabId : anchor.id
}
const startupCommand = await this.resolveMobileSessionTerminalCommand(workspace, opts)
this.assertStableReadyGraph(graphEpoch)
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
const win = this.getAvailableAuthoritativeWindow()
if (!win) {
return await this.createHeadlessMobileSessionTerminal(
return await this.createRuntimeOwnedMobileSessionTerminal(
worktreeId,
opts.activate !== false,
opts.afterTabId,
@@ -24686,142 +24808,155 @@ export class OrcaRuntimeService {
launchAgent: startupCommand.launchAgent,
viewMode: opts.viewMode,
targetGroupId: opts.targetGroupId,
launchConfig: startupCommand.launchConfig
launchConfig: startupCommand.launchConfig,
signal: opts.signal
}
)
}
const requestId = randomUUID()
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
reject(new Error('Terminal creation timed out'))
}, 10_000)
// Why: a dead client connection cancels the wait; the renderer tab (and
// its shell) stays alive for the host and mirrors on reconnect (#7718).
const onAbort = (): void => {
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
reject(new Error('client_disconnected'))
}
const handler = (
event: Electron.IpcMainEvent,
r: { requestId: string; tabId?: string; title?: string; error?: string }
): void => {
if (event.sender !== win.webContents || r.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
if (r.error) {
reject(new Error(r.error))
} else {
resolve({ tabId: r.tabId!, title: r.title ?? '' })
}
}
opts.signal?.addEventListener('abort', onAbort, { once: true })
ipcMain.on('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
afterTabId: afterDesktopTabId,
targetGroupId: opts.targetGroupId,
command: startupCommand.command,
cwd,
...(startupCommand.env ? { env: startupCommand.env } : {}),
...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}),
...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}),
...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
startupCommandDelivery: startupCommand.startupCommandDelivery,
source: 'runtime-session',
activate: opts.activate
})
})
if (opts.activate !== false) {
this.notifier?.focusTerminal(reply.tabId, worktreeId, null)
if (win.webContents.isDestroyed?.()) {
throw new Error('runtime_unavailable')
}
// Why: register the wait before the renderer's PTY spawn arrives so that
// spawn (registerPty) can publish the pty-backed surface main-side even if
// graph-sync is stalled (#7587). Removed in the finally below.
const pendingCreateKey = `${worktreeId}::${reply.tabId}`
// Why: a rescue publishes into the active group (opts.targetGroupId is not
// threaded); the renderer's reconciling publication then moves the tab to the
// requested group, so any wrong-group placement is cosmetic and stall-window-only.
this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, {
activate: opts.activate !== false,
selectIfNoActiveTab: true,
...(opts.viewMode ? { viewMode: opts.viewMode } : {})
})
const releasePublicationThrottle = pairedCreate
? this.rendererPublicationThrottle.acquire(win.webContents)
: () => {}
try {
// Why: the PTY spawn and the tabCreate reply race on independent IPC
// channels; if the spawn already registered, publish immediately so the
// wait resolves without depending on a graph sync.
this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS,
signal: opts.signal
})
if (this.isReadyMobileTerminalSurface(surface)) {
return surface
}
const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS,
requireReady: true,
signal: opts.signal
}).catch(() => null)
if (readySurface) {
return readySurface
}
if (opts.signal?.aborted) {
// Why: nobody awaits this create anymore; don't materialize or roll back — the renderer's own publication settles the tab.
throw new Error('client_disconnected')
}
const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId)
if (!pendingSurface) {
throw new Error('Timed out waiting for terminal surface after creation')
}
// Why: a hidden renderer can publish the tab shell before the PTY spawns; reuse the same identity so later focus adopts instead of creating another tab.
return await this.createHeadlessMobileSessionTerminal(
worktreeId,
opts.activate !== false,
opts.afterTabId,
{
const requestId = randomUUID()
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
reject(new Error('Terminal creation timed out'))
}, 10_000)
// Why: a dead client connection cancels the wait; the renderer tab (and
// its shell) stays alive for the host and mirrors on reconnect (#7718).
const onAbort = (): void => {
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
reject(new Error('client_disconnected'))
}
const handler = (
event: Electron.IpcMainEvent,
r: { requestId: string; tabId?: string; title?: string; error?: string }
): void => {
if (event.sender !== win.webContents || r.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
opts.signal?.removeEventListener('abort', onAbort)
if (r.error) {
reject(new Error(r.error))
} else {
resolve({ tabId: r.tabId!, title: r.title ?? '' })
}
}
opts.signal?.addEventListener('abort', onAbort, { once: true })
ipcMain.on('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
afterTabId: afterDesktopTabId,
targetGroupId: opts.targetGroupId,
command: startupCommand.command,
cwd,
env: startupCommand.env,
envToDelete: startupCommand.envToDelete,
...(startupCommand.env ? { env: startupCommand.env } : {}),
...(startupCommand.envToDelete ? { envToDelete: startupCommand.envToDelete } : {}),
...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}),
...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
startupCommandDelivery: startupCommand.startupCommandDelivery,
identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId },
launchAgent: startupCommand.launchAgent,
viewMode: opts.viewMode,
targetGroupId: opts.targetGroupId,
launchConfig: startupCommand.launchConfig
}
)
} catch (error) {
// Why: publication latency (hidden renderer) can trip the surface timeout; rescue only when a live PTY backs the tab, else a ghost tab skips rollback (#7587).
if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) {
const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
if (rescued) {
return rescued
}
source: 'runtime-session',
activate: opts.activate
})
})
if (opts.activate !== false) {
this.notifier?.focusTerminal(reply.tabId, worktreeId, null)
}
// Why: don't roll back on a client disconnect or a live shell already backing the tab — that would kill a visible terminal ("tab dies after ~10s", #7718).
if (
isClientDisconnectedError(error) ||
this.hasLiveShellForRendererTab(worktreeId, reply.tabId)
) {
// Why: register the wait before the renderer's PTY spawn arrives so that
// spawn (registerPty) can publish the pty-backed surface main-side even if
// graph-sync is stalled (#7587). Removed in the finally below.
const pendingCreateKey = `${worktreeId}::${reply.tabId}`
// Why: a rescue publishes into the active group (opts.targetGroupId is not
// threaded); the renderer's reconciling publication then moves the tab to the
// requested group, so any wrong-group placement is cosmetic and stall-window-only.
this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, {
activate: opts.activate !== false,
paired: pairedCreate,
selectIfNoActiveTab: true,
...(opts.viewMode ? { viewMode: opts.viewMode } : {})
})
try {
// Why: the PTY spawn and the tabCreate reply race on independent IPC
// channels; if the spawn already registered, publish immediately so the
// wait resolves without depending on a graph sync.
this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
const surface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
timeoutMs: MOBILE_TERMINAL_SURFACE_TIMEOUT_MS,
signal: opts.signal
})
if (this.isReadyMobileTerminalSurface(surface)) {
return surface
}
const readySurface = await this.waitForMobileTerminalSurface(worktreeId, reply.tabId, {
timeoutMs: MOBILE_TERMINAL_READY_FALLBACK_MS,
requireReady: true,
signal: opts.signal
}).catch(() => null)
if (readySurface) {
return readySurface
}
if (opts.signal?.aborted) {
// Why: nobody awaits this create anymore; don't materialize or roll back — the renderer's own publication settles the tab.
throw new Error('client_disconnected')
}
const pendingSurface = this.findMobileTerminalSurface(worktreeId, reply.tabId)
if (!pendingSurface) {
throw new Error('Timed out waiting for terminal surface after creation')
}
// Why: a hidden renderer can publish the tab shell before the PTY spawns; reuse the same identity so later focus adopts instead of creating another tab.
return await this.createRuntimeOwnedMobileSessionTerminal(
worktreeId,
opts.activate !== false,
opts.afterTabId,
{
command: startupCommand.command,
cwd,
env: startupCommand.env,
envToDelete: startupCommand.envToDelete,
startupCommandDelivery: startupCommand.startupCommandDelivery,
identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId },
launchAgent: startupCommand.launchAgent,
viewMode: opts.viewMode,
targetGroupId: opts.targetGroupId,
launchConfig: startupCommand.launchConfig,
signal: opts.signal
}
)
} catch (error) {
// Why: publication latency (hidden renderer) can trip the surface timeout; rescue only when a live PTY backs the tab, else a ghost tab skips rollback (#7587).
if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) {
const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId)
if (rescued) {
return rescued
}
}
// Why: don't roll back on a client disconnect or a live shell already backing the tab — that would kill a visible terminal ("tab dies after ~10s", #7718).
if (
isClientDisconnectedError(error) ||
this.hasLiveShellForRendererTab(worktreeId, reply.tabId)
) {
throw error
}
// Why: renderer made the tab but no live PTY backs it (real spawn/handle failure); roll it back so it can't linger as a ghost in mobile snapshots.
this.notifier?.closeTerminal(reply.tabId)
throw error
} finally {
this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey)
}
// Why: renderer made the tab but no live PTY backs it (real spawn/handle failure); roll it back so it can't linger as a ghost in mobile snapshots.
this.notifier?.closeTerminal(reply.tabId)
throw error
} finally {
this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey)
releasePublicationThrottle()
}
}
@@ -24909,7 +25044,7 @@ export class OrcaRuntimeService {
}
}
private async createHeadlessMobileSessionTerminal(
private async createRuntimeOwnedMobileSessionTerminal(
worktreeId: string,
activate: boolean,
afterTabId?: string,
@@ -24924,6 +25059,7 @@ export class OrcaRuntimeService {
viewMode?: 'terminal' | 'chat'
targetGroupId?: string
launchConfig?: SleepingAgentLaunchConfig
signal?: AbortSignal
} = {}
): Promise<RuntimeMobileSessionCreateTerminalResult> {
const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`)
@@ -24952,7 +25088,8 @@ export class OrcaRuntimeService {
: {}),
persistHostSessionBinding: true,
// Why: this method publishes the authoritative snapshot below; skip the intermediate publish to avoid a wrong-group flash.
deferMobileSessionPublish: true
deferMobileSessionPublish: true,
signal: opts.signal
})
const livePty = this.getLivePtyForHandle(terminal.handle)
if (!livePty) {
@@ -25148,6 +25285,9 @@ export class OrcaRuntimeService {
const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId)
if (pty) {
pty.runtimeSessionOwned = true
if (pending.paired) {
this.setPairedRendererSessionOwnership(pty.ptyId, true)
}
}
if (
existing &&
@@ -25176,6 +25316,69 @@ export class OrcaRuntimeService {
return this.findMobileTerminalSurface(worktreeId, tabId)
}
private restoreLivePairedRendererSessionOwnedMobileTerminals(
worktreeId: string | null,
options: { missingSnapshotOnly?: boolean; notify?: boolean } = {}
): void {
for (const ptyId of this.pairedRendererSessionOwnedPtyIds) {
const pty = this.ptysById.get(ptyId)
if (
!pty?.connected ||
!pty.tabId ||
(worktreeId !== null && !runtimeWorktreeIdsEqual(pty.worktreeId, worktreeId))
) {
continue
}
const targetWorktreeId = worktreeId ?? pty.worktreeId
const pane = parsePaneKey(pty.paneKey ?? '')
if (!pane || pane.tabId !== pty.tabId) {
continue
}
const existing = this.mobileSessionTabsByWorktree.get(targetWorktreeId)
if (existing && options.missingSnapshotOnly) {
continue
}
if (
existing?.tabs.some(
(tab) =>
tab.type === 'terminal' &&
(tab.ptyId === pty.ptyId ||
(tab.parentTabId === pty.tabId && tab.leafId === pane.leafId))
)
) {
continue
}
if (!existing) {
this.mobileSessionTabsByWorktree.set(targetWorktreeId, {
worktree: targetWorktreeId,
publicationEpoch: `renderer-rescue:${Date.now().toString(36)}`,
snapshotVersion: 0,
activeGroupId: null,
activeTabId: null,
activeTabType: null,
tabGroups: [],
tabs: []
})
}
this.publishPtyBackedMobileSessionTerminal(targetWorktreeId, pty, {
tabId: pty.tabId,
leafId: pane.leafId,
title: null,
activate: false,
selectIfNoActiveTab: false,
notify: options.notify
})
}
}
private setPairedRendererSessionOwnership(ptyId: string, owned: boolean): void {
if (owned) {
this.pairedRendererSessionOwnedPtyIds.add(ptyId)
} else {
this.pairedRendererSessionOwnedPtyIds.delete(ptyId)
}
}
private findLiveRegisteredPtyForRendererTab(
worktreeId: string,
tabId: string
@@ -25602,6 +25805,10 @@ export class OrcaRuntimeService {
createdPty.tabId = parentTabId
createdPty.paneKey = paneKey
createdPty.runtimeSessionOwned = pty.runtimeSessionOwned
this.setPairedRendererSessionOwnership(
createdPty.ptyId,
this.pairedRendererSessionOwnedPtyIds.has(pty.ptyId)
)
}
try {
@@ -25617,6 +25824,7 @@ export class OrcaRuntimeService {
splitTelemetrySource: opts.telemetrySource
})
} catch (error) {
this.setPairedRendererSessionOwnership(result.id, false)
this.ptyController.kill?.(result.id)
throw error
}
@@ -27978,6 +28186,7 @@ export class OrcaRuntimeService {
private dropDisconnectedPtyRecord(ptyId: string): void {
// Why: pruning can remove a PTY without the normal exit callback.
this.advancePtyLifecycleGeneration(ptyId)
this.pairedRendererSessionOwnedPtyIds.delete(ptyId)
this.ptysById.delete(ptyId)
this.recentPtyOutputById.delete(ptyId)
this.setupCompletionTokenByPtyId.delete(ptyId)
@@ -28110,15 +28319,27 @@ export class OrcaRuntimeService {
// renderer resends before they replace an entry — so reference identity
// before/after detects exactly the entries that actually changed.
const before = new Map(this.mobileSessionTabsByWorktree)
const worktreeIdsToHydrate = this.getKnownWorkspaceSessionWorktreeIds()
for (const snapshot of snapshots) {
worktreeIdsToHydrate.add(snapshot.worktree)
this.restoreLivePairedRendererSessionOwnedMobileTerminals(null, {
missingSnapshotOnly: true,
notify: false
})
// Why: graph sync must scan each persisted host session once, not once per workspace.
const worktreeSessionsToHydrate = new Map<string, WorkspaceSessionState | null>(
this.getWorkspaceSessionHydrationTargets(Boolean(this.offscreenBrowserBackend))
)
if (this.offscreenBrowserBackend) {
for (const snapshot of snapshots) {
if (!worktreeSessionsToHydrate.has(snapshot.worktree)) {
worktreeSessionsToHydrate.set(snapshot.worktree, null)
}
}
}
// Why: an empty renderer publication after HUB restart must not hide SSH panes persisted in this HUB's host partition.
for (const worktreeId of worktreeIdsToHydrate) {
for (const [worktreeId, workspaceSession] of worktreeSessionsToHydrate) {
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
allowAttachedWindow: true,
onlyRuntimeOwnedTerminals: true
onlyRuntimeOwnedTerminals: true,
...(workspaceSession ? { runtimeOwnedTerminalCandidateKnown: true, workspaceSession } : {})
})
}
const nextWorktrees = new Set<string>()
@@ -29169,10 +29390,10 @@ export class OrcaRuntimeService {
handle: string,
db = this.getOrchestrationDbIfAvailable()
): AgentStatusOrchestrationContext | undefined {
// Why: active dispatch is authoritative for reused terminals; completed context stale-groups future work once its done row is gone.
// Why: active dispatch is authoritative for reused terminals; settled context stale-groups later work once its row is gone.
const dispatch =
db?.getActiveDispatchForTerminal?.(handle) ??
this.getRecentCompletedDispatchForTerminal(handle, db)
this.getRecentSettledDispatchForTerminal(handle, db)
if (!dispatch) {
return undefined
}
@@ -29185,7 +29406,10 @@ export class OrcaRuntimeService {
displayName: task.display_name
})
: { taskTitle: '', displayName: '' }
const activeRun = dispatch.status === 'completed' ? undefined : db?.getActiveCoordinatorRun?.()
const activeRun =
dispatch.status === 'pending' || dispatch.status === 'dispatched'
? db?.getActiveCoordinatorRun?.()
: undefined
const parentTerminalHandle =
task?.created_by_terminal_handle ??
(activeRun?.coordinator_handle && activeRun.coordinator_handle !== handle
@@ -29198,6 +29422,7 @@ export class OrcaRuntimeService {
return {
taskId: dispatch.task_id,
dispatchId: dispatch.id,
dispatchStatus: dispatch.status,
...(display.taskTitle ? { taskTitle: display.taskTitle } : {}),
...(display.displayName ? { displayName: display.displayName } : {}),
...(parentTerminalHandle ? { parentTerminalHandle } : {}),
@@ -29207,12 +29432,16 @@ export class OrcaRuntimeService {
}
}
private getRecentCompletedDispatchForTerminal(
private getRecentSettledDispatchForTerminal(
handle: string,
db = this.getOrchestrationDbIfAvailable()
): ReturnType<OrchestrationDb['getLatestDispatchForTerminal']> {
const dispatch = db?.getLatestDispatchForTerminal?.(handle)
if (dispatch?.status !== 'completed' || !dispatch.completed_at) {
if (
!dispatch?.completed_at ||
dispatch.status === 'pending' ||
dispatch.status === 'dispatched'
) {
return undefined
}
const completedAtMs = Date.parse(
@@ -473,6 +473,7 @@ describe('OrchestrationDb', () => {
const after3 = d.failDispatch(ctx3.id, 'timeout')
expect(after3?.failure_count).toBe(3)
expect(after3?.status).toBe('circuit_broken')
expect([after1, after2, after3].every((dispatch) => dispatch?.completed_at)).toBe(true)
expect(d.getTask(task.id)?.status).toBe('failed')
})
+1
View File
@@ -5826,6 +5826,7 @@ export class OrchestrationDb {
.prepare(
`UPDATE dispatch_contexts
SET status = ?, failure_count = ?, last_failure = ?,
completed_at = COALESCE(completed_at, datetime('now')),
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
WHERE id = ?`
)
@@ -28,7 +28,10 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
const result = await context.runtime.closeMobileSessionTab(
params.worktree,
params.tabId,
{ reason: 'user' }
{
reason: 'user',
...(context.pairedDeviceId ? { clientNavigationId: context.pairedDeviceId } : {})
}
)
span.setAttribute(
'decision',
@@ -68,7 +71,8 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
{
reason: params.reason,
expectedPublicationEpoch: params.publicationEpoch,
expectedTerminalHandle: params.terminal
expectedTerminalHandle: params.terminal,
...(context.pairedDeviceId ? { clientNavigationId: context.pairedDeviceId } : {})
}
)
span.setAttribute(
@@ -136,7 +136,8 @@ describe('session tab RPC methods', () => {
expect(replies).toHaveLength(1)
expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', {
reason: 'user'
reason: 'user',
clientNavigationId: 'current-runtime'
})
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
})
@@ -180,7 +181,8 @@ describe('session tab RPC methods', () => {
expect(replies).toHaveLength(1)
expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', {
reason: 'user'
reason: 'user',
clientNavigationId: 'legacy-runtime'
})
expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled()
})
+41
View File
@@ -3419,6 +3419,47 @@ describe('OrcaRuntimeRpcServer', () => {
)
})
it('rejects unpaired terminal creates before runtime dispatch', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const createMobileSessionTerminal = vi.fn()
const runtime = {
getRuntimeId: () => 'test-runtime',
createMobileSessionTerminal
} as unknown as OrcaRuntimeService
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
const replies: Record<string, unknown>[] = []
const send = async (id: string, deviceToken?: string): Promise<void> => {
await server['handleWebSocketMessage'](
JSON.stringify({
id,
method: 'session.tabs.createTerminal',
...(deviceToken ? { deviceToken } : {}),
params: { worktree: 'id:wt-1' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
}
await send('req_missing')
await send('req_invalid', 'invalid-token')
expect(replies).toEqual([
expect.objectContaining({
id: 'req_missing',
error: expect.objectContaining({ code: 'unauthorized' }),
ok: false
}),
expect.objectContaining({
id: 'req_invalid',
error: expect.objectContaining({ code: 'unauthorized' }),
ok: false
})
])
expect(createMobileSessionTerminal).not.toHaveBeenCalled()
})
it('allows runtime-scoped WebSocket tokens to use the full RPC surface', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true })
@@ -15,6 +15,7 @@ const RELAY_DIR = '.orca-remote/relay-0.1.0+hash'
const MARKER = '.install-lock/.sftp-namespace-deadbeef'
const mapping: SftpNamespacePathMapping = {
homeRelativeNamespaceRoot: RELAY_DIR,
homeRelativePath: RELAY_DIR,
shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/${MARKER}`,
homeRelativeProbePath: `${RELAY_DIR}/${MARKER}`
@@ -249,12 +250,12 @@ describe('resolveSftpTransferPath', () => {
'marker paths must share one marker basename'
],
[
'a marker outside the install lock',
'a marker at the namespace root',
{
shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/other-lock/.sftp-namespace-deadbeef`,
homeRelativeProbePath: `${RELAY_DIR}/other-lock/.sftp-namespace-deadbeef`
shellProbePath: `${SHELL_HOME}/${RELAY_DIR}`,
homeRelativeProbePath: RELAY_DIR
},
'inside the transfer relay install lock'
'inside one namespace root'
],
[
'a marker under another relay tree',
@@ -262,7 +263,7 @@ describe('resolveSftpTransferPath', () => {
shellProbePath: `${SHELL_HOME}/other/.install-lock/.sftp-namespace-deadbeef`,
homeRelativeProbePath: 'other/.install-lock/.sftp-namespace-deadbeef'
},
'inside the transfer relay install lock'
'inside one namespace root'
]
])('rejects %s before issuing any SFTP request', async (_label, overrides, expected) => {
const { shell, ...mappingOverrides } = overrides as Record<string, string>
@@ -282,6 +283,7 @@ describe('resolveSftpTransferPath', () => {
const token = 'a'.repeat(32)
const secretMarker = `.install-lock/.sftp-namespace-${token}`
const secretMapping: SftpNamespacePathMapping = {
homeRelativeNamespaceRoot: RELAY_DIR,
homeRelativePath: RELAY_DIR,
shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/${secretMarker}`,
homeRelativeProbePath: `${RELAY_DIR}/${secretMarker}`
+7 -6
View File
@@ -10,6 +10,7 @@ import type { RemoteHostPlatform } from './ssh-remote-platform'
import { redactRelayInstallMarkerTokens } from './ssh-relay-install-marker'
export type SftpNamespacePathMapping = {
homeRelativeNamespaceRoot: string
homeRelativePath: string
shellProbePath: string
homeRelativeProbePath: string
@@ -72,14 +73,13 @@ function assertMappingIdentity(shellAbsolutePath: string, mapping: SftpNamespace
throw new Error('SFTP namespace transfer and marker must share one shell namespace prefix')
}
const lockSegmentIndex = probeSegments.length - 2
const relayDir = probeSegments.slice(0, lockSegmentIndex).join('/')
const namespaceRoot = mapping.homeRelativeNamespaceRoot
if (
lockSegmentIndex < 1 ||
probeSegments[lockSegmentIndex] !== '.install-lock' ||
(mapping.homeRelativePath !== relayDir && !mapping.homeRelativePath.startsWith(`${relayDir}/`))
(mapping.homeRelativePath !== namespaceRoot &&
!mapping.homeRelativePath.startsWith(`${namespaceRoot}/`)) ||
!mapping.homeRelativeProbePath.startsWith(`${namespaceRoot}/`)
) {
throw new Error('SFTP namespace marker must be inside the transfer relay install lock')
throw new Error('SFTP namespace transfer and marker must be inside one namespace root')
}
}
@@ -159,6 +159,7 @@ export async function resolveSftpTransferPath(
): Promise<string> {
assertAbsolutePosixPath('transfer path', shellAbsolutePath)
assertAbsolutePosixPath('marker path', mapping.shellProbePath)
assertHomeRelativePosixPath('relative namespace root', mapping.homeRelativeNamespaceRoot)
assertHomeRelativePosixPath('relative transfer path', mapping.homeRelativePath)
assertHomeRelativePosixPath('relative marker path', mapping.homeRelativeProbePath)
assertMappingIdentity(shellAbsolutePath, mapping)
+58 -1
View File
@@ -1,4 +1,5 @@
import { mkdtemp, mkdir, realpath, symlink, writeFile } from 'node:fs/promises'
import { spawnSync } from 'node:child_process'
import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Writable } from 'node:stream'
@@ -114,6 +115,62 @@ describe('sftp-upload', () => {
expect(sftp.createWriteStream).not.toHaveBeenCalled()
})
it('joins local file-descriptor teardown when a live upload is aborted', async () => {
const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-abort-'))
const localPath = join(localDir, 'relay.js')
const controller = new AbortController()
const blockedWrite = new Writable({
write() {}
})
const sftp = createSftpMock()
vi.mocked(sftp.createWriteStream).mockReturnValue(blockedWrite as never)
try {
await writeFile(localPath, Buffer.alloc(1024 * 1024, 7))
const upload = uploadFile(sftp, localPath, '/remote/relay.js', {
signal: controller.signal
})
await vi.waitFor(() => expect(sftp.createWriteStream).toHaveBeenCalledTimes(1))
controller.abort()
await expect(upload).rejects.toMatchObject({ name: 'AbortError' })
if (process.platform !== 'win32') {
const descriptorProbe = spawnSync(
'lsof',
['-a', '-p', String(process.pid), '--', localPath],
{ encoding: 'utf8' }
)
if (!descriptorProbe.error) {
expect(descriptorProbe.stdout).not.toContain(localPath)
}
}
} finally {
await rm(localDir, { recursive: true, force: true })
}
})
it('joins the local read when the remote write fails', async () => {
const localDir = await mkdtemp(join(tmpdir(), 'orca-sftp-upload-failure-'))
const localPath = join(localDir, 'relay.js')
const sftp = createSftpMock()
vi.mocked(sftp.createWriteStream).mockReturnValue(
new Writable({
write(_chunk, _encoding, callback) {
callback(new Error('remote write failed'))
}
}) as never
)
try {
await writeFile(localPath, Buffer.alloc(1024 * 1024, 7))
await expect(uploadFile(sftp, localPath, '/remote/relay.js')).rejects.toThrow(
'remote write failed'
)
} finally {
await rm(localDir, { recursive: true, force: true })
}
})
it('removes remote directory contents before removing the directory', async () => {
const sftp = createSftpMock()
vi.mocked(sftp.readdir).mockImplementation((remotePath, cb) => {
+74 -57
View File
@@ -2,6 +2,7 @@ import { constants } from 'node:fs'
import type { ReadStream } from 'node:fs'
import { lstat, open, readdir, realpath } from 'node:fs/promises'
import { isAbsolute, join as pathJoin, relative, sep } from 'node:path'
import { finished } from 'node:stream/promises'
import type { SFTPWrapper } from 'ssh2'
export function mkdirSftp(
@@ -28,67 +29,81 @@ export function uploadFile(
sftp: SFTPWrapper,
localPath: string,
remotePath: string,
options?: { exclusive?: boolean }
options?: { exclusive?: boolean; signal?: AbortSignal }
): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false
let readStream: ReadStream | null = null
let fileHandle: Awaited<ReturnType<typeof open>> | null = null
let writeStream: ReturnType<SFTPWrapper['createWriteStream']> | null = null
return uploadFileAndJoinTeardown(sftp, localPath, remotePath, options)
}
const cleanupListeners = (): void => {
writeStream?.off('close', onWriteClose)
writeStream?.off('error', onWriteError)
readStream?.off('error', onReadError)
async function uploadFileAndJoinTeardown(
sftp: SFTPWrapper,
localPath: string,
remotePath: string,
options?: { exclusive?: boolean; signal?: AbortSignal }
): Promise<void> {
const handle = await open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
let handleClose: Promise<void> | undefined
let readStream: ReadStream | undefined
let writeStream: ReturnType<SFTPWrapper['createWriteStream']> | undefined
const closeHandle = (): Promise<void> => {
handleClose ??= handle.close()
return handleClose
}
try {
options?.signal?.throwIfAborted()
const statResult = await lstat(localPath)
if (statResult.isSymbolicLink() || !statResult.isFile()) {
throw new Error(`Unsupported upload source: ${localPath}`)
}
const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => {
if (settled) {
return
}
settled = true
cleanupListeners()
readStream?.destroy()
const openedStat = await handle.stat()
if (
!openedStat.isFile() ||
openedStat.size !== statResult.size ||
(statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) ||
(statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev)
) {
throw new Error(`File changed during upload: ${localPath}`)
}
// Why: rejected local sources must not leave an empty remote file.
writeStream = sftp.createWriteStream(remotePath, {
flags: options?.exclusive ? 'wx' : 'w'
})
readStream = handle.createReadStream({ autoClose: false })
const abortTransfer = (): void => {
const reason =
options?.signal?.reason instanceof Error
? options.signal.reason
: Object.assign(new Error('Upload aborted'), { name: 'AbortError' })
readStream?.destroy(reason)
writeStream?.destroy()
void fileHandle?.close().catch(() => {})
fn(val as never)
void closeHandle().catch(() => {})
}
const onWriteClose = (): void => settle(resolve)
const onWriteError = (err: Error): void => settle(reject, err)
const onReadError = (err: Error): void => settle(reject, err)
void open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
.then(async (handle) => {
if (settled) {
void handle.close().catch(() => {})
return
}
fileHandle = handle
const statResult = await lstat(localPath)
if (statResult.isSymbolicLink() || !statResult.isFile()) {
throw new Error(`Unsupported upload source: ${localPath}`)
}
const openedStat = await handle.stat()
if (
!openedStat.isFile() ||
openedStat.size !== statResult.size ||
(statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) ||
(statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev)
) {
throw new Error(`File changed during upload: ${localPath}`)
}
// Why: validate the local source before creating the remote write
// target, so rejected sources do not leave empty files behind.
writeStream = sftp.createWriteStream(remotePath, {
flags: options?.exclusive ? 'wx' : 'w'
})
writeStream.on('close', onWriteClose)
writeStream.on('error', onWriteError)
readStream = handle.createReadStream()
readStream.on('error', onReadError)
readStream.pipe(writeStream)
options?.signal?.addEventListener('abort', abortTransfer, { once: true })
if (options?.signal?.aborted) {
abortTransfer()
}
try {
const readDone = finished(readStream, { cleanup: true }).catch((error: unknown) => {
writeStream?.destroy()
throw error
})
.catch((err: unknown) => settle(reject, err))
})
const writeDone = finished(writeStream, { cleanup: true }).catch((error: unknown) => {
readStream?.destroy(error instanceof Error ? error : undefined)
throw error
})
readStream.pipe(writeStream)
const results = await Promise.allSettled([readDone, writeDone])
const failure = results.find((result) => result.status === 'rejected')
if (failure?.status === 'rejected') {
throw failure.reason
}
} finally {
options?.signal?.removeEventListener('abort', abortTransfer)
}
} finally {
readStream?.destroy()
writeStream?.destroy()
await closeHandle()
}
}
export function uploadBuffer(
@@ -167,11 +182,13 @@ export async function uploadDirectory(
localDir: string,
remoteDir: string,
rootRealPath = localDir,
options?: { exclusive?: boolean }
options?: { exclusive?: boolean; signal?: AbortSignal }
): Promise<void> {
options?.signal?.throwIfAborted()
await assertLocalUploadPathInsideRoot(rootRealPath, localDir)
const entries = await readdir(localDir, { withFileTypes: true })
for (const entry of entries) {
options?.signal?.throwIfAborted()
const localPath = pathJoin(localDir, entry.name)
const remotePath = `${remoteDir}/${entry.name}`
await assertLocalUploadPathInsideRoot(rootRealPath, localPath)
@@ -189,7 +206,7 @@ export async function uploadDirectory(
await mkdirSftp(sftp, remotePath, { allowExisting: !options?.exclusive })
await uploadDirectory(sftp, localPath, remotePath, rootRealPath, options)
} else {
await uploadFile(sftp, localPath, remotePath, { exclusive: options?.exclusive })
await uploadFile(sftp, localPath, remotePath, options)
}
}
}
@@ -43,6 +43,7 @@ const MARKER = '.install-lock/.sftp-namespace-cafebabe'
const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_DIR}`
const namespace: SftpNamespacePathMapping = {
homeRelativeNamespaceRoot: RELAY_DIR,
homeRelativePath: RELAY_DIR,
shellProbePath: `${SHELL_RELAY_DIR}/${MARKER}`,
homeRelativeProbePath: `${RELAY_DIR}/${MARKER}`
@@ -267,6 +267,7 @@ async function boundedTransfer(
function splitNamespaceMapping(homeRelativePath: string): SftpNamespacePathMapping {
return {
homeRelativeNamespaceRoot: RELAY_DIR,
homeRelativePath,
shellProbePath: `${SHELL_RELAY_DIR}/${MARKER_PATH}`,
homeRelativeProbePath: `${RELAY_DIR}/${MARKER_PATH}`
+5 -1
View File
@@ -440,11 +440,14 @@ export class SshConnection {
const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options)
linkedSignal.signal.throwIfAborted()
const { uploadDirectory } = await import('./ssh-relay-deploy-helpers')
await uploadDirectory(sftp, localDir, targetDir)
await uploadDirectory(sftp, localDir, targetDir, localDir, {
signal: linkedSignal.signal
})
})()
await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => {
sftp.once('close', onClose)
endSftp()
return () => sftp.removeListener('close', onClose)
})
} finally {
endSftp()
@@ -543,6 +546,7 @@ export class SshConnection {
await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => {
sftp.once('close', onClose)
endSftp()
return () => sftp.removeListener('close', onClose)
})
} finally {
endSftp()
+66 -4
View File
@@ -2,11 +2,14 @@ import { describe, expect, it, vi } from 'vitest'
import { raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort'
describe('raceSftpFileTransferWithAbort', () => {
it('waits for confirmed SFTP close before rejecting an abort', async () => {
it('joins confirmed SFTP close and transfer teardown before rejecting an abort', async () => {
const controller = new AbortController()
let confirmClose: () => void = () => {}
let settleTransfer: () => void = () => {}
const promise = raceSftpFileTransferWithAbort(
new Promise<void>(() => {}),
new Promise<void>((resolve) => {
settleTransfer = resolve
}),
controller.signal,
(onClose) => {
confirmClose = onClose
@@ -24,9 +27,20 @@ describe('raceSftpFileTransferWithAbort', () => {
expect(pending).toBe('pending')
confirmClose()
const stillPending = await Promise.race([
promise.then(
() => 'settled',
() => 'settled'
),
Promise.resolve('pending')
])
expect(stillPending).toBe('pending')
settleTransfer()
await expect(promise).rejects.toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: true
sshChannelCloseConfirmed: true,
sshTransferTeardownConfirmed: true
})
})
@@ -46,10 +60,58 @@ describe('raceSftpFileTransferWithAbort', () => {
await expect(outcome).resolves.toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: false
sshChannelCloseConfirmed: false,
sshTransferTeardownConfirmed: false
})
} finally {
vi.useRealTimers()
}
})
it('marks transfer teardown unconfirmed when close wins but the transfer never settles', async () => {
vi.useFakeTimers()
try {
const controller = new AbortController()
const promise = raceSftpFileTransferWithAbort(
new Promise<void>(() => {}),
controller.signal,
(onClose) => onClose()
)
const outcome = promise.catch((error: Error) => error)
controller.abort()
await vi.advanceTimersByTimeAsync(5_000)
await expect(outcome).resolves.toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: true,
sshTransferTeardownConfirmed: false
})
} finally {
vi.useRealTimers()
}
})
it('removes the close waiter when the teardown deadline expires', async () => {
vi.useFakeTimers()
try {
const controller = new AbortController()
const removeCloseListener = vi.fn()
const outcome = raceSftpFileTransferWithAbort(
new Promise<void>(() => {}),
controller.signal,
() => removeCloseListener
).catch((error: Error) => error)
controller.abort()
await vi.advanceTimersByTimeAsync(5_000)
await expect(outcome).resolves.toMatchObject({
sshTransferTeardownConfirmed: false
})
expect(removeCloseListener).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
})
+29 -5
View File
@@ -27,12 +27,20 @@ export function createLinkedSshFileTransferSignal(signals: readonly AbortSignal[
export function raceSftpFileTransferWithAbort<T>(
operation: Promise<T>,
signal: AbortSignal,
closeSftp: (onClose: () => void) => void
closeSftp: (onClose: () => void) => (() => void) | void
): Promise<T> {
return new Promise((resolve, reject) => {
let settled = false
let abortError: (Error & { sshChannelCloseConfirmed: boolean }) | null = null
let operationSettled = false
let sftpClosed = false
let abortError:
| (Error & {
sshChannelCloseConfirmed: boolean
sshTransferTeardownConfirmed: boolean
})
| null = null
let closeGraceTimer: ReturnType<typeof setTimeout> | null = null
let removeCloseListener: (() => void) | undefined
const settle = (fn: typeof resolve | typeof reject, value: T | Error): void => {
if (settled) {
return
@@ -42,30 +50,46 @@ export function raceSftpFileTransferWithAbort<T>(
clearTimeout(closeGraceTimer)
}
signal.removeEventListener('abort', onAbort)
removeCloseListener?.()
removeCloseListener = undefined
fn(value as never)
}
const onAbort = (): void => {
// Why: rejecting the caller is insufficient; ending SFTP stops the
// abandoned transfer from mutating a successor relay install.
abortError = Object.assign(createSshOperationAbortError(), {
sshChannelCloseConfirmed: false
sshChannelCloseConfirmed: false,
sshTransferTeardownConfirmed: false
})
closeGraceTimer = setTimeout(() => settle(reject, abortError!), 5_000)
closeSftp(() => {
const unregisterClose = closeSftp(() => {
sftpClosed = true
abortError!.sshChannelCloseConfirmed = true
settle(reject, abortError!)
if (operationSettled) {
abortError!.sshTransferTeardownConfirmed = true
settle(reject, abortError!)
}
})
removeCloseListener = typeof unregisterClose === 'function' ? unregisterClose : undefined
}
signal.addEventListener('abort', onAbort, { once: true })
void operation.then(
(value) => {
operationSettled = true
if (!abortError) {
settle(resolve, value)
} else if (sftpClosed) {
abortError.sshTransferTeardownConfirmed = true
settle(reject, abortError)
}
},
(error: unknown) => {
operationSettled = true
if (!abortError) {
settle(reject, error instanceof Error ? error : new Error(String(error)))
} else if (sftpClosed) {
abortError.sshTransferTeardownConfirmed = true
settle(reject, abortError)
}
}
)
+112
View File
@@ -0,0 +1,112 @@
import { randomUUID } from 'node:crypto'
import type { SshPtyConsumerRecovery as PersistedRecovery } from '../../shared/ssh-types'
import type { SshPtyAcceptedSourceCheckpoint } from '../ipc/ssh-pty-output-source-obligations'
import type { SshPtyOutputMigrationResult } from '../ipc/ssh-pty-output-model-migration'
import type { Store } from '../persistence'
import type { SshPtyConsumerOwnerState } from './ssh-pty-consumer-session'
export type SshPtyConsumerRecoveryState = {
clientInstanceId: string
detached: boolean
serverBuildId?: string
owner?: SshPtyConsumerOwnerState
checkpointsByAppPtyId: Map<string, SshPtyAcceptedSourceCheckpoint>
modelMigrationsByAppPtyId: Map<string, Promise<SshPtyOutputMigrationResult>>
}
const recoveryByTarget = new Map<string, SshPtyConsumerRecoveryState>()
function ownerFromPersisted(record: PersistedRecovery): SshPtyConsumerOwnerState {
return {
mode: 'negotiated',
clientInstanceId: record.clientInstanceId,
clientGeneration: record.clientGeneration,
ownerGeneration: record.ownerGeneration,
ownerLease: record.ownerLease,
...(record.outputFlowControl ? { outputFlowControl: record.outputFlowControl } : {})
}
}
export function claimSshPtyConsumerRecovery(
targetId: string,
store: Store
): SshPtyConsumerRecoveryState {
const current = recoveryByTarget.get(targetId)
if (current?.detached) {
current.detached = false
return current
}
const persisted = current ? null : store.getSshPtyConsumerRecovery(targetId)
const created: SshPtyConsumerRecoveryState = {
clientInstanceId: persisted?.clientInstanceId ?? randomUUID(),
detached: false,
...(persisted
? { serverBuildId: persisted.serverBuildId, owner: ownerFromPersisted(persisted) }
: {}),
checkpointsByAppPtyId: new Map(),
modelMigrationsByAppPtyId: new Map()
}
recoveryByTarget.set(targetId, created)
return created
}
export function getSshPtyConsumerRecovery(
targetId: string
): SshPtyConsumerRecoveryState | undefined {
return recoveryByTarget.get(targetId)
}
export function rememberSshPtyConsumerRecovery(args: {
targetId: string
clientInstanceId: string
serverBuildId: string
owner: SshPtyConsumerOwnerState
store: Store
}): void {
const current = recoveryByTarget.get(args.targetId)
if (current?.clientInstanceId !== args.clientInstanceId) {
return
}
current.detached = false
current.serverBuildId = args.serverBuildId
current.owner = args.owner
args.store.upsertSshPtyConsumerRecovery({
targetId: args.targetId,
clientInstanceId: args.clientInstanceId,
serverBuildId: args.serverBuildId,
clientGeneration: args.owner.clientGeneration,
ownerGeneration: args.owner.ownerGeneration,
ownerLease: args.owner.ownerLease,
...(args.owner.outputFlowControl ? { outputFlowControl: args.owner.outputFlowControl } : {})
})
}
export function removeSshPtyConsumerOwnerRecovery(
targetId: string,
clientInstanceId: string,
store: Store
): void {
const persisted = store.getSshPtyConsumerRecovery(targetId)
if (persisted?.clientInstanceId === clientInstanceId) {
store.removeSshPtyConsumerRecovery(targetId)
}
}
export function detachSshPtyConsumerRecovery(targetId: string, clientInstanceId: string): void {
const current = recoveryByTarget.get(targetId)
if (current?.clientInstanceId === clientInstanceId) {
current.detached = true
}
}
export function forgetSshPtyConsumerRecovery(
targetId: string,
clientInstanceId: string,
store: Store
): void {
const current = recoveryByTarget.get(targetId)
if (current?.clientInstanceId === clientInstanceId) {
recoveryByTarget.delete(targetId)
}
removeSshPtyConsumerOwnerRecovery(targetId, clientInstanceId, store)
}
@@ -75,6 +75,17 @@ describe('openSshPtyConsumerSession', () => {
).rejects.toThrow('session contract mismatch')
})
it('rejects an owner lease that cannot be resumed through the relay protocol', async () => {
const { mux } = muxReturning(legacyOwnerGrant({ ownerLease: 'x'.repeat(513) }))
await expect(
openSshPtyConsumerSession(mux, {
clientInstanceId: 'client-a',
expectedServerBuildId: 'build-a'
})
).rejects.toThrow('did not grant')
})
it('does not silently downgrade when V1 was offered', async () => {
const { mux } = muxReturning(legacyOwnerGrant())
+2 -1
View File
@@ -64,7 +64,8 @@ function validateGrant(
!Number.isSafeInteger(grant.ownerGeneration) ||
grant.ownerGeneration! <= 0 ||
typeof grant.ownerLease !== 'string' ||
grant.ownerLease.length === 0
grant.ownerLease.length === 0 ||
grant.ownerLease.length > 512
) {
throw new Error('Remote relay did not grant an authenticated PTY session owner')
}
@@ -9,6 +9,7 @@
import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RelayInstallMarkerModule from './ssh-relay-install-marker'
vi.mock('electron', () => ({
app: { getAppPath: () => '/mock/app' }
@@ -16,7 +17,7 @@ vi.mock('electron', () => ({
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn().mockReturnValue('0.1.0+v2hash')
readFileSync: vi.fn().mockReturnValue('0.1.0+222222222222')
}))
vi.mock('./relay-protocol', () => ({
@@ -44,6 +45,11 @@ vi.mock('./ssh-remote-node-resolution', () => ({
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({
...(await importOriginal<typeof RelayInstallMarkerModule>()),
createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000'
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`
}))
@@ -98,52 +104,75 @@ describe('cross-version isolation', () => {
const mockExec = vi.mocked(execCommand)
// Simulated remote where:
// v1 dir = ~/.orca-remote/relay-0.1.0+v1hash/ (live daemon, listening)
// v2 dir = ~/.orca-remote/relay-0.1.0+v2hash/ (does not yet exist)
// The v2 client has fullVersion='0.1.0+v2hash' (from the fs mock above).
// v1 dir = ~/.orca-remote/relay-0.1.0+111111111111/ (live daemon, listening)
// v2 dir = ~/.orca-remote/relay-0.1.0+222222222222/ (does not yet exist)
// The v2 client has fullVersion='0.1.0+222222222222' (from the fs mock above).
//
// We feed enough exec results to walk through the deploy: platform,
// $HOME, isRelayAlreadyInstalled probe, lock acquire, upload (no exec),
// npm install, finalize, socket probe, credential publication, socket poll, then GC scan.
const responses: string[] = [
'__ORCA_REMOTE_PLATFORM__ Linux x86_64', // tagged POSIX platform probe
'/home/u', // echo $HOME
'MISSING', // isRelayAlreadyInstalled (v2 dir doesn't exist)
'OPEN', // no sibling GC claim
'', // mkdir -p remoteRelayDir (v2)
'OK', // mkdir lock OK
'OPEN', // GC did not claim while the install lock was acquired
'MISSING', // re-probe after lock → still missing → proceed with install
'', // mkdir remoteDir (uploadRelay)
'', // chmod +x node
'', // npm install
'', // chmod prebuilds
'ORCA-NPTY-PROBE-OK\n', // node -e require() load-test (post-install verify)
'', // rm -f probe-stderr (best-effort cleanup after probe resolved)
'', // touch .install-complete (finalizeInstall)
'DEAD', // launch socket probe
'', // publish the per-launch credential
'READY', // socket poll
'', // release .install-lock after relay liveness is observable
// GC scan begins here
'relay-0.1.0+v1hash\nrelay-0.1.0+v2hash\n', // ls listing
'OPEN', // v1 lock probe (siblings only — current dir is v2)
'COMPLETE', // v1 .install-complete probe
'ALIVE' // v1 socket probe → live → SKIP (don't GC v1)
]
for (const r of responses) {
mockExec.mockResolvedValueOnce(r)
}
mockExec.mockImplementation((_conn, command) => {
if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__')) {
return Promise.resolve(
'__ORCA_UPLOAD_STAGE_SLOT__.sftp-namespace-00000000000000000000000000000000:slot-0'
)
}
if (command.includes('__ORCA_UPLOAD_STAGE_PROMOTION__')) {
return Promise.resolve(
'__ORCA_UPLOAD_STAGE_PROMOTION__.sftp-namespace-00000000000000000000000000000000:PROMOTED'
)
}
if (command.includes('__ORCA_REMOTE_PLATFORM__')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/u')
}
if (command.includes("-name 'relay-0.1.0+222222222222.upload-*'")) {
return Promise.resolve('')
}
if (command.includes('relay-watcher.js') && command.includes('.install-complete')) {
return Promise.resolve('MISSING')
}
if (command.includes('.gc-claim') && command.includes('echo LOCKED || echo OPEN')) {
return Promise.resolve('OPEN')
}
if (command.includes('.install-lock') && command.includes('&& echo OK || echo BUSY')) {
return Promise.resolve('OK')
}
if (command.includes('ORCA-NPTY-PROBE-OK')) {
return Promise.resolve('ORCA-NPTY-PROBE-OK\n')
}
if (command.includes('process.stdout.write("READY")')) {
return Promise.resolve('READY')
}
if (command.includes('test -S') && command.includes('echo ALIVE || echo DEAD')) {
return Promise.resolve('DEAD')
}
if (command.includes('__ORCA_RELAY_GC_FIND_STATUS__')) {
return Promise.resolve('relay-0.1.0+111111111111\nrelay-0.1.0+222222222222\n')
}
if (command.includes('relay-0.1.0+111111111111/.install-lock')) {
return Promise.resolve('OPEN')
}
if (command.includes('relay-0.1.0+111111111111/.install-complete')) {
return Promise.resolve('COMPLETE')
}
if (command.includes('relay-0.1.0+111111111111') && command.includes('relay-*.sock')) {
return Promise.resolve('ALIVE')
}
return Promise.resolve('')
})
await deployAndLaunchRelay(conn)
await vi.waitFor(() =>
expect(mockExec.mock.calls.some(([, command]) => command.includes('relay-*.sock'))).toBe(true)
)
const allCmds = [
...mockExec.mock.calls.map(([, c]) => c),
...vi.mocked(conn.exec).mock.calls.map(([c]) => c as string)
]
// (a) the v2 deploy creates dirs/files under relay-0.1.0+v2hash
expect(allCmds.some((c) => c.includes('relay-0.1.0+v2hash'))).toBe(true)
// (a) the v2 deploy creates dirs/files under its content-hashed directory
expect(allCmds.some((c) => c.includes('relay-0.1.0+222222222222'))).toBe(true)
// (b) the v2 launch and connect socket paths are rooted in v2 dir, never v1
const launchAndConnectCmds = vi
@@ -152,13 +181,13 @@ describe('cross-version isolation', () => {
.filter((c) => c.includes('--sock-path'))
expect(launchAndConnectCmds.length).toBeGreaterThan(0)
for (const cmd of launchAndConnectCmds) {
expect(cmd).toContain('relay-0.1.0+v2hash')
expect(cmd).not.toContain('relay-0.1.0+v1hash')
expect(cmd).toContain('relay-0.1.0+222222222222')
expect(cmd).not.toContain('relay-0.1.0+111111111111')
}
// (c) GC observes v1 has a live socket and never issues an rm -rf for it
const v1RemoveCmds = allCmds.filter(
(c) => c.includes('rm -rf') && c.includes('relay-0.1.0+v1hash')
(c) => c.includes('rm -rf') && c.includes('relay-0.1.0+111111111111')
)
expect(v1RemoveCmds).toHaveLength(0)
@@ -167,11 +196,12 @@ describe('cross-version isolation', () => {
// — never a write, mkdir, chmod, touch, rm, node launch, or socket poll.
// This prevents a future refactor that accidentally writes to the v1 dir
// (e.g. shared install-complete, upload over symlink) from passing.
const v1Refs = allCmds.filter((c) => c.includes('relay-0.1.0+v1hash'))
const v1Refs = allCmds.filter((c) => c.includes('relay-0.1.0+111111111111'))
for (const cmd of v1Refs) {
const isReadOnlyProbe =
/^\s*ls\b/.test(cmd) ||
/\btest -d\b/.test(cmd) ||
/\btest -e\b/.test(cmd) ||
/\btest -f\b/.test(cmd) ||
/\btest -S\b/.test(cmd) ||
/\bfor f in .*\.sock\b/.test(cmd)
@@ -0,0 +1,566 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { getAppPath: () => '/mock/app' }
}))
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345')
}))
vi.mock('./relay-protocol', () => ({
RELAY_VERSION: '0.1.0',
RELAY_REMOTE_DIR: '.orca-remote',
parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => {
const normalizedOs = os.toLowerCase()
const normalizedArch = arch.toLowerCase()
const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64'
if (normalizedOs === 'windows' || normalizedOs === 'win32') {
return `win32-${relayArch}`
}
if (normalizedOs === 'darwin') {
return `darwin-${relayArch}`
}
if (normalizedOs === 'linux') {
return `linux-${relayArch}`
}
return null
}),
RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n',
RELAY_SENTINEL_TIMEOUT_MS: 10_000
}))
vi.mock('./ssh-relay-deploy-helpers', () => ({
uploadDirectory: vi.fn().mockResolvedValue(undefined),
waitForSentinel: vi.fn().mockResolvedValue({
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
}),
isUnconfirmedSshCommandTermination: (error: unknown) =>
error instanceof Error &&
(error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false,
execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}))
vi.mock('./ssh-remote-node-resolution', () => ({
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
vi.mock('./ssh-relay-versioned-install', () => ({
readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'),
computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`,
isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true),
finalizeInstall: vi.fn().mockResolvedValue(undefined),
abandonInstall: vi.fn().mockResolvedValue(undefined),
gcOldRelayVersions: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-relay-install-lock', () => ({
acquireInstallLock: vi.fn().mockResolvedValue(undefined),
RELAY_INSTALL_LOCK_NAME: '.install-lock'
}))
vi.mock('./ssh-relay-repair-lock', () => ({
tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired')
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`,
createSshOperationAbortError: () =>
Object.assign(new Error('SSH operation was cancelled'), {
name: 'AbortError'
})
}))
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers'
import { resolveRemoteNodePath } from './ssh-remote-node-resolution'
import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install'
import { acquireInstallLock } from './ssh-relay-install-lock'
import {
RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS,
RELAY_DEPLOY_TIMEOUT_MS
} from './ssh-relay-deploy-timing'
import type { SshConnection } from './ssh-connection'
function makeMockConnection(): SshConnection {
return {
canRunConcurrentExecCommands: vi.fn().mockReturnValue(true),
exec: vi.fn().mockResolvedValue({
on: vi.fn(),
stderr: { on: vi.fn() },
stdin: {},
stdout: { on: vi.fn() },
close: vi.fn()
}),
sftp: vi.fn().mockResolvedValue({
mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn((_event: string, cb: () => void) => {
if (_event === 'close') {
setTimeout(cb, 0)
}
}),
end: vi.fn()
}),
end: vi.fn()
})
} as unknown as SshConnection
}
function stageCommandResponse(command: string): string | undefined {
const marker = command.match(/\.sftp-namespace-[0-9a-f]{32}/u)?.[0]
if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__') && marker) {
return `__ORCA_UPLOAD_STAGE_SLOT__${marker}:slot-0`
}
if (command.includes('__ORCA_UPLOAD_STAGE_PROMOTION__') && marker) {
return `__ORCA_UPLOAD_STAGE_PROMOTION__${marker}:PROMOTED`
}
return command.includes('.upload-stages') ? '' : undefined
}
describe('deployAndLaunchRelay staged uploads', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
vi.mocked(waitForSentinel).mockReset().mockResolvedValue({
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
})
vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node')
vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true)
vi.mocked(acquireInstallLock).mockReset().mockResolvedValue(undefined)
})
it('aborts an in-progress relay upload at the overall deploy timeout', async () => {
vi.useFakeTimers()
try {
const conn = makeMockConnection()
vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true)
vi.mocked(execCommand).mockImplementation((_conn, command) => {
if (command.includes('uname')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/user')
}
return Promise.resolve(stageCommandResponse(command) ?? '')
})
vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false).mockResolvedValueOnce(false)
let uploadSignal: AbortSignal | undefined
conn.uploadDirectory = vi.fn((_localDir, _remoteDir, options) => {
uploadSignal = options?.signal
return new Promise<void>((_resolve, reject) => {
uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), {
once: true
})
})
})
const promise = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.advanceTimersByTimeAsync(0)
expect(conn.uploadDirectory).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(900_000)
const result = await promise
expect(result).toBeInstanceOf(Error)
expect((result as Error).message).toBe('Relay deployment timed out after 900s')
expect(uploadSignal?.aborted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('joins the bounded teardown window when an aborted transfer never settles', async () => {
vi.useFakeTimers()
try {
const conn = makeMockConnection()
vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false)
vi.mocked(execCommand).mockImplementation((_conn, command) => {
if (command.includes('uname')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/user')
}
return Promise.resolve(stageCommandResponse(command) ?? '')
})
let uploadSignal: AbortSignal | undefined
conn.uploadDirectory = vi.fn((_localDir, _remoteDir, options) => {
uploadSignal = options?.signal
return new Promise<void>(() => {})
})
const deployment = deployAndLaunchRelay(conn).catch((error: Error) => error)
await vi.advanceTimersByTimeAsync(0)
expect(conn.uploadDirectory).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS)
expect(uploadSignal?.aborted).toBe(true)
expect(
await Promise.race([deployment.then(() => 'settled'), Promise.resolve('pending')])
).toBe('pending')
await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS)
await expect(deployment).resolves.toMatchObject({
message: 'Relay deployment timed out after 900s',
sshChannelCloseConfirmed: false,
sshTransferTeardownConfirmed: false
})
} finally {
vi.useRealTimers()
}
})
it('waits for a deferred SFTP upload before acquiring the install lock', async () => {
const conn = makeMockConnection()
vi.mocked(isRelayAlreadyInstalled)
.mockReset()
.mockResolvedValueOnce(false)
.mockResolvedValue(true)
let socketProbe = 0
vi.mocked(execCommand).mockImplementation((_conn, command) => {
const stageResponse = stageCommandResponse(command)
if (stageResponse !== undefined) {
return Promise.resolve(stageResponse)
}
if (command.includes('uname')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/user')
}
if (command.includes('test -S')) {
return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY')
}
if (command.includes('ORCA-NATIVE')) {
return Promise.resolve('ORCA-NATIVE-DEPS-OK')
}
return Promise.resolve('')
})
conn.writeFile = vi.fn().mockResolvedValue(undefined)
let finishUpload: () => void = () => {}
conn.uploadDirectory = vi.fn(
() =>
new Promise<void>((resolve) => {
finishUpload = resolve
})
)
const deploy = deployAndLaunchRelay(conn).catch(() => undefined)
await vi.waitFor(() => expect(conn.uploadDirectory).toHaveBeenCalledTimes(1))
expect(acquireInstallLock).not.toHaveBeenCalled()
finishUpload()
await vi.waitFor(() => expect(acquireInstallLock).toHaveBeenCalledTimes(1))
await deploy
})
it('drops only its stage when a sibling finishes before the locked re-probe', async () => {
const conn = makeMockConnection()
const events: string[] = []
vi.mocked(isRelayAlreadyInstalled)
.mockReset()
.mockImplementationOnce(async () => {
events.push('initial-probe')
return false
})
.mockImplementationOnce(async () => {
events.push('locked-re-probe')
return true
})
vi.mocked(acquireInstallLock).mockImplementationOnce(async () => {
events.push('lock')
})
let socketProbe = 0
vi.mocked(execCommand).mockImplementation((_conn, command) => {
const stageResponse = stageCommandResponse(command)
if (stageResponse !== undefined) {
return Promise.resolve(stageResponse)
}
if (command.includes('uname')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/user')
}
if (command.includes('test -S')) {
return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY')
}
return Promise.resolve('')
})
conn.writeFile = vi.fn().mockResolvedValue(undefined)
conn.uploadDirectory = vi.fn().mockImplementation(async () => {
events.push('upload')
})
await deployAndLaunchRelay(conn)
expect(events).toEqual(['initial-probe', 'upload', 'lock', 'locked-re-probe'])
const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command)
expect(commands.some((command) => command.includes('cp -a'))).toBe(false)
const uploadStageRemovals = commands.filter(
(command) => /\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf')
)
expect(uploadStageRemovals).toHaveLength(1)
expect(uploadStageRemovals[0]).toContain('/.orca-remote/.upload-stages/claim-0')
})
it('runs bounded fixed-path recovery before a fresh upload', async () => {
const conn = makeMockConnection()
const events: string[] = []
vi.mocked(isRelayAlreadyInstalled)
.mockReset()
.mockResolvedValueOnce(false)
.mockResolvedValue(true)
let socketProbe = 0
vi.mocked(execCommand).mockImplementation((_conn, command) => {
if (command.includes('deleting_old=')) {
events.push('recover')
}
const stageResponse = stageCommandResponse(command)
if (stageResponse !== undefined) {
return Promise.resolve(stageResponse)
}
if (command.includes('uname')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/user')
}
if (command.includes('test -S')) {
return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY')
}
if (command.includes('ORCA-NATIVE')) {
return Promise.resolve('ORCA-NATIVE-DEPS-OK')
}
return Promise.resolve('')
})
conn.writeFile = vi.fn().mockResolvedValue(undefined)
conn.uploadDirectory = vi.fn().mockImplementation(async () => {
events.push('upload')
})
await deployAndLaunchRelay(conn)
expect(events.indexOf('recover')).toBeLessThan(events.indexOf('upload'))
})
it('launches before one bounded installed-path recovery exec', async () => {
const conn = makeMockConnection()
const events: string[] = []
let socketProbe = 0
vi.mocked(waitForSentinel).mockImplementation(async () => {
events.push('launch-ready')
return {
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
}
})
vi.mocked(execCommand).mockImplementation((_conn, command) => {
if (command.includes('deleting_old=')) {
events.push('recover')
}
const stageResponse = stageCommandResponse(command)
if (stageResponse !== undefined) {
return Promise.resolve(stageResponse)
}
if (command.includes('uname')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/user')
}
if (command.includes('ORCA-NATIVE')) {
return Promise.resolve('ORCA-NATIVE-DEPS-OK')
}
if (command.includes('test -S')) {
return Promise.resolve(socketProbe++ % 2 === 0 ? 'DEAD' : 'READY')
}
return Promise.resolve('')
})
for (let deployment = 0; deployment < 12; deployment += 1) {
await deployAndLaunchRelay(conn)
}
expect(events).toEqual(Array.from({ length: 12 }, () => ['launch-ready', 'recover']).flat())
const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command)
const recoveryCommands = commands.filter((command) => command.includes('deleting_old='))
expect(recoveryCommands).toHaveLength(12)
expect(recoveryCommands.every((command) => command.includes('.upload-stages'))).toBe(true)
expect(recoveryCommands.every((command) => !command.includes('find "$pool"'))).toBe(true)
})
it('never enumerates arbitrary stage paths during installation', async () => {
const conn = makeMockConnection()
conn.uploadDirectory = vi.fn().mockResolvedValue(undefined)
conn.writeFile = vi.fn().mockResolvedValue(undefined)
vi.mocked(isRelayAlreadyInstalled)
.mockReset()
.mockResolvedValueOnce(false)
.mockResolvedValue(true)
let socketProbe = 0
vi.mocked(execCommand).mockImplementation((_conn, command) => {
const stageResponse = stageCommandResponse(command)
if (stageResponse !== undefined) {
return Promise.resolve(stageResponse)
}
if (command.includes('uname')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/user')
}
if (command.includes('test -S')) {
return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY')
}
if (command.includes('ORCA-NATIVE')) {
return Promise.resolve('ORCA-NATIVE-DEPS-OK')
}
return Promise.resolve('')
})
await deployAndLaunchRelay(conn)
expect(conn.uploadDirectory).toHaveBeenCalledTimes(1)
const stageCommands = vi
.mocked(execCommand)
.mock.calls.map(([, command]) => command)
.filter((command) => command.includes('.upload-stages'))
expect(stageCommands.length).toBeGreaterThan(0)
expect(stageCommands.every((command) => !command.includes('-mindepth'))).toBe(true)
})
it('keeps the staging tree after an unconfirmed system SSH upload termination', async () => {
const conn = makeMockConnection()
vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false)
vi.mocked(execCommand).mockImplementation((_conn, command) => {
const stageResponse = stageCommandResponse(command)
if (stageResponse !== undefined) {
return Promise.resolve(stageResponse)
}
return Promise.resolve(
command.includes('uname') ? '__ORCA_REMOTE_PLATFORM__ Linux x86_64' : '/home/user'
)
})
conn.writeFile = vi.fn().mockResolvedValue(undefined)
const termination = Object.assign(new Error('upload teardown unconfirmed'), {
sshChannelCloseConfirmed: false
})
conn.uploadDirectory = vi.fn().mockRejectedValue(termination)
await expect(deployAndLaunchRelay(conn)).rejects.toBe(termination)
expect(acquireInstallLock).not.toHaveBeenCalled()
expect(
vi
.mocked(execCommand)
.mock.calls.some(
([, command]) =>
/\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf')
)
).toBe(false)
})
it('retries immediately after an unconfirmed upload termination instead of waiting on a fresh install lock', async () => {
const conn = makeMockConnection()
const termination = Object.assign(new Error('upload teardown unconfirmed'), {
sshChannelCloseConfirmed: false
})
let lockHeld = false
vi.mocked(acquireInstallLock).mockImplementation((_conn, _dir, _host, options) => {
if (!lockHeld) {
lockHeld = true
return Promise.resolve()
}
return new Promise<void>((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), {
once: true
})
})
})
vi.mocked(isRelayAlreadyInstalled)
.mockReset()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValue(true)
let socketProbe = 0
vi.mocked(execCommand).mockImplementation((_conn, command) => {
const stageResponse = stageCommandResponse(command)
if (stageResponse !== undefined) {
return Promise.resolve(stageResponse)
}
if (command.includes('uname')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/user')
}
if (command.includes('test -S')) {
return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY')
}
if (command.includes('ORCA-NATIVE')) {
return Promise.resolve('ORCA-NATIVE-DEPS-OK')
}
return Promise.resolve('')
})
conn.writeFile = vi.fn().mockResolvedValue(undefined)
conn.uploadDirectory = vi.fn().mockRejectedValueOnce(termination).mockResolvedValue(undefined)
await expect(deployAndLaunchRelay(conn)).rejects.toBe(termination)
await deployAndLaunchRelay(conn)
expect(conn.uploadDirectory).toHaveBeenCalledTimes(2)
expect(acquireInstallLock).toHaveBeenCalledTimes(1)
})
it('cleans a confirmed-abort staging tree so the next deployment retries immediately', async () => {
const conn = makeMockConnection()
const termination = Object.assign(new Error('upload aborted'), {
sshChannelCloseConfirmed: true
})
vi.mocked(isRelayAlreadyInstalled)
.mockReset()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValue(true)
let socketProbe = 0
vi.mocked(execCommand).mockImplementation((_conn, command) => {
const stageResponse = stageCommandResponse(command)
if (stageResponse !== undefined) {
return Promise.resolve(stageResponse)
}
if (command.includes('uname')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/user')
}
if (command.includes('test -S')) {
return Promise.resolve(socketProbe++ === 0 ? 'DEAD' : 'READY')
}
if (command.includes('ORCA-NATIVE')) {
return Promise.resolve('ORCA-NATIVE-DEPS-OK')
}
return Promise.resolve('')
})
conn.writeFile = vi.fn().mockResolvedValue(undefined)
conn.uploadDirectory = vi.fn().mockRejectedValueOnce(termination).mockResolvedValue(undefined)
await expect(deployAndLaunchRelay(conn)).rejects.toBe(termination)
expect(
vi
.mocked(execCommand)
.mock.calls.some(
([, command]) =>
/\.sftp-namespace-[0-9a-f]{32}/u.test(command) && command.includes('rm -rf')
)
).toBe(true)
await deployAndLaunchRelay(conn)
expect(conn.uploadDirectory).toHaveBeenCalledTimes(2)
})
})
+3
View File
@@ -11,3 +11,6 @@ const NATIVE_DEPS_REPAIR_BUDGET_MS = 2 * NATIVE_DEPS_COMMAND_TIMEOUT_MS
// finalize commands around install + rebuild. Keep the outer bound above that
// valid worst path while remaining below the 20-minute stale-lock threshold.
export const RELAY_DEPLOY_TIMEOUT_MS = NATIVE_DEPS_REPAIR_BUDGET_MS + 7 * 60_000
// Lets an aborted SSH transfer report whether its channel and local resources actually settled.
export const RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS = 5_000
+50 -52
View File
@@ -89,6 +89,7 @@ import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers'
import { resolveRemoteNodePath } from './ssh-remote-node-resolution'
import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install'
import { acquireInstallLock } from './ssh-relay-install-lock'
import * as DeployTiming from './ssh-relay-deploy-timing'
import type { SshConnection } from './ssh-connection'
import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution'
import {
@@ -140,6 +141,15 @@ function queueLaunchNamespaceAndDeadSocketProbe(): void {
describe('deployAndLaunchRelay', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(execCommand).mockReset().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
vi.mocked(waitForSentinel).mockReset().mockResolvedValue({
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
})
vi.mocked(resolveRemoteNodePath).mockReset().mockResolvedValue('/usr/bin/node')
vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true)
vi.mocked(acquireInstallLock).mockReset().mockResolvedValue(undefined)
})
it('calls exec to detect remote platform', async () => {
@@ -575,7 +585,10 @@ describe('deployAndLaunchRelay', () => {
await vi.advanceTimersByTimeAsync(301_000)
expect(await Promise.race([promise, Promise.resolve('pending')])).toBe('pending')
await vi.advanceTimersByTimeAsync(600_000)
await vi.advanceTimersByTimeAsync(DeployTiming.RELAY_DEPLOY_TIMEOUT_MS - 301_000)
expect(await Promise.race([promise, Promise.resolve('pending')])).toBe('pending')
await vi.advanceTimersByTimeAsync(DeployTiming.RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS)
const result = await promise
expect(result).toBeInstanceOf(Error)
@@ -588,9 +601,22 @@ describe('deployAndLaunchRelay', () => {
vi.useFakeTimers()
try {
const conn = makeMockConnection()
vi.mocked(execCommand)
.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
.mockResolvedValueOnce('/home/user')
vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(false)
conn.uploadDirectory = vi.fn().mockResolvedValue(undefined)
conn.writeFile = vi.fn().mockResolvedValue(undefined)
vi.mocked(execCommand).mockImplementation((_conn, command) => {
if (command.includes('uname')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/user')
}
const marker = command.match(/\.sftp-namespace-[0-9a-f]{32}/u)?.[0]
if (command.includes('__ORCA_UPLOAD_STAGE_SLOT__') && marker) {
return Promise.resolve(`__ORCA_UPLOAD_STAGE_SLOT__${marker}:slot-0`)
}
return Promise.resolve('')
})
vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false)
let lockSignal: AbortSignal | undefined
vi.mocked(acquireInstallLock).mockImplementationOnce((_conn, _dir, _host, options) => {
@@ -604,7 +630,7 @@ describe('deployAndLaunchRelay', () => {
await vi.advanceTimersByTimeAsync(0)
expect(acquireInstallLock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(900_000)
await vi.advanceTimersByTimeAsync(DeployTiming.RELAY_DEPLOY_TIMEOUT_MS)
const result = await promise
expect(result).toBeInstanceOf(Error)
@@ -615,40 +641,6 @@ describe('deployAndLaunchRelay', () => {
}
})
it('aborts an in-progress relay upload at the overall deploy timeout', async () => {
vi.useFakeTimers()
try {
const conn = makeMockConnection()
vi.mocked(execCommand)
.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
.mockResolvedValueOnce('/home/user')
.mockResolvedValueOnce('') // mkdir remote relay dir
vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false).mockResolvedValueOnce(false)
let uploadSignal: AbortSignal | undefined
conn.uploadDirectory = vi.fn((_localDir, _remoteDir, options) => {
uploadSignal = options?.signal
return new Promise<void>((_resolve, reject) => {
uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), {
once: true
})
})
})
const promise = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.advanceTimersByTimeAsync(0)
expect(conn.uploadDirectory).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(900_000)
const result = await promise
expect(result).toBeInstanceOf(Error)
expect((result as Error).message).toBe('Relay deployment timed out after 900s')
expect(uploadSignal?.aborted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('aborts a launch started near the deploy deadline and closes its channel once', async () => {
vi.useFakeTimers()
try {
@@ -660,6 +652,7 @@ describe('deployAndLaunchRelay', () => {
close: vi.fn()
}
const conn = makeMockConnection()
vi.mocked(isRelayAlreadyInstalled).mockReset().mockResolvedValue(true)
vi.mocked(conn.exec).mockResolvedValue(launchChannel as never)
const mockExecCommand = vi.mocked(execCommand)
mockExecCommand
@@ -710,19 +703,24 @@ describe('deployAndLaunchRelay', () => {
const connA = makeMockConnection()
const connB = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
mockExecCommand
.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe A
.mockResolvedValueOnce('/home/user') // $HOME A
.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe A
.mockResolvedValueOnce('') // launch namespace marker A
.mockResolvedValueOnce('DEAD') // probe A
.mockResolvedValueOnce('READY') // poll A
.mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') // tagged POSIX platform probe B
.mockResolvedValueOnce('/home/user') // $HOME B
.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe B
.mockResolvedValueOnce('') // launch namespace marker B
.mockResolvedValueOnce('DEAD') // probe B
.mockResolvedValueOnce('READY') // poll B
mockExecCommand.mockImplementation((_conn, command) => {
if (command.includes('__ORCA_REMOTE_PLATFORM__')) {
return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Linux x86_64')
}
if (command === 'echo $HOME') {
return Promise.resolve('/home/user')
}
if (command.includes('ORCA-NATIVE')) {
return Promise.resolve('ORCA-NATIVE-DEPS-OK')
}
if (command.includes('process.stdout.write("READY")')) {
return Promise.resolve('READY')
}
if (command.includes('test -S')) {
return Promise.resolve('DEAD')
}
return Promise.resolve('')
})
await deployAndLaunchRelay(connA, undefined, 300, 'target-a')
await deployAndLaunchRelay(connB, undefined, 300, 'target-b')

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