mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Merge origin/main into brennanb2025/fix-10385-false-connected
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 * * *'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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> }
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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
@@ -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?.()
|
||||
})
|
||||
|
||||
@@ -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: [] })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user