diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index e6b16fab7a7..ff475405a5e 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ac5fd76bc87..5455d0b7fb8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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. diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 7c302d8843f..89c1c86096f 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -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' diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index d256faecb88..7576fabc6a0 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -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(" • (?[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. diff --git a/.github/workflows/linux-wayland-gpu-sandbox.yml b/.github/workflows/linux-wayland-gpu-sandbox.yml index fc297326d14..dd893c6eae1 100644 --- a/.github/workflows/linux-wayland-gpu-sandbox.yml +++ b/.github/workflows/linux-wayland-gpu-sandbox.yml @@ -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 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 261c8d18adb..9ae0aa2a659 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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() diff --git a/.github/workflows/terminal-ime-e2e.yml b/.github/workflows/terminal-ime-e2e.yml index 7cef7d06cc5..31318bdff12 100644 --- a/.github/workflows/terminal-ime-e2e.yml +++ b/.github/workflows/terminal-ime-e2e.yml @@ -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 * * *' diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index 7dc5f0e219b..cd7e0245136 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -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: diff --git a/.github/workflows/windows-terminal-restart-e2e.yml b/.github/workflows/windows-terminal-restart-e2e.yml index 898f81bc8eb..6a47b10a1a4 100644 --- a/.github/workflows/windows-terminal-restart-e2e.yml +++ b/.github/workflows/windows-terminal-restart-e2e.yml @@ -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: diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0dd16bca193..64d939057d4 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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 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= ORCA_REVIEW_SSH_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 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= ORCA_REVIEW_SSH_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", diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index a4230668bc7..21e9df0fc6c 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -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') }) diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs index 6ed30a05916..8d8dafc22d4 100644 --- a/config/scripts/hourly-build-version.mjs +++ b/config/scripts/hourly-build-version.mjs @@ -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` + ) } diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs index e75951c481a..4454ff81512 100644 --- a/config/scripts/hourly-build-version.test.mjs +++ b/config/scripts/hourly-build-version.test.mjs @@ -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/ + ) + }) +}) diff --git a/config/scripts/locale-macos-tcc-key-overrides.mjs b/config/scripts/locale-macos-tcc-key-overrides.mjs index b7cb8998a0a..81b3d39db93 100644 --- a/config/scripts/locale-macos-tcc-key-overrides.mjs +++ b/config/scripts/locale-macos-tcc-key-overrides.mjs @@ -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: '전체 디스크 접근 권한', diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 516aac21a23..2c8dc4555c0 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -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') diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index bcedb1b5aaa..860e70c892b 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -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') }) }) diff --git a/config/scripts/run-ssh-staged-upload-reliability.mjs b/config/scripts/run-ssh-staged-upload-reliability.mjs new file mode 100644 index 00000000000..4fadf6f3001 --- /dev/null +++ b/config/scripts/run-ssh-staged-upload-reliability.mjs @@ -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) diff --git a/config/scripts/terminal-ime-e2e-workflow.test.mjs b/config/scripts/terminal-ime-e2e-workflow.test.mjs index 859ba7f168b..96062ebe706 100644 --- a/config/scripts/terminal-ime-e2e-workflow.test.mjs +++ b/config/scripts/terminal-ime-e2e-workflow.test.mjs @@ -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', () => { diff --git a/config/scripts/win-crash-survival-e2e.test.mjs b/config/scripts/win-crash-survival-e2e.test.mjs index c59f98ffb90..8ad77b9136b 100644 --- a/config/scripts/win-crash-survival-e2e.test.mjs +++ b/config/scripts/win-crash-survival-e2e.test.mjs @@ -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.*'") diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index c2dc2644f5e..f07b0caa01c 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -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 = () => ( [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)} /> diff --git a/mobile/src/components/pr-sidebar/pr-checks-presentation.test.ts b/mobile/src/components/pr-sidebar/pr-checks-presentation.test.ts index 4eadd989eda..044644b7dc4 100644 --- a/mobile/src/components/pr-sidebar/pr-checks-presentation.test.ts +++ b/mobile/src/components/pr-sidebar/pr-checks-presentation.test.ts @@ -21,8 +21,8 @@ function check(over: Partial): 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', () => { diff --git a/mobile/src/components/pr-sidebar/pr-checks-presentation.ts b/mobile/src/components/pr-sidebar/pr-checks-presentation.ts index 7404b8d1ee6..e929a13bf40 100644 --- a/mobile/src/components/pr-sidebar/pr-checks-presentation.ts +++ b/mobile/src/components/pr-sidebar/pr-checks-presentation.ts @@ -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([ - 'failure', - 'cancelled', - 'timed_out' -]) +const OUTCOME_BY_SHARED: Record = { + passed: 'success', + failed: 'failure', + pending: 'pending', + neutral: 'neutral' +} -const SUCCESS_CONCLUSIONS = new Set(['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 = { + 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': diff --git a/mobile/src/home-host-edit-navigation-source.test.ts b/mobile/src/home-host-edit-navigation-source.test.ts new file mode 100644 index 00000000000..2f6242f7ec1 --- /dev/null +++ b/mobile/src/home-host-edit-navigation-source.test.ts @@ -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\)/ + ) + }) +}) diff --git a/mobile/src/host-list-action-sheet-actions.test.ts b/mobile/src/host-list-action-sheet-actions.test.ts new file mode 100644 index 00000000000..2fb90334082 --- /dev/null +++ b/mobile/src/host-list-action-sheet-actions.test.ts @@ -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([]) + }) +}) diff --git a/mobile/src/host-list-action-sheet-actions.ts b/mobile/src/host-list-action-sheet-actions.ts new file mode 100644 index 00000000000..b045db7eea4 --- /dev/null +++ b/mobile/src/host-list-action-sheet-actions.ts @@ -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) + } + } + ] +} diff --git a/mobile/src/session/github-pr-value-readers.test.ts b/mobile/src/session/github-pr-value-readers.test.ts index 0b78d11dbb1..a8f1920945c 100644 --- a/mobile/src/session/github-pr-value-readers.test.ts +++ b/mobile/src/session/github-pr-value-readers.test.ts @@ -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() + }) +}) diff --git a/mobile/src/session/github-pr-value-readers.ts b/mobile/src/session/github-pr-value-readers.ts index e1884829272..9074d94142c 100644 --- a/mobile/src/session/github-pr-value-readers.ts +++ b/mobile/src/session/github-pr-value-readers.ts @@ -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' diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts new file mode 100644 index 00000000000..79b0c1264e4 --- /dev/null +++ b/mobile/src/session/mobile-session-route.test.ts @@ -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)') + }) +}) diff --git a/mobile/src/session/mobile-session-route.ts b/mobile/src/session/mobile-session-route.ts new file mode 100644 index 00000000000..505e53bfb85 --- /dev/null +++ b/mobile/src/session/mobile-session-route.ts @@ -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 + } +} diff --git a/mobile/src/source-control/MobileSourceControlCreatePrEntry.tsx b/mobile/src/source-control/MobileSourceControlCreatePrEntry.tsx index c58838e68aa..b60c632175d 100644 --- a/mobile/src/source-control/MobileSourceControlCreatePrEntry.tsx +++ b/mobile/src/source-control/MobileSourceControlCreatePrEntry.tsx @@ -13,6 +13,7 @@ export function MobileSourceControlCreatePrEntry({ action }: Props) { return null } const enabled = !action.disabled + const copy = action.hint ?? action.label return ( )} - - {action.label} + + {copy} - {action.hint ? ( - - {action.hint} - - ) : null} ) } diff --git a/mobile/src/source-control/mobile-create-pr-action.test.ts b/mobile/src/source-control/mobile-create-pr-action.test.ts index 99b86cc85e7..c665b034f7f 100644 --- a/mobile/src/source-control/mobile-create-pr-action.test.ts +++ b/mobile/src/source-control/mobile-create-pr-action.test.ts @@ -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 = {} @@ -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) + }) +}) diff --git a/mobile/src/source-control/mobile-create-pr-action.ts b/mobile/src/source-control/mobile-create-pr-action.ts index 0636fc01588..fe75cc60a58 100644 --- a/mobile/src/source-control/mobile-create-pr-action.ts +++ b/mobile/src/source-control/mobile-create-pr-action.ts @@ -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([ - '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, diff --git a/mobile/src/source-control/mobile-pr-chip-summary.test.ts b/mobile/src/source-control/mobile-pr-chip-summary.test.ts index 88186a06ea0..f9e21b31d73 100644 --- a/mobile/src/source-control/mobile-pr-chip-summary.test.ts +++ b/mobile/src/source-control/mobile-pr-chip-summary.test.ts @@ -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', () => { diff --git a/mobile/src/source-control/mobile-pr-chip-summary.ts b/mobile/src/source-control/mobile-pr-chip-summary.ts index 87f54d43472..c281d882b1c 100644 --- a/mobile/src/source-control/mobile-pr-chip-summary.ts +++ b/mobile/src/source-control/mobile-pr-chip-summary.ts @@ -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): 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): 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' + } } diff --git a/mobile/src/source-control/mobile-source-control-styles.ts b/mobile/src/source-control/mobile-source-control-styles.ts index d8dc20a1469..fe8d8da0a90 100644 --- a/mobile/src/source-control/mobile-source-control-styles.ts +++ b/mobile/src/source-control/mobile-source-control-styles.ts @@ -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 } }) diff --git a/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts b/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts index cc1f4965dc8..13109a610db 100644 --- a/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts +++ b/mobile/src/source-control/use-mobile-hosted-review-eligibility.test.ts @@ -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 { + return { + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null, + defaultBaseRef: 'main', + title: 'feature', + body: '', + ...overrides + } +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((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>[] = [] + const client = { + sendRequest: vi.fn(() => { + const request = deferred() + 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>[] = [] + const client = { + sendRequest: vi.fn(() => { + const request = deferred() + 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>[] = [] + const client = { + sendRequest: vi.fn(() => { + const request = deferred() + 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 }) + }) +}) diff --git a/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts b/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts index ff5a98c433b..e229d495311 100644 --- a/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts +++ b/mobile/src/source-control/use-mobile-hosted-review-eligibility.ts @@ -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 ): 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({ 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({ + 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 + }) } diff --git a/mobile/src/source-control/use-mobile-source-control-create-pr-action.ts b/mobile/src/source-control/use-mobile-source-control-create-pr-action.ts index 44655d14706..49ec9bda83a 100644 --- a/mobile/src/source-control/use-mobile-source-control-create-pr-action.ts +++ b/mobile/src/source-control/use-mobile-source-control-create-pr-action.ts @@ -6,6 +6,7 @@ import type { MobileGitStatusResult } from './mobile-git-status' type Params = { client: Parameters[0]['client'] connState: Parameters[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, diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts index e50525f3c62..1f541739d9a 100644 --- a/mobile/src/source-control/use-mobile-source-control-state.ts +++ b/mobile/src/source-control/use-mobile-source-control-state.ts @@ -209,6 +209,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara const createPrAction = useMobileSourceControlCreatePrAction({ client, connState, + hostId, worktreeId, status, hasUncommittedChanges: entries.length > 0, diff --git a/mobile/src/tasks/mobile-task-navigation.test.ts b/mobile/src/tasks/mobile-task-navigation.test.ts new file mode 100644 index 00000000000..5bc519578ba --- /dev/null +++ b/mobile/src/tasks/mobile-task-navigation.test.ts @@ -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() + }) +}) diff --git a/mobile/src/tasks/mobile-task-navigation.ts b/mobile/src/tasks/mobile-task-navigation.ts new file mode 100644 index 00000000000..3537d517106 --- /dev/null +++ b/mobile/src/tasks/mobile-task-navigation.ts @@ -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) + } +} diff --git a/mobile/src/tasks/use-open-mobile-tasks.ts b/mobile/src/tasks/use-open-mobile-tasks.ts new file mode 100644 index 00000000000..010e914d7fd --- /dev/null +++ b/mobile/src/tasks/use-open-mobile-tasks.ts @@ -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() + const router = useRouter() + const pendingRef = useRef(null) + + useEffect( + () => () => { + pendingRef.current?.controller.cancel() + pendingRef.current = null + }, + [] + ) + + return useCallback( + (hostId, provider) => { + pendingRef.current = coordinateMobileTasksNavigation( + pendingRef.current, + navigation, + router, + hostId, + provider + ) + }, + [navigation, router] + ) +} diff --git a/mobile/src/transport/host-list-load-sharing.test.ts b/mobile/src/transport/host-list-load-sharing.test.ts new file mode 100644 index 00000000000..9d2f10e8b8d --- /dev/null +++ b/mobile/src/transport/host-list-load-sharing.test.ts @@ -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((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 + }) +}) diff --git a/mobile/src/transport/host-list-load-sharing.ts b/mobile/src/transport/host-list-load-sharing.ts new file mode 100644 index 00000000000..ab1e9e2fe89 --- /dev/null +++ b/mobile/src/transport/host-list-load-sharing.ts @@ -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 | null = null +let revision = 0 + +export function getHostListLoadRevision(): number { + return revision +} + +export function shareHostListLoad(load: () => Promise): Promise { + 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 +} diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index cee492dfd09..997e8a3d42f 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -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((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((resolve) => { + releaseTokenWrite = resolve + }) + ) + let resolveParkedTokenRead: (token: string | null) => void = () => {} + const parkedTokenRead = new Promise((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((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. diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 9d16235481c..43657fffe76 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -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 { // 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() -let inflightLoad: Promise | 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 = Promise.resolve() @@ -102,13 +102,7 @@ export async function loadHosts(): Promise { // 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 { @@ -130,6 +124,7 @@ async function doLoadHosts(): Promise { 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 { 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 { 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 { export function resetHostStoreForTests(): void { hostListMutation = Promise.resolve() tokenCache.clear() - inflightLoad = null + hostListLoads.dropSharedHostListLoad() resetPairingKeychainForTests() } diff --git a/package.json b/package.json index 466aec66383..ac69d5d6cd5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/codex-accounts/fs-utils.test.ts b/src/main/codex-accounts/fs-utils.test.ts index 0932ef41215..45ab88f2baa 100644 --- a/src/main/codex-accounts/fs-utils.test.ts +++ b/src/main/codex-accounts/fs-utils.test.ts @@ -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() + 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', () => { diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 7773e8bd2ab..e0610cc79c7 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -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 diff --git a/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts new file mode 100644 index 00000000000..4e2dcdf3f74 --- /dev/null +++ b/src/main/codex-accounts/legacy-shared-config-compatibility.test.ts @@ -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() + 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() + ) + }) +}) diff --git a/src/main/codex-accounts/legacy-shared-config-compatibility.ts b/src/main/codex-accounts/legacy-shared-config-compatibility.ts new file mode 100644 index 00000000000..65e663b0faf --- /dev/null +++ b/src/main/codex-accounts/legacy-shared-config-compatibility.ts @@ -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) + } +} diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts new file mode 100644 index 00000000000..f32bfbd153d --- /dev/null +++ b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts @@ -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[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 }) +} diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.ts b/src/main/codex-accounts/managed-codex-auth-readiness.ts new file mode 100644 index 00000000000..4c9f28eec70 --- /dev/null +++ b/src/main/codex-accounts/managed-codex-auth-readiness.ts @@ -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 | 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 { + 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 { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index 965a51cc8d4..4012cf3b10c 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -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') }) diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index b6076db0e03..d1a8b1bed0d 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -8,6 +8,7 @@ import { mkdirSync, readlinkSync, readFileSync, + renameSync, rmSync, statSync, symlinkSync, @@ -201,6 +202,20 @@ function getRuntimeCodexAuthPath(): string { return join(getRuntimeCodexHomePath(), 'auth.json') } +function getSharedRuntimeAuthProvenancePath(): string { + return join(testState.userDataDir, 'codex-runtime-home', 'shared-runtime-auth-provenance.json') +} + +function writePaneRegistry( + panes: Record +): void { + writeFileSync( + join(testState.userDataDir, 'codex-pane-accounts.json'), + `${JSON.stringify({ version: 2, panes })}\n`, + 'utf-8' + ) +} + function getLegacyActiveHostCodexHomePath(): string { return join(testState.userDataDir, 'codex-runtime-home', 'active', 'host', 'home') } @@ -252,7 +267,8 @@ function createCodexAuthJson( email: string, accountId: string, refreshToken: string, - expiresAt?: number + expiresAt?: number, + lastRefresh?: string ): string { const idToken = [ encodeJwtPart({ alg: 'none', typ: 'JWT' }), @@ -268,7 +284,10 @@ function createCodexAuthJson( ].join('.') return `${JSON.stringify({ + auth_mode: 'chatgpt', + ...(lastRefresh === undefined ? {} : { last_refresh: lastRefresh }), tokens: { + access_token: `access-${accountId}`, id_token: idToken, account_id: accountId, ...(expiresAt === undefined ? {} : { expires_at: expiresAt }), @@ -291,6 +310,13 @@ describe('CodexRuntimeHomeService', () => { process.env.ORCA_USER_DATA_PATH = testState.userDataDir mkdirSync(getSystemCodexHomePath(), { recursive: true }) mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) + writePaneRegistry({ + 'retained-shared-pane': { + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + } + }) }) afterEach(() => { @@ -1039,12 +1065,14 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.isHostSystemDefaultRealHome()).toBe(true) + expect(service.getSelectedHostCodexHomeRoute()).toBe('real-home') expect(service.prepareForCodexLaunch()).toBeNull() expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([ getRuntimeCodexHomePath(), getSystemCodexHomePath() ]) service.setRealHomeLaneGate(() => false) + expect(service.getSelectedHostCodexHomeRoute()).toBe('shared-home') expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) const markerPath = join( testState.userDataDir, @@ -1105,6 +1133,642 @@ describe('CodexRuntimeHomeService', () => { } }) + it('seeds shared auth for a pane-local custom home on the real-home lane', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const customHome = join(testState.fakeHomeDir, 'pane-custom-codex-home') + + expect(service.prepareForCodexLaunch(undefined, { CODEX_HOME: customHome })).toBe( + getRuntimeCodexHomePath() + ) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + }) + + it('skips retired-home reconciliation when no retained host pane can use it', async () => { + const syncLegacySharedCodexConfigForRetainedPanes = vi.fn() + vi.doMock('./legacy-shared-config-compatibility', () => ({ + syncLegacySharedCodexConfigForRetainedPanes + })) + const retainedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'retained') + const currentAuth = createCodexAuthJson('system@example.com', 'acct-system', 'current') + const retainedConfig = 'model = "retained"\n' + writePaneRegistry({ + 'real-home-pane': { + selectionKey: 'host', + accountId: null, + homeRoute: 'real-home' + } + }) + writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') + writeFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), retainedConfig, 'utf-8') + writeFileSync(getSystemCodexAuthPath(), currentAuth, 'utf-8') + writeFileSync(join(getSystemCodexHomePath(), 'config.toml'), 'model = "current"\n', 'utf-8') + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ owner: 'system-default', authJson: retainedAuth })}\n`, + 'utf-8' + ) + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + service.reconcileLegacySharedHomeForRetainedPanes() + expect(service.prepareForCodexLaunch()).toBeNull() + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + + expect(syncLegacySharedCodexConfigForRetainedPanes).not.toHaveBeenCalled() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe( + retainedConfig + ) + } finally { + vi.doUnmock('./legacy-shared-config-compatibility') + } + }) + + it('keeps pre-rollout shared-home panes authenticated on the real-home lane', async () => { + const oldSystemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-system') + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + 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(getSystemCodexAuthPath(), oldSystemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(oldSystemAuth) + + setRealHomeLaneForTest(true) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + writeFileSync(join(getSystemCodexHomePath(), 'config.toml'), systemConfig, 'utf-8') + writeFileSync( + join(getRuntimeCodexHomePath(), 'config.toml'), + 'model_provider = "stale-provider"\n', + 'utf-8' + ) + + const service = new CodexRuntimeHomeService(store as never) + + service.setRealHomeLaneGate(() => true) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(oldSystemAuth) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toContain( + 'stale-provider' + ) + service.reconcileLegacySharedHomeForRetainedPanes() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(readFileSync(join(getRuntimeCodexHomePath(), 'config.toml'), 'utf-8')).toBe(systemConfig) + expect(service.prepareForCodexLaunch()).toBeNull() + }) + + it('preserves retained managed auth across a real-home main-process restart', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') + + setRealHomeLaneForTest(true) + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.setRealHomeLaneGate(() => true) + + expect(restartedService.prepareForCodexLaunch()).toBeNull() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) + }) + + it('preserves same-identity retained managed auth after restart and source logout', async () => { + const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') + const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'shared@example.com', + managedHomePath, + providerAccountId: 'acct-shared', + workspaceLabel: null, + workspaceAccountId: 'acct-shared', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + const restartedService = new CodexRuntimeHomeService(store as never) + + expect(restartedService.prepareForCodexLaunch()).toBeNull() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) + }) + + it('fences retained shared auth when a self-contained managed transition begins', async () => { + const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') + const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'shared@example.com', + managedHomePath, + providerAccountId: 'acct-shared', + workspaceLabel: null, + workspaceAccountId: 'acct-shared', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + service.syncForCurrentSelection() + rmSync(getSystemCodexAuthPath()) + + service.prepareForRateLimitFetch() + service.prepareForRateLimitFetch() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) + }) + + it('restores retained system ownership when a self-contained transition leaves it untouched', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed' + ) + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + service.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + service.prepareForRateLimitFetch() + service.prepareForRateLimitFetch() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('restores authoritative system auth when the shared lane follows a managed transition', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') + const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + setRealHomeLaneForTest(false) + service.syncForCurrentSelection() + + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + }) + + it('refreshes untouched retained-pane auth during real-home rate polling', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + + setRealHomeLaneForTest(true) + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('does not rewrite retained-auth provenance during unchanged rate polling', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const provenancePath = getSharedRuntimeAuthProvenancePath() + const originalInode = statSync(provenancePath).ino + + setRealHomeLaneForTest(true) + service.prepareForRateLimitFetch() + service.prepareForRateLimitFetch() + + expect(statSync(provenancePath).ino).toBe(originalInode) + }) + + it('does not rewrite completed retained logout metadata during rate polling', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + service.prepareForRateLimitFetch() + const metadataPaths = [ + getSharedRuntimeAuthProvenancePath(), + join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json'), + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json') + ] + const originalInodes = metadataPaths.map((path) => statSync(path).ino) + + service.prepareForRateLimitFetch() + service.prepareForRateLimitFetch() + + expect(metadataPaths.map((path) => statSync(path).ino)).toEqual(originalInodes) + }) + + it('clears managed transition state before later retained-auth reconciliation', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + service.syncForCurrentSelection() + + writeFileSync(getRuntimeCodexAuthPath(), systemAuth, 'utf-8') + writeFileSync( + join(testState.userDataDir, 'codex-runtime-home', 'shared-runtime-auth-provenance.json'), + `${JSON.stringify({ owner: 'system-default', authJson: systemAuth })}\n` + ) + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + setRealHomeLaneForTest(true) + service.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('does not overwrite auth changed by a retained Codex process', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const retainedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'retained-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('applies source logout after a retained pane refreshes the same identity', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const retainedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'retained-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + const service = new CodexRuntimeHomeService(store as never) + writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') + rmSync(getSystemCodexAuthPath()) + + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + }) + + it('repairs a completed pending system-auth replacement after restart', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ + owner: 'pending', + next: { owner: 'system-default', authJson: systemAuth }, + runtimeAuthJson: systemAuth + })}\n` + ) + + setRealHomeLaneForTest(true) + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) + }) + + it('recovers runtime auth quarantined by an interrupted guarded update', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + const runtimeAuthPath = getRuntimeCodexAuthPath() + const heldAuthPath = `${runtimeAuthPath}.orca-guarded` + renameSync(runtimeAuthPath, heldAuthPath) + + setRealHomeLaneForTest(true) + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) + expect(existsSync(heldAuthPath)).toBe(false) + }) + + it('fences a pending auth replacement when runtime bytes do not match its intent', async () => { + const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') + const retainedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'retained') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync( + getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify({ + owner: 'pending', + next: { owner: 'system-default', authJson: systemAuth }, + runtimeAuthJson: systemAuth + })}\n` + ) + writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') + + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) + }) + + it('does not treat malformed provenance as a missing migration marker', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const refreshedSystemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed-token' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + writeFileSync(getSharedRuntimeAuthProvenancePath(), '{"owner":"pending"}\n') + + setRealHomeLaneForTest(true) + writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) + }) + + it('recreates proven logged-out shared auth after a real-home re-login', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + const service = new CodexRuntimeHomeService(store as never) + rmSync(getSystemCodexAuthPath()) + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + + writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') + expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) + }) + + it.each([ + ['committed provenance', false], + ['pre-provenance migration', true] + ])( + 'recreates retained auth after a %s crash between logout deletion and metadata commit', + async (_label, removeProvenance) => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + rmSync(getRuntimeCodexAuthPath()) + if (removeProvenance) { + rmSync(getSharedRuntimeAuthProvenancePath()) + } + rmSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json'), + { + force: true + } + ) + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.setRealHomeLaneGate(() => true) + + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') + expect(restartedService.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) + } + ) + + it('recreates retained auth after interrupted logout crosses a managed transition', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') + ) + const settings = createSettings({ + codexSystemDefaultRealHomeEnabled: false, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + }) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + setRealHomeLaneForTest(true) + rmSync(getSystemCodexAuthPath()) + rmSync(getRuntimeCodexAuthPath()) + rmSync( + join(testState.userDataDir, 'codex-runtime-home', 'system-default-runtime-logout.json'), + { + force: true + } + ) + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.setRealHomeLaneGate(() => true) + restartedService.reconcileLegacySharedHomeForRetainedPanes() + + settings.activeCodexManagedAccountId = 'account-1' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } + restartedService.syncForCurrentSelection() + writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') + settings.activeCodexManagedAccountId = null + settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } + restartedService.syncForCurrentSelection() + + restartedService.prepareForRateLimitFetch() + restartedService.prepareForRateLimitFetch() + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) + }) + + it('preserves shared config changes when a pending real-home lane falls back', async () => { + const systemConfigPath = join(getSystemCodexHomePath(), 'config.toml') + const runtimeConfigPath = join(getRuntimeCodexHomePath(), 'config.toml') + writeFileSync(systemConfigPath, 'model = "baseline"\n', 'utf-8') + const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + writeFileSync(runtimeConfigPath, 'model = "runtime-change"\n', 'utf-8') + + setRealHomeLaneForTest(true) + service.setRealHomeLaneGate(() => false) + service.reconcileLegacySharedHomeForRetainedPanes() + expect(readFileSync(systemConfigPath, 'utf-8')).toBe('model = "baseline"\n') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') + + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(readFileSync(systemConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') + }) + it('routes a host MANAGED account to its own self-contained home when the flag is ON', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const runtimeAuthPath = getRuntimeCodexAuthPath() @@ -1194,6 +1858,12 @@ describe('CodexRuntimeHomeService', () => { settings.activeCodexManagedAccountId = 'account-2' settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } expect(service.prepareForCodexLaunch()).toBe(home2) + expect( + service.prepareForCodexLaunch(undefined, undefined, { + unavailableManagedHomePath: home1 + }) + ).toBe(home2) + expect(store.updateSettings).not.toHaveBeenCalled() // Nothing is hot-swapped, so the still-running account-1 pane keeps seeing // account-1's credentials — the single-auth.json race (GAP-5) is gone. @@ -1280,7 +1950,7 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForRateLimitFetch()).toBe(home1) }) - it('drops a managed selection whose auth.json vanished and resolves the real home (flag ON)', async () => { + it('preserves a managed selection whose auth.json is temporarily missing (flag ON)', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') // A managed home that has lost its auth.json (only the marker remains). const brokenHome = join(testState.userDataDir, 'codex-accounts', 'account-1', 'home') @@ -1308,9 +1978,9 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // The broken account is dropped and the launch resolves to the real home. - expect(service.prepareForCodexLaunch()).toBeNull() - expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + expect(service.prepareForCodexLaunch()).toBe(brokenHome) + expect(service.prepareForRateLimitFetch()).toBe(brokenHome) + expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') }) it('keeps the shared runtime home + auth hot-swap for managed accounts when the flag is OFF', async () => { @@ -1641,13 +2311,18 @@ describe('CodexRuntimeHomeService', () => { expect(syncSpy).not.toHaveBeenCalled() }) - it('fails closed when per-account auth disappears before a real-home fallback', async () => { + it('preserves selected identity when per-account auth disappears before launch', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1) const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed', 2) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const wslManagedHomePath = createManagedAuth( + testState.userDataDir, + 'wsl-account', + createCodexAuthJson('wsl@example.com', 'acct-wsl', 'wsl') + ) const settings = createSettings({ codexSystemDefaultRealHomeEnabled: true, codexManagedAccounts: [ @@ -1661,10 +2336,24 @@ describe('CodexRuntimeHomeService', () => { createdAt: 1, updatedAt: 1, lastAuthenticatedAt: 1 + }, + { + id: 'wsl-account', + email: 'wsl@example.com', + managedHomePath: wslManagedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/wsl-account/home', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 } ], activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + activeCodexManagedAccountIdsByRuntime: { + host: 'account-1', + wsl: { Ubuntu: 'wsl-account' } + } }) const store = createStore(settings) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -1674,27 +2363,52 @@ describe('CodexRuntimeHomeService', () => { // A stale pre-E process leaves a matching refresh in the shared runtime home. writeFileSync(runtimeAuthPath, account1Refreshed, 'utf-8') - // The active account's canonical auth disappears before launch. This is the - // same-account auto-deselect path, where no ordinary outgoing switch runs. + // The active account's canonical auth disappears before launch. rmSync(join(managedHomePath1, 'auth.json'), { force: true }) - expect(service.prepareForCodexLaunch()).toBeNull() + expect(service.prepareForCodexLaunch()).toBe(managedHomePath1) - // Missing canonical auth clears selection without reviving shared bytes. + // Missing canonical auth preserves selection without reviving shared bytes. expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false) expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1Refreshed) + expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') + expect(store.updateSettings).not.toHaveBeenCalled() + expect(warnSpy).not.toHaveBeenCalled() + + expect(service.isHostSystemDefaultRealHome()).toBe(false) + expect(service.prepareForRateLimitFetch()).toBe(managedHomePath1) + expect(service.prepareForCodexLaunch()).toBe(managedHomePath1) + expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false) + + writeFileSync(join(managedHomePath1, 'auth.json'), account1Auth, 'utf-8') + expect( + service.prepareForCodexLaunch(undefined, undefined, { + unavailableManagedHomePath: managedHomePath1 + }) + ).toBe(managedHomePath1) + expect(store.updateSettings).not.toHaveBeenCalled() + + rmSync(join(managedHomePath1, 'auth.json'), { force: true }) + expect( + service.prepareForCodexLaunch(undefined, undefined, { + unavailableManagedHomePath: managedHomePath1 + }) + ).toBeNull() + expect(store.getSettings().activeCodexManagedAccountId).toBeNull() + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime).toEqual({ + host: null, + wsl: { Ubuntu: 'wsl-account' } + }) + expect(store.getSettings().codexManagedAccounts).toHaveLength(2) expect(store.updateSettings).toHaveBeenCalledWith( expect.objectContaining({ activeCodexManagedAccountId: null }) ) - expect(warnSpy).toHaveBeenCalled() - - // The follow-up launch takes the real-home lane and remains fail-closed. - expect(service.isHostSystemDefaultRealHome()).toBe(true) - expect(service.prepareForCodexLaunch()).toBeNull() - expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false) + expect(warnSpy).toHaveBeenCalledWith( + '[codex-runtime-home] Active managed account credential remained unavailable, clearing selection' + ) }) - it('refuses mismatched runtime auth when the active managed auth is missing', async () => { + it('ignores mismatched runtime auth when the active managed auth is missing', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed', 1) @@ -1726,12 +2440,13 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(runtimeAuthPath, mismatchedAuth, 'utf-8') rmSync(join(managedHomePath, 'auth.json'), { force: true }) - expect(service.prepareForCodexLaunch()).toBeNull() + expect(service.prepareForCodexLaunch()).toBe(managedHomePath) expect(existsSync(join(managedHomePath, 'auth.json'))).toBe(false) expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(mismatchedAuth) - expect(warnSpy).toHaveBeenCalled() + expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') + expect(warnSpy).not.toHaveBeenCalled() }) it('ignores shared auth on an explicit managed-to-real-home deselect', async () => { @@ -3126,13 +3841,21 @@ describe('CodexRuntimeHomeService', () => { ).toEqual({ authJson: refreshedAuth }) }) - it('reads back system-default token refreshes after restart when the snapshot proves the baseline', async () => { + it('reads back system-default token refreshes after a pre-provenance restart', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-old') + const systemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system-old', + undefined, + '2026-07-30T12:00:00.000Z' + ) const refreshedAuth = createCodexAuthJson( 'system@example.com', 'acct-system', - 'system-refreshed' + 'system-refreshed', + undefined, + '2026-07-31T12:00:00.000Z' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const store = createStore(createSettings()) @@ -3141,6 +3864,7 @@ describe('CodexRuntimeHomeService', () => { new CodexRuntimeHomeService(store as never) writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + rmSync(getSharedRuntimeAuthProvenancePath()) const restartedService = new CodexRuntimeHomeService(store as never) restartedService.syncForCurrentSelection() @@ -3148,6 +3872,37 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) }) + it('does not read back older same-identity auth without provenance', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system-newer', + undefined, + '2026-07-31T12:00:00.000Z' + ) + const staleRuntimeAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'runtime-older', + undefined, + '2026-07-30T12:00:00.000Z' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') + rmSync(getSharedRuntimeAuthProvenancePath()) + const restartedService = new CodexRuntimeHomeService(store as never) + restartedService.syncForCurrentSelection() + + expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) + }) + it('keeps a local runtime logout when the system-default auth still exists', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') @@ -3278,6 +4033,36 @@ describe('CodexRuntimeHomeService', () => { ).toBe(true) }) + it('clears refreshed runtime auth after a pre-provenance external logout', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const systemAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'system', + undefined, + '2026-07-30T12:00:00.000Z' + ) + const refreshedAuth = createCodexAuthJson( + 'system@example.com', + 'acct-system', + 'refreshed', + undefined, + '2026-07-31T12:00:00.000Z' + ) + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const store = createStore(createSettings()) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') + rmSync(getSharedRuntimeAuthProvenancePath()) + rmSync(getSystemCodexAuthPath()) + new CodexRuntimeHomeService(store as never) + + expect(existsSync(runtimeAuthPath)).toBe(false) + }) + it('persists runtime auth refreshes after returning to system default', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') @@ -4376,6 +5161,48 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) + it('recreates retained auth after switching from managed to logged-out system default', async () => { + const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') + const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') + writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'account-1', + createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') + ) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'managed@example.com', + managedHomePath, + providerAccountId: 'acct-managed', + workspaceLabel: null, + workspaceAccountId: 'acct-managed', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: 'account-1' + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + rmSync(getSystemCodexAuthPath()) + settings.activeCodexManagedAccountId = null + service.syncForCurrentSelection() + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + + setRealHomeLaneForTest(true) + service.setRealHomeLaneGate(() => true) + writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') + service.reconcileLegacySharedHomeForRetainedPanes() + + expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth) + }) + it('captures a fresh system-default snapshot when re-entering managed mode', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system-1"}\n', 'utf-8') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index f26839f8d28..e9256145b77 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -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).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 + ): Extract | 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 + 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 + 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 + 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 { diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index b7e78518606..7fcd1754992 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -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( diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 4b439e20372..10ede4d9cca 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -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 } diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index f1d83989453..640dcd17506 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -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:' — 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 } @@ -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 + 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 + 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 + 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 diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index f7cb49eb95b..51a9a47508d 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -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', () => { diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index 33d091cef7c..1bb3cca9c28 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -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. */ diff --git a/src/main/codex/codex-real-home-path.test.ts b/src/main/codex/codex-real-home-path.test.ts index 3d91682d7cc..7dc961c7a18 100644 --- a/src/main/codex/codex-real-home-path.test.ts +++ b/src/main/codex/codex-real-home-path.test.ts @@ -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) + } + ) }) diff --git a/src/main/codex/codex-real-home-path.ts b/src/main/codex/codex-real-home-path.ts index 6f6db65710a..700359d4f37 100644 --- a/src/main/codex/codex-real-home-path.ts +++ b/src/main/codex/codex-real-home-path.ts @@ -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 diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index ed078f756dc..c5ee4802e5b 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -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' + } + ]) }) }) diff --git a/src/main/codex/codex-stale-pane-accounts.ts b/src/main/codex/codex-stale-pane-accounts.ts index ba1aa6eef94..b74529abc29 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -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 diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index d7fae082341..6c793a316b8 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -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 { + 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 diff --git a/src/main/index.ts b/src/main/index.ts index 212f9e63659..a80bea0d965 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { 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 { 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') { diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 22cee5340d2..0c111a5f6d8 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -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 }) => { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 06d736088ed..0a30c25ae3b 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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() }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 1567eca121d..39dd2b63d86 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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): 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 { + 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 +): Promise { + 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 | 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?.() }) diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index f91c6699850..88199db7e41 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -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 + + 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 + + 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: [] }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index c5be32dd32c..275ba54c82a 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -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 } diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index 05de50fb2d4..58b7b44637a 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -273,6 +273,9 @@ describe('SSH IPC handlers', () => { const handlers = new Map 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(), diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d8e01166973..6cddc809c29 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index e3c0fa329af..c5e4c2a81a9 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 + 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[] { diff --git a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts index b172551e1dd..5f5944191c0 100644 --- a/src/main/rate-limits/codex-fetcher-auth-errors.test.ts +++ b/src/main/rate-limits/codex-fetcher-auth-errors.test.ts @@ -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() + }) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index d28fdee015e..685aa1d506f 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -1035,6 +1035,29 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise } 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 + } = { + 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((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' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index abc82437657..701bfb496e2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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() private mobileSessionTabsByWorktree = new Map() // 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() + private readonly pairedRendererSessionOwnedPtyIds = new Set() private wslDistroByPtyId = new Map() private titleObservationSequence = 0 private headlessTerminals = new Map() @@ -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 { + 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(['local']) + for (const repo of repos) { + hostIds.add(getRepoExecutionHostId(repo)) + } + for (const hostId of this.store?.getWorkspaceSessionHostIds?.() ?? []) { + hostIds.add(hostId) + } + + const targets = new Map() + 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 ; 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 { // 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 { + 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 { - 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 { 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( + 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() @@ -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 { 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( diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 4c89c2c10a3..04985a9ee0e 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -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') }) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 369032012e4..ddaeb172a72 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -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 = ?` ) diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index e11e2b78aa5..eabffa24a73 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -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( diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index b9496b3da54..428fde012a3 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -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() }) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 4339679355c..fe44dc64b22 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -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[] = [] + const send = async (id: string, deviceToken?: string): Promise => { + 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), + () => {} + ) + } + + 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 }) diff --git a/src/main/ssh/sftp-namespace-resolution.test.ts b/src/main/ssh/sftp-namespace-resolution.test.ts index f404d27979d..98ba2637c7d 100644 --- a/src/main/ssh/sftp-namespace-resolution.test.ts +++ b/src/main/ssh/sftp-namespace-resolution.test.ts @@ -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 @@ -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}` diff --git a/src/main/ssh/sftp-namespace-resolution.ts b/src/main/ssh/sftp-namespace-resolution.ts index 1020d915127..5ee329a40c4 100644 --- a/src/main/ssh/sftp-namespace-resolution.ts +++ b/src/main/ssh/sftp-namespace-resolution.ts @@ -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 { 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) diff --git a/src/main/ssh/sftp-upload.test.ts b/src/main/ssh/sftp-upload.test.ts index c87c2a1b7f5..db86fa17f51 100644 --- a/src/main/ssh/sftp-upload.test.ts +++ b/src/main/ssh/sftp-upload.test.ts @@ -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) => { diff --git a/src/main/ssh/sftp-upload.ts b/src/main/ssh/sftp-upload.ts index 22b1331cf13..e962c7463fa 100644 --- a/src/main/ssh/sftp-upload.ts +++ b/src/main/ssh/sftp-upload.ts @@ -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 { - return new Promise((resolve, reject) => { - let settled = false - let readStream: ReadStream | null = null - let fileHandle: Awaited> | null = null - let writeStream: ReturnType | 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 { + const handle = await open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + let handleClose: Promise | undefined + let readStream: ReadStream | undefined + let writeStream: ReturnType | undefined + const closeHandle = (): Promise => { + 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 { + 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) } } } diff --git a/src/main/ssh/ssh-connection-sftp-namespace.test.ts b/src/main/ssh/ssh-connection-sftp-namespace.test.ts index dd5730aa210..e79f16d65e0 100644 --- a/src/main/ssh/ssh-connection-sftp-namespace.test.ts +++ b/src/main/ssh/ssh-connection-sftp-namespace.test.ts @@ -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}` diff --git a/src/main/ssh/ssh-connection-sftp-wire.test.ts b/src/main/ssh/ssh-connection-sftp-wire.test.ts index 8e107f9a2aa..3b609c735aa 100644 --- a/src/main/ssh/ssh-connection-sftp-wire.test.ts +++ b/src/main/ssh/ssh-connection-sftp-wire.test.ts @@ -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}` diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index c1b73bbbb2d..17f0a382251 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -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() diff --git a/src/main/ssh/ssh-file-transfer-abort.test.ts b/src/main/ssh/ssh-file-transfer-abort.test.ts index 49abe0a1839..1cc32636fdb 100644 --- a/src/main/ssh/ssh-file-transfer-abort.test.ts +++ b/src/main/ssh/ssh-file-transfer-abort.test.ts @@ -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(() => {}), + new Promise((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(() => {}), + 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(() => {}), + 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() + } + }) }) diff --git a/src/main/ssh/ssh-file-transfer-abort.ts b/src/main/ssh/ssh-file-transfer-abort.ts index a7b6cce2e49..9e2e817d9ed 100644 --- a/src/main/ssh/ssh-file-transfer-abort.ts +++ b/src/main/ssh/ssh-file-transfer-abort.ts @@ -27,12 +27,20 @@ export function createLinkedSshFileTransferSignal(signals: readonly AbortSignal[ export function raceSftpFileTransferWithAbort( operation: Promise, signal: AbortSignal, - closeSftp: (onClose: () => void) => void + closeSftp: (onClose: () => void) => (() => void) | void ): Promise { 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 | 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( 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) } } ) diff --git a/src/main/ssh/ssh-pty-consumer-recovery.ts b/src/main/ssh/ssh-pty-consumer-recovery.ts new file mode 100644 index 00000000000..7b3fe215b76 --- /dev/null +++ b/src/main/ssh/ssh-pty-consumer-recovery.ts @@ -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 + modelMigrationsByAppPtyId: Map> +} + +const recoveryByTarget = new Map() + +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) +} diff --git a/src/main/ssh/ssh-pty-consumer-session.test.ts b/src/main/ssh/ssh-pty-consumer-session.test.ts index 31e82cf3d5e..76dc25ffacb 100644 --- a/src/main/ssh/ssh-pty-consumer-session.test.ts +++ b/src/main/ssh/ssh-pty-consumer-session.test.ts @@ -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()) diff --git a/src/main/ssh/ssh-pty-consumer-session.ts b/src/main/ssh/ssh-pty-consumer-session.ts index 3f258f73099..4bfebc6360f 100644 --- a/src/main/ssh/ssh-pty-consumer-session.ts +++ b/src/main/ssh/ssh-pty-consumer-session.ts @@ -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') } diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index ce5fcecb835..4ed6505d90c 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -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()), + 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) diff --git a/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts new file mode 100644 index 00000000000..7fad908a59a --- /dev/null +++ b/src/main/ssh/ssh-relay-deploy-staged-upload.test.ts @@ -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((_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(() => {}) + }) + + 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((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((_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) + }) +}) diff --git a/src/main/ssh/ssh-relay-deploy-timing.ts b/src/main/ssh/ssh-relay-deploy-timing.ts index 5dc24f937f8..588a277f0ac 100644 --- a/src/main/ssh/ssh-relay-deploy-timing.ts +++ b/src/main/ssh/ssh-relay-deploy-timing.ts @@ -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 diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 76826a4c670..3134461fda1 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -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((_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') diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index d57e4ad55a4..7590e9817f2 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import { existsSync } from 'node:fs' import { app } from 'electron' import type { SshConnection } from './ssh-connection' -import type { RelayPlatform } from './relay-protocol' +import { RELAY_REMOTE_DIR, type RelayPlatform } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { waitForSentinel, @@ -15,11 +15,15 @@ import { writeRelayEndpointCredential } from './ssh-relay-endpoint-credential' import { createRelayInstallMarkerCommand, createRelayInstallNamespace, + createRelayUploadStageNamespace, makeRelayInstallDirectoryCommand, relayHomeRelativeDir, relaySftpNamespaceMapping, - type RelayInstallNamespace + relayUploadStageSftpNamespaceMapping, + type RelayInstallNamespace, + type RelayUploadStageNamespace } from './ssh-relay-install-namespace' +import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { readLocalFullVersion, @@ -36,7 +40,11 @@ import { tryAcquireRelayGcClaim, waitForRelayGcClaimRelease } from './ssh-relay-gc-claim' -import { NATIVE_DEPS_COMMAND_TIMEOUT_MS, RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { + NATIVE_DEPS_COMMAND_TIMEOUT_MS, + RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS, + RELAY_DEPLOY_TIMEOUT_MS +} from './ssh-relay-deploy-timing' import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' import { probeBuildToolchain, @@ -50,6 +58,15 @@ import { readRemoteHomeCommand, removeRemoteFileCommand } from './ssh-remote-commands' +import { + cleanupOwnedRelayUploadStageCommand, + parseReservedRelayUploadStage, + promoteOwnedRelayUploadStageCommand, + recoverOneStaleRelayUploadStageCommand, + relayUploadStagePromotionConfirmed, + RELAY_UPLOAD_STAGE_POOL_NAME, + reserveRelayUploadStageCommand +} from './ssh-relay-upload-stage-commands' import { isWindowsRemoteHost, joinRemotePath, @@ -118,31 +135,77 @@ export async function deployAndLaunchRelay( relayInstanceId?: string ): Promise { let timeoutHandle: ReturnType - // Why: Promise.race doesn't cancel its loser; abort so a contended install-lock waiter can't mutate the relay after this call times out. const deployAbortController = new AbortController() - const timeoutPromise = new Promise((_resolve, reject) => { + const timedOut = Symbol('relay-deploy-timeout') + const deployment = deployAndLaunchRelayInner( + conn, + onProgress, + graceTimeSeconds, + relayInstanceId, + deployAbortController.signal + ).then( + (result) => ({ status: 'fulfilled' as const, result }), + (error: unknown) => ({ status: 'rejected' as const, error }) + ) + const timeoutPromise = new Promise((resolve) => { timeoutHandle = setTimeout(() => { deployAbortController.abort() - reject(new Error(`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`)) + resolve(timedOut) }, RELAY_DEPLOY_TIMEOUT_MS) }) try { - return await Promise.race([ - deployAndLaunchRelayInner( - conn, - onProgress, - graceTimeSeconds, - relayInstanceId, - deployAbortController.signal - ), - timeoutPromise - ]) + const outcome = await Promise.race([deployment, timeoutPromise]) + if (outcome !== timedOut) { + if (outcome.status === 'fulfilled') { + return outcome.result + } + throw outcome.error + } + + const teardownExpired = Symbol('relay-deploy-teardown-timeout') + let teardownTimeoutHandle: ReturnType + const teardown = await Promise.race([ + deployment, + new Promise((resolve) => { + teardownTimeoutHandle = setTimeout( + () => resolve(teardownExpired), + RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS + ) + }) + ]).finally(() => clearTimeout(teardownTimeoutHandle!)) + const timeoutError = Object.assign( + new Error(`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`), + teardownConfirmation(teardown === teardownExpired ? undefined : teardown) + ) + throw timeoutError } finally { clearTimeout(timeoutHandle!) } } +function teardownConfirmation( + outcome: + | { status: 'fulfilled'; result: RelayDeployResult } + | { status: 'rejected'; error: unknown } + | undefined +): { sshChannelCloseConfirmed: boolean; sshTransferTeardownConfirmed: boolean } { + if (!outcome) { + return { sshChannelCloseConfirmed: false, sshTransferTeardownConfirmed: false } + } + if (outcome.status === 'fulfilled') { + return { sshChannelCloseConfirmed: true, sshTransferTeardownConfirmed: true } + } + const error = outcome.error as { + sshChannelCloseConfirmed?: unknown + sshTransferTeardownConfirmed?: unknown + } + return { + sshChannelCloseConfirmed: error?.sshChannelCloseConfirmed === true, + sshTransferTeardownConfirmed: error?.sshTransferTeardownConfirmed === true + } +} + /** * Resolve the remote home, derive the versioned relay dir, and check whether the relay is installed there. * @@ -324,6 +387,13 @@ async function deployAndLaunchRelayAttempt( // Why: derive the home-relative suffix once — recomputing it by stripping the shell home breaks on a split namespace. const homeRelativeRelayDir = relayHomeRelativeDir(fullVersion) + const uploadStagePoolDir = joinRemotePath( + hostPlatform, + remoteHome, + RELAY_REMOTE_DIR, + RELAY_UPLOAD_STAGE_POOL_NAME + ) + const homeRelativeUploadStagePoolDir = `${RELAY_REMOTE_DIR}/${RELAY_UPLOAD_STAGE_POOL_NAME}` let ownsInstallLock = false let launchGcClaimToken: string | undefined @@ -343,63 +413,137 @@ async function deployAndLaunchRelayAttempt( launchNamespace = launchFence.sftpNamespace deploySignal?.throwIfAborted() } else { - // Why: serialize concurrent first-installs via a host-native exclusive lock; the loser polls to re-check installed or steal a stale lock. - await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) - ownsInstallLock = true + await execHostCommand( + conn, + hostPlatform, + recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir), + { signal: deploySignal } + ) + const uploadStageOwner = createRelayInstallMarkerFileName() + const reservation = await execHostCommand( + conn, + hostPlatform, + reserveRelayUploadStageCommand(hostPlatform, uploadStagePoolDir, uploadStageOwner), + { signal: deploySignal } + ) + const uploadStage = parseReservedRelayUploadStage( + hostPlatform, + uploadStagePoolDir, + uploadStageOwner, + reservation + ) + const uploadStagePayloadDir = joinRemotePath(hostPlatform, uploadStage.slotDir, 'payload') + const uploadStageNamespace = createRelayUploadStageNamespace( + `${homeRelativeUploadStagePoolDir}/${uploadStage.slotName}`, + uploadStageOwner + ) + const uploadStageSftpNamespace = uploadStageNamespaceIfSupported( + conn, + hostPlatform, + uploadStageNamespace + ) + let uploadStageCleanupAllowed = true + onProgress?.('Uploading relay...') + console.log('[ssh-relay] Uploading relay...') try { - // Re-probe after acquiring the lock — a sibling installer may have finished while we waited. - if ( - !(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform, { - signal: deploySignal - })) - ) { - launchNamespace = createInstallNamespaceIfSupported( - conn, - hostPlatform, - homeRelativeRelayDir - ) - - onProgress?.('Uploading relay...') - console.log('[ssh-relay] Uploading relay...') + try { await uploadRelay( conn, platform, - remoteRelayDir, + uploadStagePayloadDir, fullVersion, hostPlatform, deploySignal, - launchNamespace + { rootDir: uploadStage.slotDir, namespace: uploadStageSftpNamespace } ) - console.log('[ssh-relay] Upload complete') + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + uploadStageCleanupAllowed = false + } + throw err + } - onProgress?.('Installing native dependencies...') - console.log('[ssh-relay] Installing native dependencies...') - await installNativeDeps( + try { + await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) + ownsInstallLock = true + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + ownsInstallLock = true + } + throw err + } + try { + // Re-probe after acquiring the lock — a sibling installer may have finished while we waited. + if ( + !(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform, { + signal: deploySignal + })) + ) { + launchNamespace = await createRelayLaunchNamespace( + conn, + hostPlatform, + remoteRelayDir, + homeRelativeRelayDir, + deploySignal + ) + try { + const promotion = await execHostCommand( + conn, + hostPlatform, + promoteOwnedRelayUploadStageCommand( + hostPlatform, + uploadStage, + uploadStageOwner, + remoteRelayDir + ), + { signal: deploySignal } + ) + if (!relayUploadStagePromotionConfirmed(uploadStageOwner, promotion)) { + throw new Error('Relay upload stage ownership was lost before promotion') + } + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + uploadStageCleanupAllowed = false + } + throw err + } + console.log('[ssh-relay] Upload complete') + + onProgress?.('Installing native dependencies...') + console.log('[ssh-relay] Installing native dependencies...') + await installNativeDeps( + conn, + remoteRelayDir, + platform, + hostPlatform, + nodePath, + deploySignal, + [], + launchNamespace + ) + console.log('[ssh-relay] Native deps installed') + + // Why: mark complete but retain the lock until launch makes daemon liveness observable to cross-version GC. + await finalizeInstall(conn, remoteRelayDir, hostPlatform, { + signal: deploySignal, + releaseLock: false + }) + } + } catch (err) { + if (!isUnconfirmedSshCommandTermination(err)) { + await abandonInstall(conn, remoteRelayDir, hostPlatform) + ownsInstallLock = false + } + throw err + } + } finally { + if (uploadStageCleanupAllowed) { + await execHostCommand( conn, - remoteRelayDir, - platform, hostPlatform, - nodePath, - deploySignal, - [], - launchNamespace - ) - console.log('[ssh-relay] Native deps installed') - - // Why: mark complete but retain the lock until launch makes daemon liveness observable to cross-version GC. - await finalizeInstall(conn, remoteRelayDir, hostPlatform, { - signal: deploySignal, - releaseLock: false - }) + cleanupOwnedRelayUploadStageCommand(hostPlatform, uploadStage, uploadStageOwner) + ).catch(() => {}) } - } catch (err) { - // Why: leave a partial install dir (no .install-complete) so the next deploy re-runs upload + install. - // Why: keep the lock if remote termination was unconfirmed — stale recovery beats overlapping a still-running npm. - if (!isUnconfirmedSshCommandTermination(err)) { - await abandonInstall(conn, remoteRelayDir, hostPlatform) - ownsInstallLock = false - } - throw err } } @@ -431,11 +575,19 @@ async function deployAndLaunchRelayAttempt( } console.log('[ssh-relay] Relay started successfully') - // Why: best-effort GC of unreferenced sibling version dirs; errors are swallowed so a GC failure never blocks connecting. - void gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, { - windowsNodePath: launched.nodePath, - windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)] - }).catch(() => {}) + void execHostCommand( + conn, + hostPlatform, + recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir) + ) + .catch(() => {}) + .then(() => + gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, { + windowsNodePath: launched.nodePath, + windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)] + }) + ) + .catch(() => {}) return { transport: launched.transport, @@ -457,7 +609,7 @@ async function uploadRelay( fullVersion: string, hostPlatform: RemoteHostPlatform, signal?: AbortSignal, - namespace?: RelayInstallNamespace + stage?: { rootDir: string; namespace?: RelayUploadStageNamespace } ): Promise { const localRelayDir = getLocalRelayPath(platform) if (!localRelayDir || !existsSync(localRelayDir)) { @@ -467,18 +619,19 @@ async function uploadRelay( ) } - // Why: the install-owner marker rides along with mkdir, so a standard install spends no extra exec channel on it. - await execHostCommand( - conn, - hostPlatform, - makeRelayInstallDirectoryCommand(hostPlatform, remoteDir, namespace), - { signal } - ) + if (!stage) { + await execHostCommand( + conn, + hostPlatform, + makeRelayInstallDirectoryCommand(hostPlatform, remoteDir), + { signal } + ) + } await uploadRelayDirectory(conn, localRelayDir, remoteDir, hostPlatform, { signal, - sftpNamespace: namespace - ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir) + sftpNamespace: stage?.namespace + ? relayUploadStageSftpNamespaceMapping(stage.namespace, hostPlatform, stage.rootDir) : undefined }) @@ -499,8 +652,13 @@ async function uploadRelay( fullVersion, { signal, - sftpNamespace: namespace - ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, '.version') + sftpNamespace: stage?.namespace + ? relayUploadStageSftpNamespaceMapping( + stage.namespace, + hostPlatform, + stage.rootDir, + '.version' + ) : undefined } ) @@ -524,6 +682,19 @@ function createInstallNamespaceIfSupported( return usesSystemSsh ? undefined : createRelayInstallNamespace(homeRelativeRelayDir) } +function uploadStageNamespaceIfSupported( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + namespace: RelayUploadStageNamespace +): RelayUploadStageNamespace | undefined { + if (isWindowsRemoteHost(hostPlatform)) { + return undefined + } + const usesSystemSsh = + typeof conn.usesSystemSshTransport === 'function' ? conn.usesSystemSshTransport() : false + return usesSystemSsh ? undefined : namespace +} + const NODE_PTY_VERSION = '1.1.0' const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs' const RELAY_NATIVE_DEPS = { diff --git a/src/main/ssh/ssh-relay-install-lock.test.ts b/src/main/ssh/ssh-relay-install-lock.test.ts new file mode 100644 index 00000000000..d70409def41 --- /dev/null +++ b/src/main/ssh/ssh-relay-install-lock.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + execCommand: vi.fn(), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + isRelayGcClaimed: vi.fn().mockResolvedValue(false), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +import { execCommand } from './ssh-relay-deploy-helpers' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +describe('acquireInstallLock', () => { + afterEach(() => { + vi.resetAllMocks() + }) + + it('propagates an unconfirmed lock-create termination so deploy can retain the lock fence', async () => { + const controller = new AbortController() + const termination = Object.assign(new Error('lock creation termination was not confirmed'), { + sshChannelCloseConfirmed: false + }) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('.install-lock')) { + controller.abort( + Object.assign(new Error('SSH operation was cancelled'), { name: 'AbortError' }) + ) + throw termination + } + return '' + }) + + await expect( + acquireInstallLock( + {} as SshConnection, + '/home/u/.orca-remote/relay-0.1.0', + getRemoteHostPlatform('linux-x64'), + { signal: controller.signal } + ) + ).rejects.toBe(termination) + }) +}) diff --git a/src/main/ssh/ssh-relay-install-lock.ts b/src/main/ssh/ssh-relay-install-lock.ts index 7cfd561b1ad..0245875e141 100644 --- a/src/main/ssh/ssh-relay-install-lock.ts +++ b/src/main/ssh/ssh-relay-install-lock.ts @@ -1,5 +1,5 @@ import type { SshConnection } from './ssh-connection' -import { execCommand } from './ssh-relay-deploy-helpers' +import { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' import { isRelayGcClaimed, waitForRelayGcClaimRelease } from './ssh-relay-gc-claim' import { @@ -101,7 +101,10 @@ export async function acquireInstallLock( await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) options?.signal?.throwIfAborted() } - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } options?.signal?.throwIfAborted() // A failed mkdir is lock contention; keep the connection-specific error // out of the user path until the bounded wait expires. diff --git a/src/main/ssh/ssh-relay-install-namespace.test.ts b/src/main/ssh/ssh-relay-install-namespace.test.ts index 7d7dd0be5e0..a16c5005aa2 100644 --- a/src/main/ssh/ssh-relay-install-namespace.test.ts +++ b/src/main/ssh/ssh-relay-install-namespace.test.ts @@ -6,11 +6,14 @@ import { describe, expect, it } from 'vitest' import { createRelayInstallMarkerCommand, createRelayInstallNamespace, + createRelayUploadStageNamespace, makeRelayInstallDirectoryCommand, + makeRelayUploadStageDirectoryCommand, relayHomeRelativeDir, relayInstallMarkerShellPath, relayRemoteDirSegments, - relaySftpNamespaceMapping + relaySftpNamespaceMapping, + relayUploadStageSftpNamespaceMapping } from './ssh-relay-install-namespace' import { getRemoteHostPlatform } from './ssh-remote-platform' import { computeRemoteRelayDir } from './ssh-relay-versioned-install' @@ -87,6 +90,7 @@ describe('relaySftpNamespaceMapping', () => { const mapping = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR) expect(mapping.homeRelativePath).toBe(`.orca-remote/relay-${VERSION}`) + expect(mapping.homeRelativeNamespaceRoot).toBe(`.orca-remote/relay-${VERSION}`) expect(mapping.homeRelativeProbePath).toBe( `.orca-remote/relay-${VERSION}/.install-lock/${namespace.markerFileName}` ) @@ -119,6 +123,37 @@ describe('relaySftpNamespaceMapping', () => { ) }) +describe('relay upload stage namespace', () => { + const stageSuffix = `.orca-remote/relay-${VERSION}.upload-123e4567-e89b-12d3-a456-426614174000` + const shellStageDir = `/var/services/homes/alice/${stageSuffix}` + const namespace = createRelayUploadStageNamespace(stageSuffix) + + it('maps only the attempt-owned payload subtree', () => { + const payload = relayUploadStageSftpNamespaceMapping(namespace, LINUX, shellStageDir) + const version = relayUploadStageSftpNamespaceMapping( + namespace, + LINUX, + shellStageDir, + '.version' + ) + + expect(payload.homeRelativeNamespaceRoot).toBe(stageSuffix) + expect(payload.homeRelativePath).toBe(`${stageSuffix}/payload`) + expect(version.homeRelativePath).toBe(`${stageSuffix}/payload/.version`) + expect(payload.shellProbePath).toBe(`${shellStageDir}/${namespace.markerFileName}`) + expect(payload.shellProbePath).toBe(version.shellProbePath) + }) + + it('creates the payload and marker without touching the shared install lock', () => { + const command = makeRelayUploadStageDirectoryCommand(namespace, LINUX, shellStageDir) + + expect(command).toContain(`${shellStageDir}/payload`) + expect(command).toContain(`${shellStageDir}/${namespace.markerFileName}`) + expect(command).toContain('umask 077') + expect(command).not.toContain('.install-lock') + }) +}) + describe('install directory command', () => { const namespace = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) diff --git a/src/main/ssh/ssh-relay-install-namespace.ts b/src/main/ssh/ssh-relay-install-namespace.ts index b46f22dda29..a75b8048841 100644 --- a/src/main/ssh/ssh-relay-install-namespace.ts +++ b/src/main/ssh/ssh-relay-install-namespace.ts @@ -1,8 +1,8 @@ -// Install-owner identity for relay uploads that cross a split shell/SFTP namespace. +// Transfer-owner identity for relay installs that cross a split shell/SFTP namespace. // // The shell and SFTP paths share one validated home-relative suffix -// (`.orca-remote/relay-`); a random marker inside the install lock -// lets each SFTP session prove it is looking at THIS install's directory. +// (`.orca-remote/relay-`); a random marker inside the shared install +// lock or attempt stage proves which shell-owned directory the SFTP session sees. // // See: docs/ssh-relay-sftp-namespace.md @@ -24,6 +24,11 @@ export type RelayInstallNamespace = { markerFileName: string } +export type RelayUploadStageNamespace = { + homeRelativeStageDir: string + markerFileName: string +} + /** * The two validated segments every relay path is built from. Both the shell * builder and the SFTP-relative builder go through here so they cannot drift. @@ -56,6 +61,16 @@ export function createRelayInstallNamespace(homeRelativeRelayDir: string): Relay } } +export function createRelayUploadStageNamespace( + homeRelativeStageDir: string, + markerFileName = createRelayInstallMarkerFileName() +): RelayUploadStageNamespace { + return { + homeRelativeStageDir, + markerFileName + } +} + /** * Describe one relay transfer to the SFTP namespace resolver. `relativeFileName` * is omitted for the bundle directory upload itself. @@ -74,6 +89,7 @@ export function relaySftpNamespaceMapping( } const homeRelativeLockDir = `${namespace.homeRelativeRelayDir}/${RELAY_INSTALL_LOCK_NAME}` return { + homeRelativeNamespaceRoot: namespace.homeRelativeRelayDir, homeRelativePath: relativeFileName !== undefined ? `${namespace.homeRelativeRelayDir}/${relativeFileName}` @@ -83,6 +99,37 @@ export function relaySftpNamespaceMapping( } } +export function relayUploadStageSftpNamespaceMapping( + namespace: RelayUploadStageNamespace, + host: RemoteHostPlatform, + shellStageDir: string, + relativeFileName?: string +): SftpNamespacePathMapping { + if (relativeFileName !== undefined) { + assertSafeRemotePathSegment(relativeFileName, 'posix') + } + const homeRelativePayloadDir = `${namespace.homeRelativeStageDir}/payload` + return { + homeRelativeNamespaceRoot: namespace.homeRelativeStageDir, + homeRelativePath: + relativeFileName === undefined + ? homeRelativePayloadDir + : `${homeRelativePayloadDir}/${relativeFileName}`, + shellProbePath: joinRemotePath(host, shellStageDir, namespace.markerFileName), + homeRelativeProbePath: `${namespace.homeRelativeStageDir}/${namespace.markerFileName}` + } +} + +export function makeRelayUploadStageDirectoryCommand( + namespace: RelayUploadStageNamespace, + host: RemoteHostPlatform, + shellStageDir: string +): string { + const payloadDir = joinRemotePath(host, shellStageDir, 'payload') + const markerPath = joinRemotePath(host, shellStageDir, namespace.markerFileName) + return `${makeRemoteDirectoryCommand(host, payloadDir)} && umask 077 && touch ${shellEscape(markerPath)}` +} + export function relayInstallMarkerShellPath( namespace: RelayInstallNamespace, host: RemoteHostPlatform, diff --git a/src/main/ssh/ssh-relay-install-transfers.ts b/src/main/ssh/ssh-relay-install-transfers.ts index decbcd103ee..c5c99f99b70 100644 --- a/src/main/ssh/ssh-relay-install-transfers.ts +++ b/src/main/ssh/ssh-relay-install-transfers.ts @@ -40,7 +40,9 @@ export async function uploadRelayDirectory( sftpNamespace: options?.sftpNamespace }) options?.signal?.throwIfAborted() - await uploadDirectory(sftp, localRelayDir, targetDir) + await uploadDirectory(sftp, localRelayDir, targetDir, localRelayDir, { + signal: options?.signal + }) }) } @@ -93,6 +95,7 @@ async function runSftpFallbackTransfer( (onClose) => { sftp.once('close', onClose) endSftp() + return () => sftp.removeListener('close', onClose) } ) } finally { diff --git a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts index f2f9ebf4298..e8fc2274583 100644 --- a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts +++ b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts @@ -54,6 +54,20 @@ export function makeMockConnection(capture: SftpWriteCapture): SshConnection { export type ExecResponse = string | { reject: string } +const STAGE_OWNER = '.sftp-namespace-00000000000000000000000000000000' + +export function makeStagedFirstInstallExecPrefix(): ExecResponse[] { + return [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // bounded stale-stage recovery + `__ORCA_UPLOAD_STAGE_SLOT__${STAGE_OWNER}:slot-0`, + '', // chmod staged node + '', // final install namespace marker + `__ORCA_UPLOAD_STAGE_PROMOTION__${STAGE_OWNER}:PROMOTED` + ] +} + // Repair reconnect (isRelayAlreadyInstalled → true) where BOTH native deps are broken and the host // cannot compile node-pty, so the caller's resets must survive into the node-pty-less reinstall. export function makeRepairToolchainSkipExecResponses(): ExecResponse[] { @@ -108,23 +122,18 @@ export function makeExecResponses(opts: { // reinstall succeeds; only then are the chmod/probe/launch slots reached. if (opts.npmInstall !== 'ok' && opts.nodePtySkipRetry !== 'ok') { return [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - '', // mkdir remoteDir (uploadRelay) - '', // chmod +x node + ...makeStagedFirstInstallExecPrefix(), opts.npmInstall, // npm install rejects opts.toolchainProbe ?? 'HAVE make\nHAVE g++\nHAVE cc\nHAVE python3\nPKG apt-get', - ...(opts.nodePtySkipRetry ? [opts.nodePtySkipRetry] : []) // reinstall also rejects + ...(opts.nodePtySkipRetry ? [opts.nodePtySkipRetry] : []), // reinstall also rejects + '' // clean stage root ] } if (opts.npmInstall !== 'ok') { // Skip path, exactly as production runs it: no chmod-prebuilds (node-pty is gone) and no rebuild // (it provably can't compile here). The probe still runs to catch a dead @parcel/watcher. return [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - '', // mkdir remoteDir (uploadRelay) - '', // chmod +x node + ...makeStagedFirstInstallExecPrefix(), opts.npmInstall, // npm install rejects on the missing compiler opts.toolchainProbe ?? 'HAVE python3\nPKG dnf', '', // rm -rf node-pty + reinstall without it @@ -134,6 +143,7 @@ export function makeExecResponses(opts: { : 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING\n', '', // cat probe stderr '', // rm -f probe stderr + '', // clean stage root 'DEAD', '', // publish the per-launch credential 'READY' @@ -151,10 +161,7 @@ export function makeExecResponses(opts: { ? { reject: 'cd: no such file or directory' } : probe const slots: ExecResponse[] = [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - '', // mkdir remoteDir (uploadRelay) - '', // chmod +x node + ...makeStagedFirstInstallExecPrefix(), '', // npm install native deps '', // chmod prebuilds probeSlot @@ -178,6 +185,6 @@ export function makeExecResponses(opts: { slots.push('') // rm -f stderr after rebuild probe } } - slots.push('DEAD', '', 'READY') + slots.push('', 'DEAD', '', 'READY') // clean stage root, launch, credential, readiness return slots } diff --git a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts new file mode 100644 index 00000000000..4d90a7c8289 --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + 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() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), + 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-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'` +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' +import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { + abandonInstall, + finalizeInstall, + isRelayAlreadyInstalled +} from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { + makeExecResponses, + makeStagedFirstInstallExecPrefix, + makeMockConnection, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' + +describe('installNativeDeps staged uploads', () => { + const sftpCapture: SftpWriteCapture = { + paths: [], + contents: {}, + execCallCountAtWrite: {} + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset().mockResolvedValue('') + vi.mocked(uploadDirectory).mockResolvedValue(undefined) + sftpCapture.paths.length = 0 + for (const key of Object.keys(sftpCapture.contents)) { + delete sftpCapture.contents[key] + } + for (const key of Object.keys(sftpCapture.execCallCountAtWrite)) { + delete sftpCapture.execCallCountAtWrite[key] + } + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) + }) + + function feed(execResponses: ExecResponse[]): void { + const mockExec = vi.mocked(execCommand) + for (const response of execResponses) { + if (typeof response === 'string') { + mockExec.mockResolvedValueOnce(response) + } else { + mockExec.mockRejectedValueOnce(new Error(response.reject)) + } + } + } + + it('writes a hardcoded package.json BEFORE running npm install', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) + + await deployAndLaunchRelay(conn) + + const pkgPath = sftpCapture.paths.find((path) => path.endsWith('/package.json')) + expect(pkgPath, 'package.json must be written via SFTP').toBeTruthy() + + const written = sftpCapture.contents[pkgPath as string] + expect(written).toBeTruthy() + const parsed = JSON.parse(written) as Record + expect(parsed.name).toBe('orca-relay') + expect(parsed.version).toBe('1.0.0') + expect(parsed.private).toBe(true) + expect(parsed.type).toBe('commonjs') + expect(parsed.dependencies).toEqual({ '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' }) + expect(parsed.allowScripts).toEqual({ + '@parcel/watcher@2.5.6': true, + 'node-pty@1.1.0': true + }) + + const execCalls = vi.mocked(execCommand).mock.calls.map(([, command]) => command) + const npmInstallIdx = execCalls.findIndex( + (command) => + command.includes('npm install') && + command.includes('node-pty') && + command.includes('@parcel/watcher') + ) + expect(npmInstallIdx).toBeGreaterThanOrEqual(0) + expect(execCalls[npmInstallIdx]).toContain('--ignore-scripts=false') + const writeObservedAt = sftpCapture.execCallCountAtWrite[pkgPath as string] + expect(writeObservedAt).toBeLessThanOrEqual(npmInstallIdx) + }) + + it('promotes only after the first-install lock is acquired', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) + vi.mocked(acquireInstallLock).mockImplementationOnce(async () => { + const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('cp -a'))).toBe(false) + }) + + await deployAndLaunchRelay(conn) + + const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) + const promotionIndex = commands.findIndex((command) => command.includes('cp -a')) + const npmIndex = commands.findIndex((command) => command.includes('npm install')) + expect(promotionIndex).toBeGreaterThanOrEqual(0) + expect(npmIndex).toBeGreaterThan(promotionIndex) + }) + + it('cleans a staged upload when cancellation wins before lock acquisition', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection(sftpCapture) + feed(makeStagedFirstInstallExecPrefix()) + vi.mocked(acquireInstallLock).mockImplementationOnce( + (_conn, _remoteDir, _host, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { + once: true + }) + }) + ) + vi.mocked(execCommand).mockResolvedValueOnce('') + + const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + const result = await deploy + + expect(result).toBeInstanceOf(Error) + expect(vi.mocked(acquireInstallLock)).toHaveBeenCalledTimes(1) + const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('cp -a'))).toBe(false) + expect(commands.some((command) => command.includes('rm -rf'))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('retains the lock after an unconfirmed promotion termination', async () => { + const conn = makeMockConnection(sftpCapture) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/u') + .mockResolvedValueOnce('') + .mockResolvedValueOnce( + '__ORCA_UPLOAD_STAGE_SLOT__.sftp-namespace-00000000000000000000000000000000:slot-0' + ) + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockRejectedValueOnce( + Object.assign(new Error('promotion termination was not confirmed'), { + sshChannelCloseConfirmed: false + }) + ) + .mockResolvedValueOnce('') + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow( + 'promotion termination was not confirmed' + ) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 3036d1d633c..5297da7c85a 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,6 +1,7 @@ // Why: regression coverage for the install-probe contract — the "node-pty is not available" bug shipped because every guard layer was silent. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } @@ -36,6 +37,11 @@ vi.mock('./ssh-remote-node-resolution', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, @@ -81,6 +87,7 @@ import { makeExecResponses, makeMockConnection, makeRepairToolchainSkipExecResponses, + makeStagedFirstInstallExecPrefix, type ExecResponse, type SftpWriteCapture } from './ssh-relay-native-deps-install-fixture' @@ -96,7 +103,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { beforeEach(() => { vi.clearAllMocks() // mockReset because clearAllMocks keeps queued mockResolvedValueOnce entries, so a leaked response would bleed into the next test. - vi.mocked(execCommand).mockReset() + vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) sftpCapture.paths.length = 0 for (const k of Object.keys(sftpCapture.contents)) { @@ -126,40 +133,6 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { } } - it('writes a hardcoded package.json BEFORE running npm install', async () => { - const conn = makeMockConnection(sftpCapture) - feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) - - await deployAndLaunchRelay(conn) - - const pkgPath = sftpCapture.paths.find((p) => p.endsWith('/package.json')) - expect(pkgPath, 'package.json must be written via SFTP').toBeTruthy() - - const written = sftpCapture.contents[pkgPath as string] - expect(written).toBeTruthy() - const parsed = JSON.parse(written) as Record - expect(parsed.name).toBe('orca-relay') - expect(parsed.version).toBe('1.0.0') - expect(parsed.private).toBe(true) - // Why: pin commonjs so a future Node default flip can't break require('node-pty'). - expect(parsed.type).toBe('commonjs') - expect(parsed.dependencies).toEqual({ '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' }) - expect(parsed.allowScripts).toEqual({ - '@parcel/watcher@2.5.6': true, - 'node-pty@1.1.0': true - }) - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) - const npmInstallIdx = execCalls.findIndex( - (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') - ) - expect(npmInstallIdx).toBeGreaterThanOrEqual(0) - expect(execCalls[npmInstallIdx]).toContain('--ignore-scripts=false') - // Pin write-before-install ordering to catch a Promise.all refactor where the final-state assertions above still pass. - const writeObservedAt = sftpCapture.execCallCountAtWrite[pkgPath as string] - expect(writeObservedAt).toBeLessThanOrEqual(npmInstallIdx) - }) - it('propagates a hard `npm install` failure so the deploy aborts before finalizeInstall', async () => { const conn = makeMockConnection(sftpCapture) feed( @@ -366,10 +339,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { // Why: rebuild degrades gracefully, but the post-rebuild probe must surface transport death, else a dead channel finalizes a half-repaired install. const conn = makeMockConnection(sftpCapture) feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', // uname - '/home/u', // $HOME - '', // mkdir remoteDir (uploadRelay) - '', // chmod +x node + ...makeStagedFirstInstallExecPrefix(), '', // npm install native deps '', // chmod prebuilds 'MISSING\n', // first probe: require() fails @@ -393,12 +363,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { vi.useFakeTimers() try { const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - '', // mkdir remoteDir - '' // chmod +x node - ]) + feed(makeStagedFirstInstallExecPrefix()) let installSignal: AbortSignal | undefined vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { expect(command).toContain('npm install') @@ -560,16 +525,19 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { feed([ '__ORCA_REMOTE_PLATFORM__ Windows AMD64', 'C:\\Users\\u', - '', // mkdir remoteDir + '', // bounded stale-stage recovery + '__ORCA_UPLOAD_STAGE_SLOT__.sftp-namespace-00000000000000000000000000000000:slot-0', + '__ORCA_UPLOAD_STAGE_PROMOTION__.sftp-namespace-00000000000000000000000000000000:PROMOTED', '', // npm install native deps 'MISSING\n', // native process exit normalized by PowerShell command '', // npm rebuild native deps 'MISSING\n', // rebuilt native process still cannot load + '', // clean stage root '', // no persisted active pipe marker - 'WAITING', + 'WAITING', // initial pipe probe '', // publish the per-launch credential '', // WMI relay launch - 'READY', + 'READY', // readiness poll '' // persist active pipe marker ]) @@ -637,7 +605,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { for (const k of Object.keys(sftpCapture.execCallCountAtWrite)) { delete sftpCapture.execCallCountAtWrite[k] } - vi.mocked(execCommand).mockReset() + vi.mocked(execCommand).mockReset().mockResolvedValue('') const conn2 = makeMockConnection(sftpCapture) feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) @@ -756,12 +724,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { vi.useFakeTimers() try { const conn = makeMockConnection(sftpCapture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - '', // mkdir remoteDir - '' // chmod +x node - ]) + feed(makeStagedFirstInstallExecPrefix()) let installSignal: AbortSignal | undefined vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { expect(command).toContain('npm install') @@ -789,11 +752,10 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) await vi.waitFor(() => expect(installSignal).toBeDefined()) await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + await vi.advanceTimersByTimeAsync(5_000) const result = await deploy expect(result).toBeInstanceOf(Error) expect((result as Error).message).toContain('Relay deployment timed out') - await vi.advanceTimersByTimeAsync(5_000) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() } finally { @@ -803,21 +765,19 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { it('does not finalize or release a first-install lock after unconfirmed rebuild teardown', async () => { const conn = makeMockConnection(sftpCapture) - vi.mocked(execCommand) - .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') - .mockResolvedValueOnce('/home/u') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('MISSING') - .mockResolvedValueOnce('rebuild diagnostics') - .mockResolvedValueOnce('') - .mockRejectedValueOnce( - Object.assign(new Error('rebuild termination was not confirmed'), { - sshChannelCloseConfirmed: false - }) - ) + feed([ + ...makeStagedFirstInstallExecPrefix(), + '', // npm install + '', // chmod prebuilds + 'MISSING', + 'rebuild diagnostics', + '' // remove probe diagnostics + ]) + vi.mocked(execCommand).mockRejectedValueOnce( + Object.assign(new Error('rebuild termination was not confirmed'), { + sshChannelCloseConfirmed: false + }) + ) await expect(deployAndLaunchRelay(conn)).rejects.toThrow( 'rebuild termination was not confirmed' @@ -831,10 +791,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { try { const conn = makeMockConnection(sftpCapture) feed([ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - '', // mkdir remoteDir - '', // chmod +x node + ...makeStagedFirstInstallExecPrefix(), '', // npm install '', // chmod prebuilds 'MISSING', @@ -867,11 +824,10 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) await vi.waitFor(() => expect(rebuildSignal).toBeDefined()) await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + await vi.advanceTimersByTimeAsync(5_000) const result = await deploy expect(result).toBeInstanceOf(Error) expect((result as Error).message).toContain('Relay deployment timed out') - await vi.advanceTimersByTimeAsync(5_000) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() } finally { diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index bf0a1891cb4..de4f00fdf08 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -149,6 +149,9 @@ function createFakeRelay(): FakeRelay { function createSession(targetId: string): InstanceType { const store = { getRepos: vi.fn().mockReturnValue([]), + getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), + upsertSshPtyConsumerRecovery: vi.fn(), + removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn() diff --git a/src/main/ssh/ssh-relay-session-data-delivery.test.ts b/src/main/ssh/ssh-relay-session-data-delivery.test.ts index 61c7fe86198..19939ae3857 100644 --- a/src/main/ssh/ssh-relay-session-data-delivery.test.ts +++ b/src/main/ssh/ssh-relay-session-data-delivery.test.ts @@ -303,6 +303,45 @@ describe('SshRelaySession data delivery', () => { fresh.dispose() }) + it('resumes authenticated ownership from a persisted main-process recovery record', async () => { + const targetId = 'persisted-recovery-target' + const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() + vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ + targetId, + clientInstanceId: 'persisted-client', + serverBuildId: 'test-relay-build', + clientGeneration: 7, + ownerGeneration: 11, + ownerLease: 'persisted-owner-lease', + outputFlowControl: { version: 1, windowSu: 256 * 1024 } + }) + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, + platform: 'linux-x64', + serverBuildId: 'test-relay-build' + }) + + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + + expect(openConsumerSessionMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + clientInstanceId: 'persisted-client', + resume: { ownerGeneration: 11, ownerLease: 'persisted-owner-lease' } + }) + ) + expect(mockStore.upsertSshPtyConsumerRecovery).toHaveBeenCalledWith( + expect.objectContaining({ + targetId, + clientInstanceId: 'persisted-client', + ownerLease: 'test-owner-lease' + }) + ) + session.dispose() + expect(mockStore.removeSshPtyConsumerRecovery).toHaveBeenCalledWith(targetId) + }) + it('clears stale owner recovery and retries a fresh same-build relay once without resume', async () => { const targetId = 'fresh-relay-retry' const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps() diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts index 52be21dcc2e..24d95fe18a5 100644 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts @@ -100,6 +100,9 @@ function createMockDeps(): { const mockConn = {} as SshConnection const mockStore = { getRepos: vi.fn().mockReturnValue([]), + getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), + upsertSshPtyConsumerRecovery: vi.fn(), + removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn() diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts index b319140c0cf..41c4bca32d4 100644 --- a/src/main/ssh/ssh-relay-session-test-fixtures.ts +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -18,6 +18,9 @@ export function createMockDeps(): SshRelaySessionTestDeps { const mockConn = {} as SshConnection const mockStore = { getRepos: vi.fn().mockReturnValue([]), + getSshPtyConsumerRecovery: vi.fn().mockReturnValue(null), + upsertSshPtyConsumerRecovery: vi.fn(), + removeSshPtyConsumerRecovery: vi.fn(), getSshRemotePtyLeases: vi.fn().mockReturnValue([]), markSshRemotePtyLease: vi.fn(), markSshRemotePtyLeases: vi.fn(), diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index d5dba94c80b..5edce4306a6 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -55,8 +55,6 @@ import { installSshPtySourceAckPublisher, installSshPtySourceCancellationPublisher } from '../ipc/ssh-pty-output-intake-registry' -import type { SshPtyAcceptedSourceCheckpoint } from '../ipc/ssh-pty-output-source-obligations' -import type { SshPtyOutputMigrationResult } from '../ipc/ssh-pty-output-model-migration' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider, @@ -102,6 +100,14 @@ import type { } from '../../shared/pty-source-recovery-contract' import { SshPtyRecoveryRetentionBudget } from './ssh-pty-recovery-retention-budget' import { SshPtyRetiredSourceDeliveries } from './ssh-pty-retired-source-deliveries' +import { + claimSshPtyConsumerRecovery, + detachSshPtyConsumerRecovery, + forgetSshPtyConsumerRecovery, + getSshPtyConsumerRecovery, + rememberSshPtyConsumerRecovery, + removeSshPtyConsumerOwnerRecovery +} from './ssh-pty-consumer-recovery' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' @@ -227,33 +233,6 @@ function normalizeRelayGracePeriodSeconds(graceTimeSeconds: number | undefined): ) } -type PtyConsumerRecovery = { - clientInstanceId: string - detached: boolean - serverBuildId?: string - owner?: SshPtyConsumerOwnerState - checkpointsByAppPtyId: Map - modelMigrationsByAppPtyId: Map> -} - -const ptyConsumerRecoveryByTarget = new Map() - -function ptyConsumerRecoveryForTarget(targetId: string): PtyConsumerRecovery { - const current = ptyConsumerRecoveryByTarget.get(targetId) - if (current?.detached) { - current.detached = false - return current - } - const created = { - clientInstanceId: randomUUID(), - detached: false, - checkpointsByAppPtyId: new Map(), - modelMigrationsByAppPtyId: new Map>() - } - ptyConsumerRecoveryByTarget.set(targetId, created) - return created -} - export class SshRelaySession { private _state: RelaySessionState = 'idle' private mux: SshChannelMultiplexer | null = null @@ -305,7 +284,7 @@ export class SshRelaySession { platform: string ) => void ) { - this.ptyConsumerClientInstanceId = ptyConsumerRecoveryForTarget(targetId).clientInstanceId + this.ptyConsumerClientInstanceId = claimSshPtyConsumerRecovery(targetId, store).clientInstanceId } refreshEnvironment( @@ -654,7 +633,7 @@ export class SshRelaySession { this.store.markSshRemotePtyLeases(this.targetId, 'terminated') this.currentConnection = null this._state = 'disposed' - ptyConsumerRecoveryByTarget.delete(this.targetId) + forgetSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId, this.store) } detach(): void { @@ -669,10 +648,7 @@ export class SshRelaySession { this.store.markSshRemotePtyLeases(this.targetId, 'detached') this.currentConnection = null this._state = 'disposed' - const recovery = ptyConsumerRecoveryByTarget.get(this.targetId) - if (recovery?.clientInstanceId === this.ptyConsumerClientInstanceId) { - recovery.detached = true - } + detachSshPtyConsumerRecovery(this.targetId, this.ptyConsumerClientInstanceId) } // ── Private ─────────────────────────────────────────────────────── @@ -729,7 +705,7 @@ export class SshRelaySession { this.remoteCliBridgeEnv ?? undefined, providerGeneration ) - const consumerOwnerState = this.negotiatedPtyConsumerOwner() + const consumerOwnerState = this.activePtyConsumerOwner() if (consumerOwnerState) { ptyProvider.setPtyDeliveryPauseAdapter?.(({ id, providerGeneration: generation, paused }) => { if ( @@ -836,13 +812,20 @@ export class SshRelaySession { return true } - private negotiatedPtyConsumerOwner(serverBuildId?: string): SshPtyConsumerOwnerState | null { + private activePtyConsumerOwner(): SshPtyConsumerOwnerState | null { const state = this.ptyConsumerSessionState - if (state && state.mode !== 'legacy-fallback') { - return state as SshPtyConsumerOwnerState + return state && state.mode !== 'legacy-fallback' ? state : null + } + + private recoverablePtyConsumerOwner( + serverBuildId: string | undefined + ): SshPtyConsumerOwnerState | null { + const active = this.activePtyConsumerOwner() + if (active) { + return active } - const recovery = ptyConsumerRecoveryByTarget.get(this.targetId) - return !serverBuildId || recovery?.serverBuildId === serverBuildId + const recovery = getSshPtyConsumerRecovery(this.targetId) + return serverBuildId && recovery?.serverBuildId === serverBuildId ? (recovery?.owner ?? null) : null } @@ -851,7 +834,7 @@ export class SshRelaySession { mux: SshChannelMultiplexer, serverBuildId: string | undefined ): Promise { - const previousOwner = this.negotiatedPtyConsumerOwner(serverBuildId) + const previousOwner = this.recoverablePtyConsumerOwner(serverBuildId) const options = { clientInstanceId: this.ptyConsumerClientInstanceId, expectedServerBuildId: serverBuildId, @@ -877,7 +860,7 @@ export class SshRelaySession { ) { throw error } - const recovery = ptyConsumerRecoveryByTarget.get(this.targetId) + const recovery = getSshPtyConsumerRecovery(this.targetId) if (recovery) { delete recovery.owner recovery.checkpointsByAppPtyId.clear() @@ -893,27 +876,23 @@ export class SshRelaySession { ) } } + removeSshPtyConsumerOwnerRecovery(this.targetId, this.ptyConsumerClientInstanceId, this.store) this.ptyConsumerSessionState = null return openSshPtyConsumerSession(mux, options) } } private rememberPtyConsumerRecovery(serverBuildId: string | undefined): void { - const owner = this.negotiatedPtyConsumerOwner() + const owner = this.activePtyConsumerOwner() if (!owner || !serverBuildId) { return } - const previous = ptyConsumerRecoveryByTarget.get(this.targetId) - ptyConsumerRecoveryByTarget.set(this.targetId, { + rememberSshPtyConsumerRecovery({ + targetId: this.targetId, clientInstanceId: this.ptyConsumerClientInstanceId, - detached: false, serverBuildId, owner, - checkpointsByAppPtyId: - previous?.checkpointsByAppPtyId ?? new Map(), - modelMigrationsByAppPtyId: - previous?.modelMigrationsByAppPtyId ?? - new Map>() + store: this.store }) } @@ -1228,7 +1207,7 @@ export class SshRelaySession { this.ptyRecoveryNotificationCleanups = [] if (this.activePtyProviderGeneration !== null) { const providerGeneration = this.activePtyProviderGeneration - if (reason === 'connection_lost' && this.negotiatedPtyConsumerOwner()?.outputFlowControl) { + if (reason === 'connection_lost' && this.activePtyConsumerOwner()?.outputFlowControl) { this.beginPtyModelMigration(providerGeneration, outputGenerationReason) } else { closeSshPtyOutputGeneration(providerGeneration, outputGenerationReason) @@ -1361,7 +1340,7 @@ export class SshRelaySession { return } const pending = this.pendingPtyReattaches.get(payload.id) - if (pending && this.negotiatedPtyConsumerOwner()?.outputFlowControl) { + if (pending && this.activePtyConsumerOwner()?.outputFlowControl) { if (pending.livePassthrough) { void this.acceptPtyData(payload).catch(() => {}) return @@ -1403,7 +1382,7 @@ export class SshRelaySession { } private acceptPtyData(payload: SshPtyDataPayload): Promise { - const consumerOwner = this.negotiatedPtyConsumerOwner() + const consumerOwner = this.activePtyConsumerOwner() const offeredSource = payload.source if ( offeredSource && @@ -1677,10 +1656,10 @@ export class SshRelaySession { this.retiredSourceDeliveries.activate(relayPtyId) clearProviderPtyState(payload.id) deletePtyOwnership(payload.id) - ptyConsumerRecoveryByTarget.get(this.targetId)?.checkpointsByAppPtyId.delete(payload.id) - ptyConsumerRecoveryByTarget - .get(this.targetId) - ?.checkpointsByAppPtyId.delete(toRelaySshPtyId(this.targetId, payload.id)) + getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(payload.id) + getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete( + toRelaySshPtyId(this.targetId, payload.id) + ) this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'terminated') if (deliveryHandled) { return @@ -2103,10 +2082,10 @@ export class SshRelaySession { private async sourceRecoveryRequest( appPtyId: string ): Promise { - if (!this.negotiatedPtyConsumerOwner()?.outputFlowControl) { + if (!this.activePtyConsumerOwner()?.outputFlowControl) { return undefined } - const recovery = ptyConsumerRecoveryByTarget.get(this.targetId) + const recovery = getSshPtyConsumerRecovery(this.targetId) const migration = recovery?.modelMigrationsByAppPtyId.get(appPtyId) if (migration) { const outcome = await migration @@ -2136,7 +2115,7 @@ export class SshRelaySession { } private beginPtyModelMigration(providerGeneration: number, closeReason: string): void { - const recovery = ptyConsumerRecoveryByTarget.get(this.targetId) + const recovery = getSshPtyConsumerRecovery(this.targetId) if (!recovery) { closeSshPtyOutputGeneration(providerGeneration, closeReason) return @@ -2150,7 +2129,7 @@ export class SshRelaySession { const fence = previous ? previous.then(() => result) : result recovery.modelMigrationsByAppPtyId.set(ptyId, fence) void fence.then((outcome) => { - const current = ptyConsumerRecoveryByTarget.get(this.targetId) + const current = getSshPtyConsumerRecovery(this.targetId) if (current?.modelMigrationsByAppPtyId.get(ptyId) !== fence) { return } @@ -2179,7 +2158,7 @@ export class SshRelaySession { ): Promise { const recovery = attachResult.sourceRecovery const pendingRecovery = recovery?.status === 'pending' ? recovery : undefined - const owner = this.negotiatedPtyConsumerOwner() + const owner = this.activePtyConsumerOwner() if ( !owner?.outputFlowControl || !pendingRecovery || @@ -2277,7 +2256,7 @@ export class SshRelaySession { ) // Why: checkpoints are app-id keyed; a relay-id entry here would be shadowed // by a staler app-id entry on the next sourceRecoveryRequest lookup. - ptyConsumerRecoveryByTarget.get(this.targetId)?.checkpointsByAppPtyId.set( + getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.set( appPtyId, Object.freeze({ id: appPtyId, @@ -2417,8 +2396,8 @@ export class SshRelaySession { if (!identity || !recovery || this.sameSourceDelivery(identity, recovery)) { this.sourceIdentityByRelayPtyId.delete(relayPtyId) } - ptyConsumerRecoveryByTarget.get(this.targetId)?.checkpointsByAppPtyId.delete(appPtyId) - ptyConsumerRecoveryByTarget.get(this.targetId)?.checkpointsByAppPtyId.delete(relayPtyId) + getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(appPtyId) + getSshPtyConsumerRecovery(this.targetId)?.checkpointsByAppPtyId.delete(relayPtyId) this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'detached') } diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts index 324781486de..3809af2c3d9 100644 --- a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -5,6 +5,7 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RelayInstallMarkerModule from './ssh-relay-install-marker' vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } @@ -40,6 +41,11 @@ vi.mock('./ssh-remote-node-resolution', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) +vi.mock('./ssh-relay-install-marker', async (importOriginal) => ({ + ...(await importOriginal()), + createRelayInstallMarkerFileName: () => '.sftp-namespace-00000000000000000000000000000000' +})) + vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, @@ -74,7 +80,10 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' -import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { + RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS, + RELAY_DEPLOY_TIMEOUT_MS +} from './ssh-relay-deploy-timing' import { parseUnameToRelayPlatform } from './relay-protocol' import { abandonInstall, @@ -94,6 +103,9 @@ const RELAY_SUFFIX = '.orca-remote/relay-0.1.0+testhash' const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_SUFFIX}` const SFTP_RELAY_DIR = `${SFTP_HOME}/${RELAY_SUFFIX}` const MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/ +const STAGE_OWNER = '.sftp-namespace-00000000000000000000000000000000' +const STAGE_RESERVED = `__ORCA_UPLOAD_STAGE_SLOT__${STAGE_OWNER}:slot-0` +const STAGE_PROMOTED = `__ORCA_UPLOAD_STAGE_PROMOTION__${STAGE_OWNER}:PROMOTED` type ConnectionOptions = { // '/homes/u' models a DSM host whose SFTP subsystem starts outside the shell home. @@ -130,14 +142,15 @@ function newCapture(): Capture { } // The marker this run created, read back from the shell command that made it. -function issuedMarkerName(): string | undefined { +function issuedMarkerNames(): string[] { + const markers: string[] = [] for (const [, command] of vi.mocked(execCommand).mock.calls) { const match = decodeCommand(command).match(MARKER_PATTERN) if (match) { - return match[0] + markers.push(match[0]) } } - return undefined + return markers } function decodeCommand(command: string): string { @@ -155,7 +168,8 @@ function makeConnection(capture: Capture, options: ConnectionOptions = {}): SshC const lstatPresent = options.lstatPresent ?? ((path: string) => - path.startsWith(`${SFTP_HOME}/`) && path.includes(issuedMarkerName() ?? '\0')) + path.startsWith(`${SFTP_HOME}/`) && + issuedMarkerNames().some((marker) => path.includes(marker))) const makeSftp = (): unknown => { const sftp = new EventEmitter() @@ -231,12 +245,33 @@ function feed(responses: string[]): void { const POSIX_FIRST_INSTALL = [ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, - '', // mkdir remoteDir (+ install-owner marker) - '', // chmod +x node + '', // bounded stale-stage recovery + STAGE_RESERVED, + '', // chmod staged node + '', // final install namespace marker + STAGE_PROMOTED, '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', '', // rm probe stderr + '', // clean stage root + 'DEAD', + '', // publish the per-launch credential + 'READY' +] + +const POSIX_SYSTEM_SSH_FIRST_INSTALL = [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + SHELL_HOME, + '', // bounded stale-stage recovery + STAGE_RESERVED, + '', // chmod staged node + STAGE_PROMOTED, + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + '', // clean stage root 'DEAD', '', // publish the per-launch credential 'READY' @@ -274,7 +309,7 @@ describe('relay install writes on a split SFTP namespace', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(execCommand).mockReset() + vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockImplementation((_sftp, _local, remote: string) => { capture.uploadTargets.push(remote) return Promise.resolve() @@ -298,9 +333,10 @@ describe('relay install writes on a split SFTP namespace', () => { await deployAndLaunchRelay(conn) - expect(capture.uploadTargets).toEqual([SFTP_RELAY_DIR]) + expect(capture.uploadTargets).toHaveLength(1) + expect(capture.uploadTargets[0]).toBe('/homes/u/.orca-remote/.upload-stages/slot-0/payload') expect(capture.writePaths).toEqual([ - `${SFTP_RELAY_DIR}/.version`, + `${capture.uploadTargets[0]}/.version`, `${SFTP_RELAY_DIR}/package.json` ]) // Every shell command — mkdir, chmod, npm, launch — still names the shell path. @@ -317,9 +353,14 @@ describe('relay install writes on a split SFTP namespace', () => { await deployAndLaunchRelay(conn) const markerCommands = execCommands().filter((command) => MARKER_PATTERN.test(command)) - expect(markerCommands).toHaveLength(1) - expect(markerCommands[0]).toContain('mkdir') - expect(markerCommands[0]).toContain(`${SHELL_RELAY_DIR}/.install-lock`) + const reservation = markerCommands.find((command) => + command.includes('__ORCA_UPLOAD_STAGE_SLOT__') + ) + const installMarker = markerCommands.find((command) => command.includes('.install-lock')) + expect(reservation).toContain('mkdir') + expect(reservation).toContain('/.orca-remote/.upload-stages') + expect(reservation).not.toContain('.install-lock') + expect(installMarker).toContain(`${SHELL_RELAY_DIR}/.install-lock`) }) it('probes one shared marker for every first-install artifact transfer', async () => { @@ -328,15 +369,17 @@ describe('relay install writes on a split SFTP namespace', () => { await deployAndLaunchRelay(conn) - const marker = issuedMarkerName() - expect(marker).toMatch(MARKER_PATTERN) - expect(capture.lstatCalls).toEqual([ - `${SHELL_RELAY_DIR}/.install-lock/${marker}`, - `${SFTP_RELAY_DIR}/.install-lock/${marker}`, - `${SHELL_RELAY_DIR}/.install-lock/${marker}`, - `${SFTP_RELAY_DIR}/.install-lock/${marker}`, - `${SHELL_RELAY_DIR}/.install-lock/${marker}`, - `${SFTP_RELAY_DIR}/.install-lock/${marker}` + const [stageMarker, installMarker] = issuedMarkerNames() + expect(stageMarker).toMatch(MARKER_PATTERN) + expect(installMarker).toMatch(MARKER_PATTERN) + expect(capture.lstatCalls).toHaveLength(6) + for (const path of capture.lstatCalls.slice(0, 4)) { + expect(path).toContain('.upload-stages') + expect(path).toContain(stageMarker) + } + expect(capture.lstatCalls.slice(4)).toEqual([ + `${SHELL_RELAY_DIR}/.install-lock/${installMarker}`, + `${SFTP_RELAY_DIR}/.install-lock/${installMarker}` ]) }) @@ -349,9 +392,10 @@ describe('relay install writes on a split SFTP namespace', () => { await deployAndLaunchRelay(conn) - expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') + expect(capture.uploadTargets[0]).toMatch(/\/payload$/) expect(capture.writePaths).toEqual([ - `${SHELL_RELAY_DIR}/.version`, + `${capture.uploadTargets[0]}/.version`, `${SHELL_RELAY_DIR}/package.json` ]) }) @@ -362,7 +406,8 @@ describe('relay install writes on a split SFTP namespace', () => { await deployAndLaunchRelay(conn) - expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') + expect(capture.uploadTargets[0]).toMatch(/\/payload$/) expect(capture.lstatCalls).toEqual([]) }) @@ -375,7 +420,10 @@ describe('relay install writes on a split SFTP namespace', () => { await deployAndLaunchRelay(conn) - expect(capture.uploadTargets).toEqual([`/volume1/shared/${RELAY_SUFFIX}`]) + expect(capture.uploadTargets[0]).toContain( + '/volume1/shared/.orca-remote/.upload-stages/slot-0/payload' + ) + expect(capture.uploadTargets[0]).toMatch(/\/payload$/) }) it('passes the same mapping to a connection that owns its own transfer methods', async () => { @@ -385,43 +433,43 @@ describe('relay install writes on a split SFTP namespace', () => { await deployAndLaunchRelay(conn) // The shipping path hands over shell paths plus a mapping; resolution happens on the write session. - expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') + expect(capture.uploadTargets[0]).toMatch(/\/payload$/) expect(capture.writePaths).toEqual([ - `${SHELL_RELAY_DIR}/.version`, + `${capture.uploadTargets[0]}/.version`, `${SHELL_RELAY_DIR}/package.json` ]) - const marker = issuedMarkerName() + const [stageMarker, installMarker] = issuedMarkerNames() const mappings = [...capture.uploadOptions, ...capture.writeOptions].map( (options) => options?.sftpNamespace ) expect(mappings).toHaveLength(3) - for (const mapping of mappings) { - expect(mapping?.shellProbePath).toBe(`${SHELL_RELAY_DIR}/.install-lock/${marker}`) - expect(mapping?.homeRelativeProbePath).toBe(`${RELAY_SUFFIX}/.install-lock/${marker}`) - } - expect(mappings.map((mapping) => mapping?.homeRelativePath)).toEqual([ - RELAY_SUFFIX, - `${RELAY_SUFFIX}/.version`, - `${RELAY_SUFFIX}/package.json` - ]) + expect(mappings[0]?.shellProbePath).toContain('/.orca-remote/.upload-stages/slot-0/') + expect(mappings[0]?.shellProbePath).toContain(stageMarker) + expect(mappings[1]?.shellProbePath).toBe(mappings[0]?.shellProbePath) + expect(mappings[2]?.shellProbePath).toBe(`${SHELL_RELAY_DIR}/.install-lock/${installMarker}`) + expect(mappings[0]?.homeRelativePath).toBe('.orca-remote/.upload-stages/slot-0/payload') + expect(mappings[1]?.homeRelativePath).toBe(`${mappings[0]?.homeRelativePath}/.version`) + expect(mappings[2]?.homeRelativePath).toBe(`${RELAY_SUFFIX}/package.json`) expect(capture.realpathCalls).toEqual([]) }) it('leaves system-SSH connections unmapped and unprobed', async () => { // transferMethods models the shipping SshConnection path (uploadDirectory/writeFile → system SSH helpers). const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) - feed(POSIX_FIRST_INSTALL) + feed(POSIX_SYSTEM_SSH_FIRST_INSTALL) await deployAndLaunchRelay(conn) - expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) + expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(true) expect(capture.realpathCalls).toEqual([]) expect(capture.lstatCalls).toEqual([]) expect(conn.sftp).not.toHaveBeenCalled() // System SSH never retargets: shell absolute paths, no mapping, no SFTP session. - expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.uploadTargets[0]).toContain('/.orca-remote/.upload-stages/slot-0/payload') + expect(capture.uploadTargets[0]).toMatch(/\/payload$/) expect(capture.writePaths).toEqual([ - `${SHELL_RELAY_DIR}/.version`, + `${capture.uploadTargets[0]}/.version`, `${SHELL_RELAY_DIR}/package.json` ]) const transferOptions = [...capture.uploadOptions, ...capture.writeOptions] @@ -434,22 +482,34 @@ describe('relay install writes on a split SFTP namespace', () => { it('leaves Windows hosts unmapped and unprobed', async () => { vi.mocked(parseUnameToRelayPlatform).mockReturnValue('win32-x64') const conn = makeConnection(capture) - feed([ - '__ORCA_REMOTE_PLATFORM__ Windows AMD64', - 'C:\\Users\\u', - '' // mkdir remoteDir - ]) - // Fail the install right after the package.json write; the launch path is not what this asserts. - vi.mocked(execCommand).mockRejectedValueOnce(new Error('npm install failed')) + vi.mocked(execCommand).mockImplementation((_conn, command) => { + const decoded = decodeCommand(command) + if (decoded.includes('__ORCA_REMOTE_PLATFORM__')) { + return Promise.resolve('__ORCA_REMOTE_PLATFORM__ Windows AMD64') + } + if (decoded.includes('[Environment]::GetFolderPath')) { + return Promise.resolve('C:\\Users\\u') + } + if (decoded.includes('__ORCA_UPLOAD_STAGE_SLOT__')) { + return Promise.resolve(STAGE_RESERVED) + } + if (decoded.includes('__ORCA_UPLOAD_STAGE_PROMOTION__')) { + return Promise.resolve(STAGE_PROMOTED) + } + if (decoded.includes('npm install')) { + return Promise.reject(new Error('npm install failed')) + } + return Promise.resolve('') + }) await expect(deployAndLaunchRelay(conn)).rejects.toThrow('npm install failed') - expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) + expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(true) expect(capture.realpathCalls).toEqual([]) - expect(capture.writePaths).toEqual([ - 'C:/Users/u/.orca-remote/relay-0.1.0+testhash/.version', - 'C:/Users/u/.orca-remote/relay-0.1.0+testhash/package.json' - ]) + expect(capture.writePaths[0]).toBe( + 'C:/Users/u/.orca-remote/.upload-stages/slot-0/payload/.version' + ) + expect(capture.writePaths[1]).toBe('C:/Users/u/.orca-remote/relay-0.1.0+testhash/package.json') }) it('releases the first-install lock when a redirected upload fails', async () => { @@ -459,7 +519,7 @@ describe('relay install writes on a split SFTP namespace', () => { await expect(deployAndLaunchRelay(conn)).rejects.toThrow('sftp write failed') - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() }) @@ -472,13 +532,13 @@ describe('relay install writes on a split SFTP namespace', () => { const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1)) await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS) const result = await deploy expect((result as Error).message).toContain('Relay deployment timed out') await vi.advanceTimersByTimeAsync(5_000) expect(capture.sftpEndCalls).toBe(1) - // A confirmed close releases the first-install lock and leaves the dir incomplete. - expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() } finally { vi.useRealTimers() @@ -494,8 +554,8 @@ describe('relay install writes on a split SFTP namespace', () => { const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1)) await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS) await deploy - await vi.advanceTimersByTimeAsync(5_000) expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() @@ -511,7 +571,7 @@ describe('relay repair writes on a split SFTP namespace', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(execCommand).mockReset() + vi.mocked(execCommand).mockReset().mockResolvedValue('') vi.mocked(uploadDirectory).mockResolvedValue(undefined) vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) diff --git a/src/main/ssh/ssh-relay-upload-cancel.docker.test.ts b/src/main/ssh/ssh-relay-upload-cancel.docker.test.ts new file mode 100644 index 00000000000..e67fb3116c0 --- /dev/null +++ b/src/main/ssh/ssh-relay-upload-cancel.docker.test.ts @@ -0,0 +1,422 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) + +import { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { listRelayBaseDirsCommand } from './ssh-remote-commands' +import { gcOldRelayVersions } from './ssh-relay-versioned-install' +import type { SshTarget } from '../../shared/ssh-types' + +const RUN_REVIEW_ORACLE = process.env.ORCA_REVIEW_SSH_UPLOAD_CANCEL === '1' +const REMOTE_REPO = '/tmp/orca-pr-10207-real-repo' + +type TargetFixture = { + containerName: string + identityFile: string + port: number + tempDir: string +} + +type RemoteInventory = { + installLock: boolean + payloadFiles: number + uploadStages: string[] +} + +function run(command: string, args: string[], timeout = 30_000): string { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout + }).trim() +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +function dockerExec(fixture: TargetFixture, command: string): string { + return run('docker', ['exec', fixture.containerName, 'bash', '-lc', command], 60_000) +} + +function startTarget(): TargetFixture { + const image = process.env.ORCA_REVIEW_SSH_IMAGE + if (!image) { + throw new Error('ORCA_REVIEW_SSH_IMAGE is required') + } + const tempDir = mkdtempSync(join(tmpdir(), 'orca-pr10207-ssh-')) + const identityFile = join(tempDir, 'id_ed25519') + run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) + const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim() + const containerName = `orca-pr10207-${randomUUID().slice(0, 12)}` + run( + 'docker', + [ + 'run', + '-d', + '--name', + containerName, + '-p', + '0.0.0.0::22', + '-e', + `AUTHORIZED_KEY=${publicKey}`, + image, + 'bash', + '-lc', + 'printf "%s\\n" "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && exec /usr/sbin/sshd -D -e' + ], + 120_000 + ) + const port = Number(run('docker', ['port', containerName, '22/tcp']).split(':').at(-1)) + const fixture = { containerName, identityFile, port, tempDir } + dockerExec( + fixture, + [ + `mkdir -p ${shellQuote(REMOTE_REPO)}`, + `cd ${shellQuote(REMOTE_REPO)}`, + 'git init', + 'git config user.email review@test.local', + 'git config user.name "PR 10207 Review"', + "printf '%s\\n' real-docker-ssh-repository > README.md", + 'git add README.md', + 'git commit -m initial' + ].join(' && ') + ) + return fixture +} + +function stopTarget(fixture: TargetFixture | null): void { + if (!fixture) { + return + } + spawnSync('docker', ['stop', fixture.containerName], { stdio: 'ignore', timeout: 20_000 }) + spawnSync('docker', ['rm', fixture.containerName], { stdio: 'ignore', timeout: 20_000 }) + rmSync(fixture.tempDir, { recursive: true, force: true }) +} + +function createConnection(fixture: TargetFixture): SshConnection { + const host = process.env.ORCA_REVIEW_SSH_TARGET_HOST ?? '' + if (!host || host === 'localhost' || host === '::1' || host.startsWith('127.')) { + throw new Error(`Review SSH target must be non-loopback, received ${JSON.stringify(host)}`) + } + const target: SshTarget = { + id: `pr-10207-${randomUUID()}`, + label: 'PR 10207 Docker SSH target', + source: 'manual', + host, + port: fixture.port, + username: 'root', + identityFile: fixture.identityFile, + identitiesOnly: true + } + return new SshConnection(target, { onStateChange: vi.fn() }) +} + +function readInventory(fixture: TargetFixture, remoteRelayDir: string): RemoteInventory { + const stagePool = '/root/.orca-remote/.upload-stages' + const raw = dockerExec( + fixture, + [ + `lock=0; test -d ${shellQuote(`${remoteRelayDir}/.install-lock`)} && lock=1`, + `files=0; test -d ${shellQuote(remoteRelayDir)} && files=$(find ${shellQuote(remoteRelayDir)} -type f ! -path '*/.install-lock/*' | wc -l | tr -d ' ')`, + `printf 'LOCK=%s\\nFILES=%s\\n' "$lock" "$files"`, + `find ${shellQuote(stagePool)} -mindepth 1 -maxdepth 1 \\( -name 'slot-*' -o -name 'claim-*' -o -name 'delete-*' \\) -print 2>/dev/null | sort || true` + ].join('; ') + ) + const lines = raw.split(/\r?\n/) + return { + installLock: lines[0] === 'LOCK=1', + payloadFiles: Number(lines[1]?.slice('FILES='.length) ?? 0), + uploadStages: lines.slice(2).filter(Boolean) + } +} + +describe.skipIf(!RUN_REVIEW_ORACLE)('SSH relay upload cancellation recovery', () => { + let fixture: TargetFixture | null = null + + beforeAll(() => { + fixture = startTarget() + }) + + afterAll(() => { + stopTarget(fixture) + }) + + it('aborts a live SFTP upload after remote bytes arrive without creating the shared lock', async () => { + const activeFixture = fixture as TargetFixture + const localRelayDir = join(process.cwd(), 'out', 'relay', 'linux-arm64') + const relayVersion = readFileSync(join(localRelayDir, '.version'), 'utf8').trim() + const relayJsSize = statSync(join(localRelayDir, 'relay.js')).size + const remoteRelayDir = `/root/.orca-remote/relay-${relayVersion}` + const connection = createConnection(activeFixture) + await connection.connect() + const sentinelPid = dockerExec(activeFixture, 'sleep 300 /dev/null 2>&1 & echo $!') + let releaseFirstWrite: () => void = () => {} + const firstWrite = new Promise((resolve) => { + releaseFirstWrite = resolve + }) + let acknowledgedBytes = 0 + const openSftp = connection.sftp.bind(connection) + connection.sftp = vi.fn(async (signal) => { + const sftp = await openSftp(signal) + const createWriteStream = sftp.createWriteStream.bind(sftp) + sftp.createWriteStream = ((...args: Parameters) => { + const [remotePath] = args + const stream = createWriteStream(...args) + if (!remotePath.endsWith('/payload/relay.js')) { + return stream + } + const writable = stream as typeof stream & { + _write: ( + chunk: Buffer, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ) => void + } + const write = writable._write.bind(writable) + writable._write = (chunk, encoding, callback): void => { + write(chunk, encoding, (error) => { + if (error) { + callback(error) + return + } + acknowledgedBytes += chunk.length + releaseFirstWrite() + }) + } + return stream + }) as typeof sftp.createWriteStream + return sftp + }) + + const deployment = deployAndLaunchRelay(connection, undefined, 60) + let barrierTimeout: ReturnType + try { + await Promise.race([ + firstWrite, + new Promise((_resolve, reject) => { + barrierTimeout = setTimeout( + () => reject(new Error('Timed out waiting for first remote SFTP chunk')), + 30_000 + ) + }) + ]) + } finally { + clearTimeout(barrierTimeout!) + } + const partialRemoteBytes = Number( + dockerExec( + activeFixture, + "find /root/.orca-remote/.upload-stages -type f -path '*/slot-*/payload/relay.js' -printf '%s\\n'" + ) + ) + const operationController = ( + connection as unknown as { systemOperationAbortController: AbortController } + ).systemOperationAbortController + operationController.abort() + + await expect(deployment).rejects.toMatchObject({ name: 'AbortError' }) + const inventory = readInventory(activeFixture, remoteRelayDir) + const sentinelCommand = dockerExec( + activeFixture, + `tr '\\0' ' ' < /proc/${shellQuote(sentinelPid)}/cmdline` + ) + await connection.disconnect() + + console.log( + `[pr-10207-live-sftp-abort] ${JSON.stringify({ acknowledgedBytes, partialRemoteBytes, relayJsSize, inventory, sentinelPid, sentinelCommand })}` + ) + expect(acknowledgedBytes).toBeGreaterThan(0) + expect(partialRemoteBytes).toBe(acknowledgedBytes) + expect(partialRemoteBytes).toBeLessThan(relayJsSize) + expect(inventory.installLock).toBe(false) + expect(sentinelCommand).toContain('sleep 300') + }, 180_000) + + it('recovers cancellation with bounded safe reclamation and bounded real version GC', async () => { + const activeFixture = fixture as TargetFixture + const relayVersion = readFileSync( + join(process.cwd(), 'out', 'relay', 'linux-arm64', '.version'), + 'utf8' + ).trim() + const remoteRelayDir = `/root/.orca-remote/relay-${relayVersion}` + const firstConnection = createConnection(activeFixture) + const progress: string[] = [] + await firstConnection.connect() + const unconfirmedCancellation = Object.assign(new Error('injected upload cancellation'), { + sshChannelCloseConfirmed: false + }) + firstConnection.uploadDirectory = vi.fn().mockRejectedValue(unconfirmedCancellation) + + await expect( + deployAndLaunchRelay(firstConnection, (status) => progress.push(status), 60) + ).rejects.toBe(unconfirmedCancellation) + await firstConnection.disconnect() + const firstInventory = readInventory(activeFixture, remoteRelayDir) + const expected = process.env.ORCA_REVIEW_EXPECT_RECOVERY === '1' ? 'recovered' : 'blocked' + if (expected === 'recovered') { + const secondAbandonedConnection = createConnection(activeFixture) + await secondAbandonedConnection.connect() + secondAbandonedConnection.uploadDirectory = vi.fn().mockRejectedValue(unconfirmedCancellation) + await expect(deployAndLaunchRelay(secondAbandonedConnection, undefined, 60)).rejects.toBe( + unconfirmedCancellation + ) + await secondAbandonedConnection.disconnect() + } + + const retryConnection = createConnection(activeFixture) + await retryConnection.connect() + let retryResult: 'blocked' | 'recovered' + let finalInventory: RemoteInventory | undefined + let scaleEvidence: + | { gcDurationMs: number; listingBytes: number; scaleEntries: number } + | undefined + if (firstInventory.installLock) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 1_500) + await expect( + acquireInstallLock(retryConnection, remoteRelayDir, getRemoteHostPlatform('linux-arm64'), { + signal: controller.signal + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + clearTimeout(timer) + retryResult = 'blocked' + } else { + const deployed = await deployAndLaunchRelay(retryConnection, undefined, 60) + const mux = new SshChannelMultiplexer(deployed.transport) + await expect(mux.request('session.resolveHome', { path: '~' })).resolves.toEqual({ + resolvedPath: '/root' + }) + mux.dispose() + retryResult = 'recovered' + } + const repoHead = await execCommand( + retryConnection, + `cd ${shellQuote(REMOTE_REPO)} && git rev-parse --verify HEAD` + ) + await retryConnection.disconnect() + + if (expected === 'recovered') { + const replacedStage = firstInventory.uploadStages[0]! + const originalStage = `${replacedStage}.original` + const foreignTarget = '/root/orca-pr10207-foreign-stage-target' + const stagePool = '/root/.orca-remote/.upload-stages' + const symlinkStage = `${stagePool}/slot-2` + const ownerMarker = '.orca-upload-owner' + const identityMarker = '.orca-upload-identity' + dockerExec( + activeFixture, + [ + `mv ${shellQuote(replacedStage)} ${shellQuote(originalStage)}`, + `mkdir -p ${shellQuote(`${replacedStage}/payload`)} ${shellQuote(foreignTarget)}`, + `cp ${shellQuote(`${originalStage}/${ownerMarker}`)} ${shellQuote(`${replacedStage}/${ownerMarker}`)}`, + `cp ${shellQuote(`${originalStage}/${identityMarker}`)} ${shellQuote(`${replacedStage}/${identityMarker}`)}`, + `touch -d '3 hours ago' ${shellQuote(`${replacedStage}/${ownerMarker}`)} ${shellQuote(`${originalStage}/${ownerMarker}`)}`, + `printf foreign > ${shellQuote(`${replacedStage}/payload/foreign`)}`, + `printf alive > ${shellQuote(`${foreignTarget}/sentinel`)}`, + `ln -s ${shellQuote(foreignTarget)} ${shellQuote(symlinkStage)}`, + `i=0; while [ "$i" -lt 15197 ]; do mkdir ${shellQuote(`/root/.orca-remote/relay-${relayVersion}.upload-scale-`)}"$i"; i=$((i + 1)); done` + ].join(' && ') + ) + const adversarialInventory = readInventory(activeFixture, remoteRelayDir) + + const reclaimableStage = `${stagePool}/slot-1` + dockerExec( + activeFixture, + `touch -d '3 hours ago' ${shellQuote(`${reclaimableStage}/${ownerMarker}`)}` + ) + + const cleanupConnection = createConnection(activeFixture) + await cleanupConnection.connect() + const cleanedDeployment = await deployAndLaunchRelay(cleanupConnection, undefined, 60) + const cleanupMux = new SshChannelMultiplexer(cleanedDeployment.transport) + await expect(cleanupMux.request('session.resolveHome', { path: '~' })).resolves.toEqual({ + resolvedPath: '/root' + }) + const reclaimDeadline = Date.now() + 10_000 + while ( + dockerExec( + activeFixture, + `test -e ${shellQuote(reclaimableStage)} && echo PRESENT || true` + ) && + Date.now() < reclaimDeadline + ) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + expect( + dockerExec(activeFixture, `test ! -e ${shellQuote(reclaimableStage)} && echo RECLAIMED`) + ).toBe('RECLAIMED') + + const listing = await execCommand( + cleanupConnection, + listRelayBaseDirsCommand(getRemoteHostPlatform('linux-arm64'), '/root/.orca-remote') + ) + const gcStartedAt = Date.now() + await gcOldRelayVersions( + cleanupConnection, + '/root', + remoteRelayDir, + getRemoteHostPlatform('linux-arm64') + ) + const gcDurationMs = Date.now() - gcStartedAt + finalInventory = readInventory(activeFixture, remoteRelayDir) + expect( + dockerExec( + activeFixture, + `test -L ${shellQuote(symlinkStage)} && test -d ${shellQuote(foreignTarget)} && test "$(cat ${shellQuote(`${foreignTarget}/sentinel`)})" = alive && echo PRESERVED` + ).trim() + ).toBe('PRESERVED') + expect( + dockerExec( + activeFixture, + `test -d ${shellQuote(replacedStage)} && test -d ${shellQuote(originalStage)} && echo PRESERVED` + ).trim() + ).toBe('PRESERVED') + expect(finalInventory.uploadStages.sort()).toEqual( + adversarialInventory.uploadStages.filter((stage) => stage !== reclaimableStage).sort() + ) + const listingBytes = Buffer.byteLength(listing) + expect(listingBytes).toBeLessThan(1_024) + expect(gcDurationMs).toBeLessThan(10_000) + const scaleEntries = Number( + dockerExec( + activeFixture, + `find /root/.orca-remote -mindepth 1 -maxdepth 1 -type d -name ${shellQuote(`relay-${relayVersion}.upload-scale-*`)} | wc -l` + ) + ) + expect(scaleEntries).toBe(15_197) + scaleEvidence = { gcDurationMs, listingBytes, scaleEntries } + cleanupMux.dispose() + await cleanupConnection.disconnect() + } + console.log( + `[pr-10207-oracle] ${JSON.stringify({ progress, firstInventory, retryResult, finalInventory, scaleEvidence, repoHead: repoHead.trim() })}` + ) + expect(progress).toContain('Uploading relay...') + expect(repoHead.trim()).toMatch(/^[0-9a-f]{40}$/) + expect(retryResult).toBe(expected) + if (expected === 'recovered') { + expect(firstInventory.installLock).toBe(false) + expect(firstInventory.uploadStages.length).toBeGreaterThan(0) + expect(finalInventory?.installLock).toBe(false) + expect(finalInventory!.uploadStages).toEqual([ + '/root/.orca-remote/.upload-stages/slot-0', + '/root/.orca-remote/.upload-stages/slot-0.original', + '/root/.orca-remote/.upload-stages/slot-2' + ]) + } else { + expect(firstInventory.installLock).toBe(true) + expect(firstInventory.payloadFiles).toBe(0) + } + }, 180_000) +}) diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts new file mode 100644 index 00000000000..c1e3031f095 --- /dev/null +++ b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts @@ -0,0 +1,336 @@ +import { spawnSync } from 'node:child_process' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform' +import { + cleanupOwnedRelayUploadStageCommand, + parseReservedRelayUploadStage, + promoteOwnedRelayUploadStageCommand, + recoverOneStaleRelayUploadStageCommand, + relayUploadStagePromotionConfirmed, + reserveRelayUploadStageCommand, + RELAY_UPLOAD_STAGE_SLOT_COUNT +} from './ssh-relay-upload-stage-commands' + +const posix = getRemoteHostPlatform('linux-x64') +const windows = getRemoteHostPlatform('win32-x64') +const owner = '.sftp-namespace-123e4567e89b12d3a456426614174000' +const roots: string[] = [] +const configuredPowerShell = process.env.ORCA_POWERSHELL_EXECUTABLE +const powerShellExecutable = [ + configuredPowerShell, + ...(process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh']) +].find( + (candidate) => + candidate && + spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { + stdio: 'ignore' + }).status === 0 +) + +function decodePowerShellCommand(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/u)?.[1] ?? '' + return Buffer.from(encoded, 'base64').toString('utf16le') +} + +function createPool(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-upload-stage-pool-')) + roots.push(root) + const pool = join(root, 'pool') + mkdirSync(pool) + return pool +} + +function runCommand(host: RemoteHostPlatform, command: string, prefix = '') { + if (host.commandDialect === 'powershell') { + if (!powerShellExecutable) { + throw new Error('PowerShell is unavailable') + } + return spawnSync( + powerShellExecutable, + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `${prefix}\n${decodePowerShellCommand(command)}` + ], + { encoding: 'utf8' } + ) + } + return spawnSync('/bin/sh', ['-c', `${prefix}\n${command}`], { encoding: 'utf8' }) +} + +function createStage( + host: RemoteHostPlatform, + pool: string, + index: number, + stageOwner = owner, + stale = false, + state: 'slot' | 'claim' | 'delete' = 'slot' +): string { + const reservedOwner = /^\.sftp-namespace-[0-9a-f]{32}$/u.test(stageOwner) ? stageOwner : owner + const reservation = runCommand(host, reserveRelayUploadStageCommand(host, pool, reservedOwner)) + expect(reservation.status, reservation.stderr).toBe(0) + const slot = join(pool, `slot-${index}`) + const stage = join(pool, `${state}-${index}`) + if (stage !== slot) { + expect(existsSync(stage)).toBe(false) + renameSync(slot, stage) + } + const marker = join(stage, '.orca-upload-owner') + if (stageOwner !== reservedOwner) { + writeFileSync(marker, stageOwner) + } + writeFileSync(join(stage, 'payload', 'relay.js'), `relay-${index}`) + if (stale) { + const old = new Date(Date.now() - 60 * 60_000) + utimesSync(marker, old, old) + } + return stage +} + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe.each([ + ['POSIX', posix] as const, + ...((powerShellExecutable ? [['PowerShell', windows] as const] : []) as (readonly [ + string, + RemoteHostPlatform + ])[]) +])('%s relay upload stage commands', (_label, host) => { + it.each([0, 1, 7, 8, 9])('bounds reservation with %i occupied entries', (count) => { + const pool = createPool() + for (let index = 0; index < Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT); index += 1) { + createStage(host, pool, index) + } + if (count > RELAY_UPLOAD_STAGE_SLOT_COUNT) { + mkdirSync(join(pool, 'foreign-extra')) + } + + const result = runCommand(host, reserveRelayUploadStageCommand(host, pool, owner)) + if (count < RELAY_UPLOAD_STAGE_SLOT_COUNT) { + expect(result.status, result.stderr).toBe(0) + expect(parseReservedRelayUploadStage(host, pool, owner, result.stdout).slotName).toBe( + `slot-${count}` + ) + } else { + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('staging quota is full') + } + }) + + it('promotes only the post-claim owned payload and removes its fixed stage', () => { + const pool = createPool() + const destination = join(pool, 'destination') + mkdirSync(destination) + createStage(host, pool, 0) + const stage = parseReservedRelayUploadStage( + host, + pool, + owner, + `noise\n__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0\n` + ) + + const result = runCommand( + host, + promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) + ) + + expect(result.status, result.stderr).toBe(0) + expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(true) + expect(readFileSync(join(destination, 'relay.js'), 'utf8')).toBe('relay-0') + expect(existsSync(join(pool, 'slot-0'))).toBe(false) + expect(existsSync(join(pool, 'claim-0'))).toBe(false) + expect(existsSync(join(pool, 'delete-0'))).toBe(false) + }) + + it('rejects a payload reparse point without copying or deleting it', () => { + const pool = createPool() + const destination = join(pool, 'destination') + const foreign = join(pool, 'foreign.js') + mkdirSync(destination) + const stagePath = createStage(host, pool, 0) + rmSync(join(stagePath, 'payload', 'relay.js')) + writeFileSync(foreign, 'foreign') + symlinkSync(foreign, join(stagePath, 'payload', 'relay.js')) + const stage = parseReservedRelayUploadStage( + host, + pool, + owner, + `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` + ) + + const result = runCommand( + host, + promoteOwnedRelayUploadStageCommand(host, stage, owner, destination) + ) + + expect(result.status, result.stderr).toBe(0) + expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) + expect(existsSync(join(destination, 'relay.js'))).toBe(false) + expect(lstatSync(join(pool, 'slot-0', 'payload', 'relay.js')).isSymbolicLink()).toBe(true) + expect(readFileSync(foreign, 'utf8')).toBe('foreign') + }) + + it('reclaims one stale owned stage but preserves fresh and foreign stages', () => { + const pool = createPool() + createStage(host, pool, 0, owner, false) + createStage(host, pool, 1, '.foreign-owner', true) + createStage(host, pool, 2, owner, true) + + const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) + + expect(result.status, result.stderr).toBe(0) + expect(existsSync(join(pool, 'slot-0'))).toBe(true) + expect(existsSync(join(pool, 'slot-1'))).toBe(true) + expect(existsSync(join(pool, 'slot-2'))).toBe(false) + }) + + it('drains fixed stale claim and delete states across repeated deployments', () => { + const pool = createPool() + createStage(host, pool, 0, owner, true, 'claim') + createStage(host, pool, 1, owner, true, 'delete') + + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = runCommand(host, recoverOneStaleRelayUploadStageCommand(host, pool, 60)) + expect(result.status, result.stderr).toBe(0) + } + + expect(existsSync(join(pool, 'claim-0'))).toBe(false) + expect(existsSync(join(pool, 'delete-1'))).toBe(false) + }) +}) + +describe('POSIX ownership race fencing', () => { + it('restores a replacement directory and preserves the original moved aside before claim', () => { + const pool = createPool() + const destination = join(pool, 'destination') + mkdirSync(destination) + createStage(posix, pool, 0) + const stage = parseReservedRelayUploadStage( + posix, + pool, + owner, + `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` + ) + const prefix = [ + 'raced=0', + 'mv() {', + 'if [ "$raced" -eq 0 ]; then', + 'raced=1; command mv "$1" "$1.original"; mkdir "$1"; mkdir "$1/payload"; cp "$1.original/.orca-upload-owner" "$1/.orca-upload-owner"; cp "$1.original/.orca-upload-identity" "$1/.orca-upload-identity"; touch -t 202001010000 "$1/.orca-upload-owner"; printf foreign > "$1/foreign";', + 'fi;', + 'command mv "$@";', + '}' + ].join('\n') + + const result = runCommand( + posix, + promoteOwnedRelayUploadStageCommand(posix, stage, owner, destination), + prefix + ) + + expect(result.status, result.stderr).toBe(0) + expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) + expect(existsSync(join(pool, 'slot-0', 'foreign'))).toBe(true) + expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) + expect(existsSync(join(destination, 'relay.js'))).toBe(false) + }) + + it('restores a replacement symlink without touching its target', () => { + const pool = createPool() + const destination = join(pool, 'destination') + const foreign = join(pool, 'foreign') + mkdirSync(destination) + mkdirSync(foreign) + writeFileSync(join(foreign, 'sentinel'), 'alive') + createStage(posix, pool, 0) + const stage = parseReservedRelayUploadStage( + posix, + pool, + owner, + `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` + ) + const prefix = [ + 'raced=0', + 'mv() {', + 'if [ "$raced" -eq 0 ]; then raced=1; command mv "$1" "$1.original"; ln -s ' + + `'${foreign}' "$1"; fi;`, + 'command mv "$@";', + '}' + ].join('\n') + + const result = runCommand( + posix, + cleanupOwnedRelayUploadStageCommand(posix, stage, owner), + prefix + ) + + expect(result.status, result.stderr).toBe(0) + expect(lstatSync(join(pool, 'slot-0')).isSymbolicLink()).toBe(true) + expect(readFileSync(join(foreign, 'sentinel'), 'utf8')).toBe('alive') + expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) + }) +}) + +describe.runIf(powerShellExecutable)('PowerShell ownership race fencing', () => { + it('restores an old same-owner replacement whose persisted file ID does not match', () => { + const pool = createPool() + const destination = join(pool, 'destination') + mkdirSync(destination) + createStage(windows, pool, 0) + const stage = parseReservedRelayUploadStage( + windows, + pool, + owner, + `__ORCA_UPLOAD_STAGE_SLOT__${owner}:slot-0` + ) + const prefix = [ + '$script:raced = $false', + 'function Move-Item {', + 'param($LiteralPath, $Destination, $ErrorAction)', + 'if (-not $script:raced) {', + '$script:raced = $true', + 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination ($LiteralPath + ".original") -ErrorAction Stop', + '$null = New-Item -ItemType Directory -Path $LiteralPath', + '$null = New-Item -ItemType Directory -Path (Join-Path $LiteralPath "payload")', + '$newPath = (Get-Item -LiteralPath $LiteralPath).FullName', + '$originalPath = (Get-Item -LiteralPath ($LiteralPath + ".original")).FullName', + '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-owner"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-owner")))', + '[System.IO.File]::WriteAllText((Join-Path $newPath ".orca-upload-identity"), [System.IO.File]::ReadAllText((Join-Path $originalPath ".orca-upload-identity")))', + '(Get-Item -LiteralPath (Join-Path $newPath ".orca-upload-owner") -Force).LastWriteTimeUtc = [DateTime]::UtcNow.AddHours(-2)', + '[System.IO.File]::WriteAllText((Join-Path $newPath "foreign"), "foreign")', + '}', + 'Microsoft.PowerShell.Management\\Move-Item -LiteralPath $LiteralPath -Destination $Destination -ErrorAction $ErrorAction', + '}' + ].join('\n') + + const result = runCommand( + windows, + promoteOwnedRelayUploadStageCommand(windows, stage, owner, destination), + prefix + ) + + expect(result.status, result.stderr).toBe(0) + expect(relayUploadStagePromotionConfirmed(owner, result.stdout)).toBe(false) + expect(existsSync(join(pool, 'slot-0', 'foreign'))).toBe(true) + expect(existsSync(join(pool, 'slot-0.original', '.orca-upload-owner'))).toBe(true) + expect(existsSync(join(destination, 'relay.js'))).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.ts b/src/main/ssh/ssh-relay-upload-stage-commands.ts new file mode 100644 index 00000000000..9a36c4119bf --- /dev/null +++ b/src/main/ssh/ssh-relay-upload-stage-commands.ts @@ -0,0 +1,254 @@ +import { shellEscape } from './ssh-connection-utils' +import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform' +import { + RELAY_UPLOAD_IDENTITY_FILE_NAME as IDENTITY_FILE_NAME, + RELAY_UPLOAD_OWNER_FILE_NAME as OWNER_FILE_NAME, + RELAY_UPLOAD_PROMOTION_RESULT_PREFIX as PROMOTION_RESULT_PREFIX, + RELAY_UPLOAD_SLOT_RESULT_PREFIX as SLOT_RESULT_PREFIX, + RELAY_UPLOAD_STAGE_POOL_NAME, + RELAY_UPLOAD_STAGE_SLOT_COUNT, + RELAY_UPLOAD_STAGE_STALE_SECONDS, + type RelayUploadStageSlot +} from './ssh-relay-upload-stage-contract' +import { + cleanupWindowsRelayUploadStageCommand, + promoteWindowsRelayUploadStageCommand, + recoverWindowsRelayUploadStageCommand, + reserveWindowsRelayUploadStageCommand +} from './ssh-relay-upload-stage-windows-commands' + +export { + RELAY_UPLOAD_STAGE_POOL_NAME, + RELAY_UPLOAD_STAGE_SLOT_COUNT, + RELAY_UPLOAD_STAGE_STALE_SECONDS, + type RelayUploadStageSlot +} from './ssh-relay-upload-stage-contract' + +const OWNER_PATTERN = /^\.sftp-namespace-[0-9a-f]{32}$/u + +function assertOwner(owner: string): void { + if (!OWNER_PATTERN.test(owner)) { + throw new Error('Invalid relay upload stage owner') + } +} + +function slotPaths( + host: RemoteHostPlatform, + poolDir: string, + slotName: string +): RelayUploadStageSlot { + if (!/^slot-[0-7]$/u.test(slotName)) { + throw new Error('Invalid relay upload stage slot') + } + return { + poolDir, + slotName, + slotDir: joinRemotePath(host, poolDir, slotName), + claimDir: joinRemotePath(host, poolDir, `claim-${slotName.slice(5)}`), + deleteDir: joinRemotePath(host, poolDir, `delete-${slotName.slice(5)}`) + } +} + +export function reserveRelayUploadStageCommand( + host: RemoteHostPlatform, + poolDir: string, + owner: string +): string { + assertOwner(owner) + return isWindowsRemoteHost(host) + ? reserveWindowsRelayUploadStageCommand(poolDir, owner) + : reservePosixStageCommand(poolDir, owner) +} + +export function parseReservedRelayUploadStage( + host: RemoteHostPlatform, + poolDir: string, + owner: string, + output: string +): RelayUploadStageSlot { + assertOwner(owner) + const authenticatedPrefix = `${SLOT_RESULT_PREFIX}${owner}:` + const line = output + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .find((entry) => entry.startsWith(authenticatedPrefix)) + if (!line) { + throw new Error('Remote relay upload stage reservation returned no authenticated slot') + } + return slotPaths(host, poolDir, line.slice(authenticatedPrefix.length)) +} + +export function promoteOwnedRelayUploadStageCommand( + host: RemoteHostPlatform, + stage: RelayUploadStageSlot, + owner: string, + destinationDir: string +): string { + assertOwner(owner) + return isWindowsRemoteHost(host) + ? promoteWindowsRelayUploadStageCommand(stage, owner, destinationDir) + : promotePosixStageCommand(stage, owner, destinationDir) +} + +export function relayUploadStagePromotionConfirmed(owner: string, output: string): boolean { + assertOwner(owner) + return output + .split(/\r?\n/u) + .some((line) => line.trim() === `${PROMOTION_RESULT_PREFIX}${owner}:PROMOTED`) +} + +export function cleanupOwnedRelayUploadStageCommand( + host: RemoteHostPlatform, + stage: RelayUploadStageSlot, + owner: string +): string { + assertOwner(owner) + return isWindowsRemoteHost(host) + ? cleanupWindowsRelayUploadStageCommand(stage, owner) + : cleanupPosixStageCommand(stage, owner) +} + +export function recoverOneStaleRelayUploadStageCommand( + host: RemoteHostPlatform, + poolDir: string, + staleSeconds = RELAY_UPLOAD_STAGE_STALE_SECONDS +): string { + const cutoffSeconds = Math.max(1, Math.ceil(staleSeconds)) + return isWindowsRemoteHost(host) + ? recoverWindowsRelayUploadStageCommand(poolDir, cutoffSeconds) + : recoverPosixStageCommand(poolDir, Math.ceil(cutoffSeconds / 60)) +} + +function reservePosixStageCommand(poolDir: string, owner: string): string { + const pool = shellEscape(poolDir) + const slots = Array.from({ length: RELAY_UPLOAD_STAGE_SLOT_COUNT }, (_, index) => index).join(' ') + return [ + 'umask 077;', + `pool=${pool};`, + 'mkdir -p "$pool" || exit 1;', + '[ -d "$pool" ] && [ ! -L "$pool" ] || exit 1;', + `for n in ${slots}; do`, + 'slot="$pool/slot-$n"; claim="$pool/claim-$n"; deleting="$pool/delete-$n";', + 'if [ ! -e "$claim" ] && [ ! -L "$claim" ] && [ ! -e "$deleting" ] && [ ! -L "$deleting" ] && mkdir "$slot" 2>/dev/null; then', + `identity=$(ls -id "$slot" 2>/dev/null | awk '{print $1}') || identity=;`, + `if [ -n "$identity" ] && mkdir "$slot/payload" && printf '%s' ${shellEscape(owner)} > "$slot/${OWNER_FILE_NAME}" && printf '%s' "$identity" > "$slot/${IDENTITY_FILE_NAME}" && : > "$slot/${owner}"; then`, + `printf '%s%s:%s\\n' ${shellEscape(SLOT_RESULT_PREFIX)} ${shellEscape(owner)} "slot-$n"; exit 0;`, + 'fi;', + 'fi;', + 'done;', + `printf '%s\\n' 'Orca relay upload staging quota is full; reconnect after 40 minutes or inspect .orca-remote/${RELAY_UPLOAD_STAGE_POOL_NAME}' >&2;`, + 'exit 75' + ].join(' ') +} + +function posixClaimPrelude(stage: RelayUploadStageSlot): string { + return [ + `pool=${shellEscape(stage.poolDir)}; slot=${shellEscape(stage.slotDir)}; claim=${shellEscape(stage.claimDir)}; deleting=${shellEscape(stage.deleteDir)};`, + '[ -d "$pool" ] && [ ! -L "$pool" ] || exit 0;', + '[ ! -e "$claim" ] && [ ! -L "$claim" ] && [ ! -e "$deleting" ] && [ ! -L "$deleting" ] || exit 0;', + 'mv "$slot" "$claim" 2>/dev/null || exit 0;' + ].join(' ') +} + +function posixRestoreClaim(): string { + return 'if [ ! -e "$slot" ] && [ ! -L "$slot" ]; then mv "$claim" "$slot" 2>/dev/null || true; fi;' +} + +function posixOwnedDirectoryCheck( + pathVariable: string, + owner: string, + requirePayload: boolean +): string { + const path = `$${pathVariable}` + const checks = [ + `owner_file="${path}/${OWNER_FILE_NAME}";`, + `identity_file="${path}/${IDENTITY_FILE_NAME}";`, + `actual=$(cat "$owner_file" 2>/dev/null) || actual=;`, + `expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=;`, + `identity=$(ls -id "${path}" 2>/dev/null | awk '{print $1}') || identity=;`, + `[ -n "$identity" ] && [ "$identity" = "$expected_identity" ] && [ -d "${path}" ] && [ ! -L "${path}" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && [ "$actual" = ${shellEscape(owner)} ]` + ] + if (requirePayload) { + checks.push( + `&& payload="${path}/payload" && [ -d "$payload" ] && [ ! -L "$payload" ]`, + `&& payload_identity=$(ls -id "$payload" 2>/dev/null | awk '{print $1}') && [ -n "$payload_identity" ]`, + '&& links=$(find "$payload" -type l -print -quit 2>/dev/null) && [ -z "$links" ]' + ) + } + return checks.join(' ') +} + +function posixMoveOwnedClaimToDelete(owner: string): string { + return [ + '[ ! -e "$deleting" ] && [ ! -L "$deleting" ] || exit 0;', + 'mv "$claim" "$deleting" 2>/dev/null || exit 0;', + `if ${posixOwnedDirectoryCheck('deleting', owner, true)} && [ "$identity" = "$claim_identity" ]; then`, + 'rm -rf "$deleting";', + 'else', + 'if [ ! -e "$claim" ] && [ ! -L "$claim" ]; then mv "$deleting" "$claim" 2>/dev/null || true; fi;', + 'fi;' + ].join(' ') +} + +function promotePosixStageCommand( + stage: RelayUploadStageSlot, + owner: string, + destinationDir: string +): string { + const expected = shellEscape(owner) + return [ + posixClaimPrelude(stage), + `if ! { ${posixOwnedDirectoryCheck('claim', owner, true)}; }; then`, + posixRestoreClaim(), + `printf '%s%s:OWNERSHIP_LOST\\n' ${shellEscape(PROMOTION_RESULT_PREFIX)} ${expected}; exit 0;`, + 'fi;', + 'claim_identity="$identity";', + `if (cd "$payload" && [ "$(ls -id . 2>/dev/null | awk '{print $1}')" = "$payload_identity" ] && cp -a . ${shellEscape(destinationDir)}/); then`, + posixMoveOwnedClaimToDelete(owner), + `printf '%s%s:PROMOTED\\n' ${shellEscape(PROMOTION_RESULT_PREFIX)} ${expected}; exit 0;`, + 'fi;', + 'exit 1' + ].join(' ') +} + +function cleanupPosixStageCommand(stage: RelayUploadStageSlot, owner: string): string { + return [ + posixClaimPrelude(stage), + `if ${posixOwnedDirectoryCheck('claim', owner, true)}; then`, + 'claim_identity="$identity";', + posixMoveOwnedClaimToDelete(owner), + 'else', + posixRestoreClaim(), + 'fi;', + 'exit 0' + ].join(' ') +} + +function recoverPosixStageCommand(poolDir: string, staleMinutes: number): string { + const pool = shellEscape(poolDir) + const slots = Array.from({ length: RELAY_UPLOAD_STAGE_SLOT_COUNT }, (_, index) => index).join(' ') + return [ + `pool=${pool}; [ -d "$pool" ] && [ ! -L "$pool" ] || exit 0;`, + `for n in ${slots}; do`, + 'slot="$pool/slot-$n"; claim="$pool/claim-$n"; deleting="$pool/delete-$n";', + `if [ -d "$deleting" ] && [ ! -L "$deleting" ]; then owner_file="$deleting/${OWNER_FILE_NAME}"; identity_file="$deleting/${IDENTITY_FILE_NAME}"; deleting_actual=$(cat "$owner_file" 2>/dev/null) || deleting_actual=; expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=; deleting_identity=$(ls -id "$deleting" 2>/dev/null | awk '{print $1}') || deleting_identity=; deleting_old=$(find "$owner_file" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) || deleting_old=; if [ -n "$deleting_identity" ] && [ "$deleting_identity" = "$expected_identity" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && printf '%s' "$deleting_actual" | grep -Eq '^\\.sftp-namespace-[0-9a-f]{32}$' && [ -n "$deleting_old" ] && links=$(find "$deleting" -type l -print -quit 2>/dev/null) && [ -z "$links" ]; then rm -rf "$deleting"; exit 0; fi; fi;`, + 'if [ ! -e "$claim" ] && [ ! -L "$claim" ] && [ ! -e "$deleting" ] && [ ! -L "$deleting" ] && [ -d "$slot" ] && [ ! -L "$slot" ] &&', + `old=$(find "$slot/${OWNER_FILE_NAME}" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) && [ -n "$old" ]; then`, + 'mv "$slot" "$claim" 2>/dev/null || continue;', + 'fi;', + `owner_file="$claim/${OWNER_FILE_NAME}"; identity_file="$claim/${IDENTITY_FILE_NAME}"; actual=$(cat "$owner_file" 2>/dev/null) || actual=; expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=; identity=$(ls -id "$claim" 2>/dev/null | awk '{print $1}') || identity=; old=$(find "$owner_file" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) || old=;`, + `if [ -n "$identity" ] && [ "$identity" = "$expected_identity" ] && [ -d "$claim" ] && [ ! -L "$claim" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && printf '%s' "$actual" | grep -Eq '^\\.sftp-namespace-[0-9a-f]{32}$' && [ -n "$old" ]; then`, + 'claim_identity="$identity";', + '[ ! -e "$deleting" ] && [ ! -L "$deleting" ] && mv "$claim" "$deleting" 2>/dev/null || continue;', + `owner_file="$deleting/${OWNER_FILE_NAME}"; identity_file="$deleting/${IDENTITY_FILE_NAME}"; deleting_actual=$(cat "$owner_file" 2>/dev/null) || deleting_actual=; expected_identity=$(cat "$identity_file" 2>/dev/null) || expected_identity=; deleting_identity=$(ls -id "$deleting" 2>/dev/null | awk '{print $1}') || deleting_identity=; deleting_old=$(find "$owner_file" -prune -type f -mmin +${staleMinutes} -print 2>/dev/null) || deleting_old=;`, + `if [ -n "$deleting_identity" ] && [ "$deleting_identity" = "$claim_identity" ] && [ "$deleting_identity" = "$expected_identity" ] && [ -d "$deleting" ] && [ ! -L "$deleting" ] && [ -f "$owner_file" ] && [ ! -L "$owner_file" ] && [ -f "$identity_file" ] && [ ! -L "$identity_file" ] && printf '%s' "$deleting_actual" | grep -Eq '^\\.sftp-namespace-[0-9a-f]{32}$' && [ -n "$deleting_old" ] && links=$(find "$deleting" -type l -print -quit 2>/dev/null) && [ -z "$links" ]; then`, + 'rm -rf "$deleting"; exit 0;', + 'fi;', + 'if [ ! -e "$claim" ] && [ ! -L "$claim" ]; then mv "$deleting" "$claim" 2>/dev/null || true; fi;', + 'continue;', + 'fi;', + posixRestoreClaim(), + 'done;', + 'exit 0' + ].join(' ') +} diff --git a/src/main/ssh/ssh-relay-upload-stage-contract.ts b/src/main/ssh/ssh-relay-upload-stage-contract.ts new file mode 100644 index 00000000000..2fcc20c1381 --- /dev/null +++ b/src/main/ssh/ssh-relay-upload-stage-contract.ts @@ -0,0 +1,16 @@ +export const RELAY_UPLOAD_STAGE_POOL_NAME = '.upload-stages' +export const RELAY_UPLOAD_STAGE_SLOT_COUNT = 8 +export const RELAY_UPLOAD_STAGE_STALE_SECONDS = 40 * 60 + +export const RELAY_UPLOAD_OWNER_FILE_NAME = '.orca-upload-owner' +export const RELAY_UPLOAD_IDENTITY_FILE_NAME = '.orca-upload-identity' +export const RELAY_UPLOAD_SLOT_RESULT_PREFIX = '__ORCA_UPLOAD_STAGE_SLOT__' +export const RELAY_UPLOAD_PROMOTION_RESULT_PREFIX = '__ORCA_UPLOAD_STAGE_PROMOTION__' + +export type RelayUploadStageSlot = { + claimDir: string + deleteDir: string + poolDir: string + slotDir: string + slotName: string +} diff --git a/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts b/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts new file mode 100644 index 00000000000..18b490d6742 --- /dev/null +++ b/src/main/ssh/ssh-relay-upload-stage-windows-commands.ts @@ -0,0 +1,247 @@ +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + RELAY_UPLOAD_IDENTITY_FILE_NAME, + RELAY_UPLOAD_OWNER_FILE_NAME, + RELAY_UPLOAD_PROMOTION_RESULT_PREFIX, + RELAY_UPLOAD_SLOT_RESULT_PREFIX, + RELAY_UPLOAD_STAGE_POOL_NAME, + RELAY_UPLOAD_STAGE_SLOT_COUNT, + type RelayUploadStageSlot +} from './ssh-relay-upload-stage-contract' + +export function reserveWindowsRelayUploadStageCommand(poolDir: string, owner: string): string { + return powerShellCommand( + [ + "$ErrorActionPreference = 'Stop'", + ...windowsFileIdentityScript(), + `$pool = ${powerShellLiteral(poolDir)}`, + `$owner = ${powerShellLiteral(owner)}`, + '$null = New-Item -ItemType Directory -Force -Path $pool', + '$poolItem = Get-Item -LiteralPath $pool -Force -ErrorAction Stop', + 'if (-not $poolItem.PSIsContainer -or (($poolItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { throw "Unsafe relay upload stage pool" }', + `foreach ($n in 0..${RELAY_UPLOAD_STAGE_SLOT_COUNT - 1}) {`, + '$slot = Join-Path $pool ("slot-" + $n)', + '$claim = Join-Path $pool ("claim-" + $n)', + '$deleting = Join-Path $pool ("delete-" + $n)', + '$slotExisting = Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue', + '$claimExisting = Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue', + '$deleteExisting = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', + 'if (($null -ne $claimExisting) -or ($null -ne $slotExisting) -or ($null -ne $deleteExisting)) { continue }', + 'try {', + '$null = New-Item -ItemType Directory -Path $slot -ErrorAction Stop', + '$null = New-Item -ItemType Directory -Path (Join-Path $slot "payload") -ErrorAction Stop', + '$identity = & $getFileIdentity $slot', + `[System.IO.File]::WriteAllText((Join-Path $slot ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}), $owner, [System.Text.UTF8Encoding]::new($false))`, + `[System.IO.File]::WriteAllText((Join-Path $slot ${powerShellLiteral(RELAY_UPLOAD_IDENTITY_FILE_NAME)}), $identity, [System.Text.UTF8Encoding]::new($false))`, + `[System.IO.File]::WriteAllText((Join-Path $slot ${powerShellLiteral(owner)}), '', [System.Text.UTF8Encoding]::new($false))`, + `Write-Output (${powerShellLiteral(RELAY_UPLOAD_SLOT_RESULT_PREFIX)} + $owner + ':slot-' + $n)`, + 'exit 0', + '} catch { continue }', + '}', + `throw ${powerShellLiteral(`Orca relay upload staging quota is full; reconnect after 40 minutes or inspect .orca-remote/${RELAY_UPLOAD_STAGE_POOL_NAME}`)}` + ].join('\n') + ) +} + +function windowsFileIdentityScript(): string[] { + return [ + "if ($env:OS -eq 'Windows_NT') {", + "if ($null -eq ('OrcaRelayUploadFileIdentity' -as [type])) {", + "Add-Type -TypeDefinition @'", + 'using System;', + 'using System.ComponentModel;', + 'using System.Runtime.InteropServices;', + 'using Microsoft.Win32.SafeHandles;', + 'public static class OrcaRelayUploadFileIdentity {', + '[StructLayout(LayoutKind.Sequential)] struct Info { public uint Attr; public System.Runtime.InteropServices.ComTypes.FILETIME C; public System.Runtime.InteropServices.ComTypes.FILETIME A; public System.Runtime.InteropServices.ComTypes.FILETIME W; public uint Vol; public uint SizeH; public uint SizeL; public uint Links; public uint IndexH; public uint IndexL; }', + '[DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr sec, uint creation, uint flags, IntPtr template);', + '[DllImport("kernel32.dll", SetLastError=true)] static extern bool GetFileInformationByHandle(SafeFileHandle handle, out Info info);', + 'public static string Read(string path) {', + 'using (var handle = CreateFileW(path, 0, 7, IntPtr.Zero, 3, 0x02200000, IntPtr.Zero)) {', + 'if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());', + 'Info info; if (!GetFileInformationByHandle(handle, out info)) throw new Win32Exception(Marshal.GetLastWin32Error());', + 'return info.Vol.ToString("x") + ":" + info.IndexH.ToString("x") + ":" + info.IndexL.ToString("x");', + '}', + '}', + '}', + "'@", + '}', + '$getFileIdentity = { param($path) [OrcaRelayUploadFileIdentity]::Read($path) }', + '} else {', + '$getFileIdentity = { param($path) $resolved = (Get-Item -LiteralPath $path -Force -ErrorAction Stop).FullName; $value = & /usr/bin/stat -f "%i" -- $resolved 2>$null; if ($LASTEXITCODE -ne 0) { $value = & /usr/bin/stat -c "%i" -- $resolved }; ([string]$value).Trim() }', + '}' + ] +} + +function windowsClaimPrelude(stage: RelayUploadStageSlot): string[] { + return [ + `$slot = ${powerShellLiteral(stage.slotDir)}`, + `$claim = ${powerShellLiteral(stage.claimDir)}`, + `$deleting = ${powerShellLiteral(stage.deleteDir)}`, + `$pool = ${powerShellLiteral(stage.poolDir)}`, + '$poolItem = Get-Item -LiteralPath $pool -Force -ErrorAction SilentlyContinue', + 'if (($null -eq $poolItem) -or -not $poolItem.PSIsContainer -or (($poolItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { exit 0 }', + '$claimBefore = Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue', + '$deleteBefore = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', + '$slotBefore = Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue', + 'if (($null -ne $claimBefore) -or ($null -ne $deleteBefore) -or ($null -eq $slotBefore)) { exit 0 }', + 'Move-Item -LiteralPath $slot -Destination $claim -ErrorAction Stop' + ] +} + +function windowsOwnershipScript( + owner: string, + requirePayload: boolean, + pathExpression = '$claim' +): string[] { + return [ + `$expectedOwner = ${powerShellLiteral(owner)}`, + `$ownedPath = ${pathExpression}`, + `$ownerPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}`, + `$identityPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_IDENTITY_FILE_NAME)}`, + '$claimItem = Get-Item -LiteralPath $ownedPath -Force -ErrorAction SilentlyContinue', + '$ownerItem = Get-Item -LiteralPath $ownerPath -Force -ErrorAction SilentlyContinue', + '$identityItem = Get-Item -LiteralPath $identityPath -Force -ErrorAction SilentlyContinue', + '$actualOwner = if ($null -ne $ownerItem) { [System.IO.File]::ReadAllText($ownerPath).Trim() } else { "" }', + '$expectedIdentity = if ($null -ne $identityItem) { [System.IO.File]::ReadAllText($identityPath).Trim() } else { "" }', + '$actualIdentity = if ($null -ne $claimItem) { & $getFileIdentity $ownedPath } else { "" }', + '$owned = ($null -ne $claimItem) -and $claimItem.PSIsContainer -and (($claimItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $ownerItem) -and -not $ownerItem.PSIsContainer -and (($ownerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $identityItem) -and -not $identityItem.PSIsContainer -and (($identityItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($actualOwner -ceq $expectedOwner) -and ($actualIdentity -ceq $expectedIdentity)', + ...(requirePayload + ? [ + '$payload = Join-Path $ownedPath "payload"', + '$payloadItem = Get-Item -LiteralPath $payload -Force -ErrorAction SilentlyContinue', + '$reparse = $null', + 'if (($null -ne $payloadItem) -and $payloadItem.PSIsContainer -and (($payloadItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0)) {', + '$reparse = Get-ChildItem -LiteralPath $payload -Force -Recurse -ErrorAction Stop | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1', + '}', + '$owned = $owned -and ($null -ne $payloadItem) -and $payloadItem.PSIsContainer -and (($payloadItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -eq $reparse)' + ] + : []) + ] +} + +function windowsRestoreClaim(): string { + return 'if (($null -eq (Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue)) -and ($null -ne (Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue))) { Move-Item -LiteralPath $claim -Destination $slot -ErrorAction SilentlyContinue }' +} + +function windowsStaleOwnershipScript(pathExpression: string): string[] { + return [ + `$ownedPath = ${pathExpression}`, + `$ownerPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}`, + `$identityPath = Join-Path $ownedPath ${powerShellLiteral(RELAY_UPLOAD_IDENTITY_FILE_NAME)}`, + '$ownedItem = Get-Item -LiteralPath $ownedPath -Force -ErrorAction SilentlyContinue', + '$ownerItem = Get-Item -LiteralPath $ownerPath -Force -ErrorAction SilentlyContinue', + '$identityItem = Get-Item -LiteralPath $identityPath -Force -ErrorAction SilentlyContinue', + '$actualOwner = if ($null -ne $ownerItem) { [System.IO.File]::ReadAllText($ownerPath).Trim() } else { "" }', + '$expectedIdentity = if ($null -ne $identityItem) { [System.IO.File]::ReadAllText($identityPath).Trim() } else { "" }', + '$actualIdentity = if ($null -ne $ownedItem) { & $getFileIdentity $ownedPath } else { "" }', + "$owned = ($null -ne $ownedItem) -and $ownedItem.PSIsContainer -and (($ownedItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $ownerItem) -and -not $ownerItem.PSIsContainer -and (($ownerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $identityItem) -and -not $identityItem.PSIsContainer -and (($identityItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($actualIdentity -ceq $expectedIdentity) -and ($ownerItem.LastWriteTimeUtc -lt $cutoff) -and ($actualOwner -match '^\\.sftp-namespace-[0-9a-f]{32}$')", + '$reparse = $null', + 'if ($owned) {', + '$reparse = Get-ChildItem -LiteralPath $ownedPath -Force -Recurse -ErrorAction Stop | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 } | Select-Object -First 1', + '}', + '$owned = $owned -and ($null -eq $reparse)' + ] +} + +function windowsDeleteOwnedClaimScript(owner: string): string[] { + return [ + '$deleteBefore = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', + 'if ($null -ne $deleteBefore) { exit 0 }', + 'Move-Item -LiteralPath $claim -Destination $deleting -ErrorAction Stop', + ...windowsOwnershipScript(owner, true, '$deleting'), + 'if ($owned) {', + 'Remove-Item -LiteralPath $deleting -Recurse -Force -ErrorAction Stop', + '} elseif (($null -eq (Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue)) -and ($null -ne (Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue))) {', + 'Move-Item -LiteralPath $deleting -Destination $claim -ErrorAction SilentlyContinue', + '}' + ] +} + +export function promoteWindowsRelayUploadStageCommand( + stage: RelayUploadStageSlot, + owner: string, + destinationDir: string +): string { + return powerShellCommand( + [ + "$ErrorActionPreference = 'Stop'", + ...windowsFileIdentityScript(), + ...windowsClaimPrelude(stage), + ...windowsOwnershipScript(owner, true), + 'if (-not $owned) {', + windowsRestoreClaim(), + `Write-Output (${powerShellLiteral(RELAY_UPLOAD_PROMOTION_RESULT_PREFIX)} + $expectedOwner + ':OWNERSHIP_LOST')`, + 'exit 0', + '}', + `Get-ChildItem -LiteralPath $payload -Force -ErrorAction Stop | Copy-Item -Destination ${powerShellLiteral(destinationDir)} -Recurse -Force -ErrorAction Stop`, + ...windowsDeleteOwnedClaimScript(owner), + `Write-Output (${powerShellLiteral(RELAY_UPLOAD_PROMOTION_RESULT_PREFIX)} + $expectedOwner + ':PROMOTED')` + ].join('\n') + ) +} + +export function cleanupWindowsRelayUploadStageCommand( + stage: RelayUploadStageSlot, + owner: string +): string { + return powerShellCommand( + [ + "$ErrorActionPreference = 'Stop'", + ...windowsFileIdentityScript(), + ...windowsClaimPrelude(stage), + ...windowsOwnershipScript(owner, true), + 'if ($owned) {', + ...windowsDeleteOwnedClaimScript(owner), + '} else {', + windowsRestoreClaim(), + '}' + ].join('\n') + ) +} + +export function recoverWindowsRelayUploadStageCommand( + poolDir: string, + staleSeconds: number +): string { + return powerShellCommand( + [ + "$ErrorActionPreference = 'Stop'", + ...windowsFileIdentityScript(), + `$pool = ${powerShellLiteral(poolDir)}`, + `$cutoff = [DateTime]::UtcNow.AddSeconds(-${staleSeconds})`, + '$poolItem = Get-Item -LiteralPath $pool -Force -ErrorAction SilentlyContinue', + 'if (($null -eq $poolItem) -or -not $poolItem.PSIsContainer -or (($poolItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { exit 0 }', + `foreach ($n in 0..${RELAY_UPLOAD_STAGE_SLOT_COUNT - 1}) {`, + '$slot = Join-Path $pool ("slot-" + $n)', + '$claim = Join-Path $pool ("claim-" + $n)', + '$deleting = Join-Path $pool ("delete-" + $n)', + ...windowsStaleOwnershipScript('$deleting'), + 'if ($owned) {', + 'Remove-Item -LiteralPath $deleting -Recurse -Force -ErrorAction Stop', + 'exit 0', + '}', + `$slotOwner = Join-Path $slot ${powerShellLiteral(RELAY_UPLOAD_OWNER_FILE_NAME)}`, + '$slotItem = Get-Item -LiteralPath $slot -Force -ErrorAction SilentlyContinue', + '$slotOwnerItem = Get-Item -LiteralPath $slotOwner -Force -ErrorAction SilentlyContinue', + '$claimItem = Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue', + '$deleteItem = Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue', + 'if (($null -eq $claimItem) -and ($null -eq $deleteItem) -and ($null -ne $slotItem) -and $slotItem.PSIsContainer -and (($slotItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and ($null -ne $slotOwnerItem) -and -not $slotOwnerItem.PSIsContainer -and (($slotOwnerItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) -and $slotOwnerItem.LastWriteTimeUtc -lt $cutoff) {', + 'Move-Item -LiteralPath $slot -Destination $claim -ErrorAction SilentlyContinue', + '}', + ...windowsStaleOwnershipScript('$claim'), + 'if ($owned -and ($null -eq (Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue))) {', + 'Move-Item -LiteralPath $claim -Destination $deleting -ErrorAction Stop', + ...windowsStaleOwnershipScript('$deleting'), + 'if (-not $owned) {', + 'if (($null -eq (Get-Item -LiteralPath $claim -Force -ErrorAction SilentlyContinue)) -and ($null -ne (Get-Item -LiteralPath $deleting -Force -ErrorAction SilentlyContinue))) { Move-Item -LiteralPath $deleting -Destination $claim -ErrorAction SilentlyContinue }', + 'continue', + '}', + 'Remove-Item -LiteralPath $deleting -Recurse -Force -ErrorAction Stop', + 'exit 0', + '}', + windowsRestoreClaim(), + '}' + ].join('\n') + ) +} diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index 062444a9d01..e4380c319c3 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -20,6 +20,7 @@ import { import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone' import { listRelayBaseDirsCommand, + MAX_RELAY_GC_LISTING_ENTRIES, moveRemoteTreeCommand, probeFileExistsCommand, probeRelayInstalledCommand, @@ -196,6 +197,7 @@ export async function gcOldRelayVersions( .split('\n') .map((s) => s.trim()) .filter(Boolean) + .slice(0, MAX_RELAY_GC_LISTING_ENTRIES) await cleanupRelayGcTombstones(conn, baseDir, entries, host) diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 594ed2864d5..13a1e2e246a 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -20,8 +20,10 @@ import { commandInRemoteDirectory, commandWithNodePath, listRelayBaseDirsCommand, + MAX_RELAY_GC_LISTING_ENTRIES, makeRemoteDirectoryCommand, moveRemoteTreeCommand, + promoteRemoteTreeContentsCommand, probeDirectoryExistsCommand, probeRelayInstalledCommand, readRemoteHomeCommand, @@ -31,9 +33,13 @@ import { getRemoteHostPlatform } from './ssh-remote-platform' const posix = getRemoteHostPlatform('linux-x64') const windows = getRemoteHostPlatform('win32-x64') -const powerShellExecutable = ( - process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh'] -).find((candidate) => { +const powerShellExecutable = [ + process.env.ORCA_POWERSHELL_EXECUTABLE, + ...(process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh']) +].find((candidate) => { + if (!candidate) { + return false + } const result = spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { stdio: 'ignore' }) @@ -151,6 +157,29 @@ describe('ssh remote command builders', () => { expect(windowsScript).toContain("'MOVED'") }) + it('enumerates Windows staging children before copying', () => { + const script = decodePowerShellCommand( + promoteRemoteTreeContentsCommand(windows, 'C:/Users/me/relay.upload-123', 'C:/Users/me/relay') + ) + expect(script).toContain('Get-ChildItem -LiteralPath') + expect(script).toContain(' -Force -ErrorAction Stop | Copy-Item -Destination') + expect(script).not.toContain('Copy-Item -LiteralPath') + expect(script).toContain('Remove-Item -LiteralPath') + expect(script).toContain("$ErrorActionPreference = 'Stop'") + expect(script).toContain('Copy-Item -Destination') + }) + + it('removes POSIX staging only after the copy succeeds', () => { + const command = promoteRemoteTreeContentsCommand( + posix, + '/home/u/relay.upload-123', + '/home/u/relay' + ) + expect(command).toContain("cp -a '/home/u/relay.upload-123'/. '/home/u/relay'/") + expect(command).toContain("&& rm -rf '/home/u/relay.upload-123'") + expect(command.indexOf('cp -a')).toBeLessThan(command.indexOf('rm -rf')) + }) + it('emits an explicit POSIX liveness result so GC can fail closed', () => { const command = relayLivenessProbeCommand(posix, '/home/u/.orca-remote/relay-0.1.0') @@ -177,6 +206,36 @@ describe('ssh remote command builders', () => { ) }) + it('bounds real POSIX GC output with more than the exec-cap stage population', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-relay-gc-scale-')) + try { + for (let index = 0; index < 15_197; index += 1) { + mkdirSync(join(root, `relay-0.1.0+abc.upload-${String(index).padStart(12, '0')}`)) + } + mkdirSync(join(root, 'relay-0.1.0+aaa')) + mkdirSync(join(root, 'relay-0.1.0+bbb')) + + const output = await runShellCommand(listRelayBaseDirsCommand(posix, root)) + const entries = output.trim().split('\n') + + expect(entries).toEqual(['relay-0.1.0+aaa', 'relay-0.1.0+bbb']) + expect(Buffer.byteLength(output)).toBeLessThan(1_024) + expect(entries.length).toBeLessThanOrEqual(MAX_RELAY_GC_LISTING_ENTRIES) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, 30_000) + + it('fails closed when real POSIX GC enumeration fails', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-relay-gc-failure-')) + try { + const command = `find() { return 23; }\n${listRelayBaseDirsCommand(posix, root)}` + await expect(runShellCommand(command)).rejects.toThrow('shell exited 1') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('escapes double quotes before passing JavaScript to native Windows commands', () => { const script = decodePowerShellCommand( relayLivenessProbeCommand(windows, 'C:/Users/me/.orca-remote/relay-0.1.0', { diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 155fb5ba66f..7cec0d027c2 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -63,6 +63,19 @@ export function moveRemoteTreeCommand( ) } +export function promoteRemoteTreeContentsCommand( + host: RemoteHostPlatform, + sourcePath: string, + destinationPath: string +): string { + if (!isWindowsRemoteHost(host)) { + return `cp -a ${shellEscape(sourcePath)}/. ${shellEscape(destinationPath)}/ && rm -rf ${shellEscape(sourcePath)}` + } + return powerShellCommand( + `$ErrorActionPreference = 'Stop'; Get-ChildItem -LiteralPath ${powerShellLiteral(sourcePath)} -Force -ErrorAction Stop | Copy-Item -Destination ${powerShellLiteral(destinationPath)} -Recurse -Force -ErrorAction Stop; Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Recurse -Force -ErrorAction Stop` + ) +} + export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath: string): string { if (!isWindowsRemoteHost(host)) { return `touch ${shellEscape(remotePath)}` @@ -102,17 +115,26 @@ export function probeRelayInstalledCommand( ) } +export const MAX_RELAY_GC_LISTING_ENTRIES = 64 + export function listRelayBaseDirsCommand(host: RemoteHostPlatform, baseDir: string): string { if (!isWindowsRemoteHost(host)) { - return `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true` + const statusPrefix = '__ORCA_RELAY_GC_FIND_STATUS__' + return [ + `base=${shellEscape(baseDir)}; [ -d "$base" ] || exit 0;`, + `{ find "$base" -mindepth 1 -maxdepth 1 -type d -name 'relay-*' -print; status=$?; printf '\n${statusPrefix}%s\n' "$status"; } |`, + String.raw`awk 'BEGIN { count=0; status=-1 } /^${statusPrefix}[0-9]+$/ { status=substr($0, ${statusPrefix.length + 1}); next } { name=$0; sub(/^.*\//, "", name); if (name ~ /^relay-(v?[0-9]+\.[0-9]+\.[0-9]+(\+[0-9a-f]+)?)(\.gc-tombstone\.[0-9]+\.[0-9]+)?$/ && count < ${MAX_RELAY_GC_LISTING_ENTRIES}) { entries[count++]=name } } END { if (status != 0) exit 1; for (i=0; i { ) }) + it('reconciles retained Codex homes after authoritative daemon inventory', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const daemonInitIndex = source.indexOf('await initDaemonPtyProvider(signal') + const routeGateIndex = source.indexOf( + 'codexRuntimeHome?.isHostSystemDefaultRealHome()', + daemonInitIndex + ) + const inventoryIndex = source.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) + const reconciliation = 'codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' + const reconciliationIndex = source.indexOf(reconciliation, inventoryIndex) + const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex) + const desktopIndex = source.indexOf('Promise.resolve(openMainWindow())', serveIndex) + + expect(daemonInitIndex).toBeGreaterThanOrEqual(0) + expect(routeGateIndex).toBeGreaterThan(daemonInitIndex) + expect(inventoryIndex).toBeGreaterThan(routeGateIndex) + expect(reconciliationIndex).toBeGreaterThan(inventoryIndex) + expect(serveIndex).toBeGreaterThan(reconciliationIndex) + expect(desktopIndex).toBeGreaterThan(serveIndex) + expect(source.split(reconciliation)).toHaveLength(2) + }) + it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') diff --git a/src/main/updater-release-builds.test.ts b/src/main/updater-release-builds.test.ts index 2ab08ae62cb..b9344954a5a 100644 --- a/src/main/updater-release-builds.test.ts +++ b/src/main/updater-release-builds.test.ts @@ -80,6 +80,29 @@ describe('listReleaseBuilds', () => { expect(builds.map((build) => build.version)).toEqual(['1.4.159']) }) + // Why: the hourly workflow composes the release title and the picker renders it + // verbatim, so the two can never drift. A title that only repeats the tag says + // nothing the version beside it does not, and must not become a picker row + // reading "v1.4.163-hourly.202607311933". + it('keeps a composed release title and drops one that repeats the tag', async () => { + fetchMock.mockResolvedValue( + jsonResponse([ + release('v1.4.163-hourly.202607312054', { name: '1.4.163 • 01 • 07-31 13:54 • e698241' }), + release('v1.4.163-hourly.202607311933', { name: 'v1.4.163-hourly.202607311933' }), + release('v1.4.163-hourly.202607311835', { name: ' ' }), + release('v1.4.163-hourly.202607311735', { name: 42 }) + ]) + ) + + const builds = await listReleaseBuilds('hourly') + expect(builds.map((build) => build.name)).toEqual([ + '1.4.163 • 01 • 07-31 13:54 • e698241', + null, + null, + null + ]) + }) + it('surfaces a rate limit as an actionable message', async () => { fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 403 })) await expect(listReleaseBuilds('hourly')).rejects.toThrow(/rate limit/i) diff --git a/src/main/updater-release-builds.ts b/src/main/updater-release-builds.ts index a7ff6089baf..f4f694facf4 100644 --- a/src/main/updater-release-builds.ts +++ b/src/main/updater-release-builds.ts @@ -22,6 +22,7 @@ export function getReleaseDownloadUrlForRepo(repo: string, tag: string): string type GitHubReleaseEntry = { tag_name?: unknown + name?: unknown draft?: unknown published_at?: unknown html_url?: unknown @@ -37,10 +38,15 @@ function parseReleaseEntry(entry: GitHubReleaseEntry, repo: string): ReleaseBuil if (!isValidVersion(version) || !channel) { return null } + // Why null when it merely repeats the tag: GitHub titles an untitled release + // with its tag name, and hourlies predating the naming change were created that + // way too. Neither says anything the version beside it does not. + const name = typeof entry.name === 'string' ? entry.name.trim() : '' return { tag, version, channel, + name: name && name !== tag ? name : null, publishedAt: typeof entry.published_at === 'string' ? entry.published_at : null, releaseUrl: typeof entry.html_url === 'string' diff --git a/src/main/window/renderer-publication-throttle.test.ts b/src/main/window/renderer-publication-throttle.test.ts new file mode 100644 index 00000000000..5715ef25ae1 --- /dev/null +++ b/src/main/window/renderer-publication-throttle.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + RendererPublicationThrottle, + type RendererPublicationThrottleTarget +} from './renderer-publication-throttle' + +function createTarget(): RendererPublicationThrottleTarget & { + calls: boolean[] + destroyed: boolean +} { + const calls: boolean[] = [] + return { + calls, + destroyed: false, + isDestroyed() { + return this.destroyed + }, + setBackgroundThrottling(allowed) { + calls.push(allowed) + } + } +} + +describe('RendererPublicationThrottle', () => { + it('unthrottles only while a publication lease is active', () => { + const target = createTarget() + const throttle = new RendererPublicationThrottle() + + const release = throttle.acquire(target) + release() + + expect(target.calls).toEqual([false, true]) + }) + + it('keeps overlapping publications unthrottled until the last release', () => { + const target = createTarget() + const throttle = new RendererPublicationThrottle() + + const releaseFirst = throttle.acquire(target) + const releaseSecond = throttle.acquire(target) + releaseFirst() + releaseSecond() + releaseSecond() + + expect(target.calls).toEqual([false, true]) + }) + + it('does not restore throttling on a destroyed renderer', () => { + const target = createTarget() + const throttle = new RendererPublicationThrottle() + + const release = throttle.acquire(target) + target.destroyed = true + release() + + expect(target.calls).toEqual([false]) + }) +}) diff --git a/src/main/window/renderer-publication-throttle.ts b/src/main/window/renderer-publication-throttle.ts new file mode 100644 index 00000000000..c7a63d74597 --- /dev/null +++ b/src/main/window/renderer-publication-throttle.ts @@ -0,0 +1,32 @@ +export type RendererPublicationThrottleTarget = { + isDestroyed?: () => boolean + setBackgroundThrottling: (allowed: boolean) => void +} + +export class RendererPublicationThrottle { + private readonly leasesByTarget = new Map() + + acquire(target: RendererPublicationThrottleTarget): () => void { + const leaseCount = this.leasesByTarget.get(target) ?? 0 + if (leaseCount === 0) { + target.setBackgroundThrottling(false) + } + this.leasesByTarget.set(target, leaseCount + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const remaining = (this.leasesByTarget.get(target) ?? 1) - 1 + if (remaining > 0) { + this.leasesByTarget.set(target, remaining) + return + } + this.leasesByTarget.delete(target) + if (target.isDestroyed?.() !== true) { + target.setBackgroundThrottling(true) + } + } + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index cdb5000d6cc..35c58ab5a71 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2379,10 +2379,14 @@ export type PreloadApi = { wslDistro?: string | null }) => Promise /** Live PTYs whose baked CODEX_HOME still points at a deselected account. */ - listStalePanes: (args: { - ptyIds: string[] - }) => Promise< - { ptyId: string; launchAccountId: string | null; activeAccountId: string | null }[] + listStalePanes: (args: { ptyIds: string[] }) => Promise< + { + ptyId: string + launchAccountId: string | null + activeAccountId: string | null + /** Optional for compatibility with a pre-reason main process. */ + reason?: 'account-change' | 'home-route-change' + }[] > /** The selection lane each PTY launched from, keyed by pty id; unrecorded panes are absent. */ listRecordedPaneLanes: (args: { ptyIds: string[] }) => Promise> diff --git a/src/preload/index.ts b/src/preload/index.ts index 9da862f485b..3862d973ff5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2046,7 +2046,12 @@ const api = { listStalePanes: (args: { ptyIds: string[] }): Promise< - { ptyId: string; launchAccountId: string | null; activeAccountId: string | null }[] + { + ptyId: string + launchAccountId: string | null + activeAccountId: string | null + reason?: 'account-change' | 'home-route-change' + }[] > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => ipcRenderer.invoke('codexAccounts:listRecordedPaneLanes', args), diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index 92f19f41144..4df9c54a9ed 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -73,6 +73,7 @@ function isInsideHiddenTree(element: HTMLElement): boolean { type RestartNotice = { previousAccountLabel: string nextAccountLabel: string + homeRouteChanged?: true } export default function CodexRestartChip({ @@ -195,27 +196,41 @@ function LoudRestartOverlay({
- {translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')} + {restartNotice.homeRouteChanged + ? translate('auto.components.CodexRestartChip.8f0d5c92a1', 'Codex setup changed') + : translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')}
- {translate( - 'auto.components.CodexRestartChip.a4c8e1b2f7', - 'Codex is still signed in as {{value0}}', - { value0: restartNotice.previousAccountLabel } - )} + {restartNotice.homeRouteChanged + ? translate( + 'auto.components.CodexRestartChip.3ea91b5c07', + 'This Codex session is using an outdated configuration' + ) + : translate( + 'auto.components.CodexRestartChip.a4c8e1b2f7', + 'Codex is still signed in as {{value0}}', + { value0: restartNotice.previousAccountLabel } + )}
- {translate( - 'auto.components.CodexRestartChip.9375620cc3', - 'Restart this session to use {{value0}}. It stays on the previous account until you do.', - { value0: restartNotice.nextAccountLabel } - )} + {restartNotice.homeRouteChanged + ? translate( + 'auto.components.CodexRestartChip.e6b7139d2a', + 'Restart this session to load your current Codex configuration.' + ) + : translate( + 'auto.components.CodexRestartChip.9375620cc3', + 'Restart this session to use {{value0}}. It stays on the previous account until you do.', + { value0: restartNotice.nextAccountLabel } + )}
) : null}
@@ -393,29 +365,20 @@ export default function FeatureTipsModal(): JSX.Element | null { ) } - return ( - - - - - - {currentTip.title} - - - {currentTip.description} - - + if (currentTip.action !== 'enable-voice') { + currentTip.action satisfies never + return null + } - - void handlePrimaryAction()} - onSkip={handleSkip} - showSkip - /> - - - + return ( + void handlePrimaryAction()} + onSkip={handleSkip} + onVoiceSettingsClick={openVoiceSettings} + /> ) } diff --git a/src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.test.tsx b/src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.test.tsx new file mode 100644 index 00000000000..5e73fdfe889 --- /dev/null +++ b/src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { VoiceDictationFeatureTipVisual } from './VoiceDictationFeatureTipVisual' + +const prefersReducedMotionMock = vi.hoisted(() => vi.fn(() => false)) +const shortcutMock = vi.hoisted(() => vi.fn(() => ({ keys: ['⌘', 'E'], doubleTap: false }))) + +vi.mock('@/components/feature-wall/feature-wall-modal-helpers', () => ({ + usePrefersReducedMotion: prefersReducedMotionMock +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useShortcutKeyDetails: shortcutMock +})) + +async function renderVisual(): Promise<{ container: HTMLDivElement; root: Root }> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => root.render()) + return { container, root } +} + +describe('VoiceDictationFeatureTipVisual', () => { + beforeEach(() => { + prefersReducedMotionMock.mockReturnValue(false) + shortcutMock.mockReturnValue({ keys: ['⌘', 'E'], doubleTap: false }) + }) + + afterEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + document.body.innerHTML = '' + }) + + it('types the dictated prompt once and settles on the complete sentence', async () => { + vi.useFakeTimers() + const { container, root } = await renderVisual() + const prompt = container.querySelector('[data-testid="dictated-prompt"]') + + expect(prompt?.textContent).toBe('') + await act(async () => vi.advanceTimersByTime(650)) + expect(prompt?.textContent).toBe('R') + await act(async () => vi.runAllTimers()) + expect(prompt?.textContent).toBe( + 'Review this diff for edge cases and add tests for anything you find.' + ) + expect(vi.getTimerCount()).toBe(0) + + await act(async () => root.unmount()) + }) + + it('gives the prompt the full remaining row width', () => { + const html = renderToStaticMarkup() + const promptClass = html.match(/data-testid="dictated-prompt" class="([^"]+)"/)?.[1] + + expect(promptClass).toContain('min-w-0') + expect(promptClass).toContain('flex-1') + expect(promptClass).not.toContain('max-w-') + }) + + it('renders the completed prompt without timers for reduced motion', async () => { + prefersReducedMotionMock.mockReturnValue(true) + const setTimeoutSpy = vi.spyOn(window, 'setTimeout') + const { container, root } = await renderVisual() + + expect(container.textContent).toContain( + 'Review this diff for edge cases and add tests for anything you find.' + ) + expect(container.innerHTML).not.toContain('animate-waveform') + expect(setTimeoutSpy).not.toHaveBeenCalled() + + await act(async () => root.unmount()) + }) + + it('does not show a shortcut that the user has unassigned', () => { + shortcutMock.mockReturnValue({ keys: [], doubleTap: false }) + + const html = renderToStaticMarkup() + + expect(html).not.toContain('Start dictation') + expect(html).not.toContain('Ctrl') + }) +}) diff --git a/src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.tsx b/src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.tsx new file mode 100644 index 00000000000..ff94ed96fd3 --- /dev/null +++ b/src/renderer/src/components/feature-tips/VoiceDictationFeatureTipVisual.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState, type JSX } from 'react' +import { Mic, Square } from 'lucide-react' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { usePrefersReducedMotion } from '@/components/feature-wall/feature-wall-modal-helpers' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { translate } from '@/i18n/i18n' + +const TYPE_START_DELAY_MS = 650 +const TYPE_INTERVAL_MS = 36 +const WAVEFORM_BAR_HEIGHTS = [38, 70, 100, 58, 82] + +export function VoiceDictationFeatureTipVisual(): JSX.Element { + const reducedMotion = usePrefersReducedMotion() + const shortcut = useShortcutKeyDetails('voice.dictation') + const dictatedPrompt = translate( + 'featureTips.voice.demoPrompt', + 'Review this diff for edge cases and add tests for anything you find.' + ) + const [typedLength, setTypedLength] = useState(0) + const effectiveTypedLength = reducedMotion ? dictatedPrompt.length : typedLength + const typing = !reducedMotion && effectiveTypedLength < dictatedPrompt.length + + useEffect(() => { + if (reducedMotion) { + return + } + + let cancelled = false + let timeoutId: number | undefined + let nextLength = 0 + const typeNext = (): void => { + if (cancelled) { + return + } + nextLength += 1 + setTypedLength(nextLength) + if (nextLength < dictatedPrompt.length) { + timeoutId = window.setTimeout(typeNext, TYPE_INTERVAL_MS) + } + } + + timeoutId = window.setTimeout(typeNext, TYPE_START_DELAY_MS) + return () => { + cancelled = true + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId) + } + } + }, [dictatedPrompt, reducedMotion]) + + return ( + + ) +} diff --git a/src/renderer/src/components/feature-tips/VoiceDictationTipDialog.test.tsx b/src/renderer/src/components/feature-tips/VoiceDictationTipDialog.test.tsx new file mode 100644 index 00000000000..d366b056b6e --- /dev/null +++ b/src/renderer/src/components/feature-tips/VoiceDictationTipDialog.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode, type RefObject } from 'react' +import { createRoot } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FEATURE_TIPS, type FeatureTip } from '../../../../shared/feature-tips' +import { VoiceDictationTipDialog } from './VoiceDictationTipDialog' + +const shortcutMock = vi.hoisted(() => vi.fn(() => ({ keys: ['⌘', 'E'], doubleTap: false }))) +const dialogContentPropsMock = vi.hoisted(() => vi.fn()) +const featureTipActionsPropsMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/hooks/useShortcutLabel', () => ({ + useShortcutKeyDetails: shortcutMock +})) + +vi.mock('./VoiceDictationFeatureTipVisual', () => ({ + VoiceDictationFeatureTipVisual: () =>
+})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: ReactNode }) =>
{children}
, + DialogContent: ({ + children, + ...props + }: { + children: ReactNode + onOpenAutoFocus?: (event: Event) => void + }) => { + dialogContentPropsMock(props) + return
{children}
+ }, + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

+})) + +vi.mock('./FeatureTipActions', () => ({ + FeatureTipActions: (props: { primaryButtonRef?: RefObject }) => { + featureTipActionsPropsMock(props) + return + } +})) + +function getVoiceTip(): FeatureTip { + const tip = FEATURE_TIPS.find((entry) => entry.id === 'voice-dictation') + if (!tip) { + throw new Error('Expected voice-dictation feature tip fixture') + } + return { ...tip } +} + +function renderDialog(): string { + return renderToStaticMarkup( + + ) +} + +describe('VoiceDictationTipDialog', () => { + beforeEach(() => { + shortcutMock.mockReturnValue({ keys: ['⌘', 'E'], doubleTap: false }) + dialogContentPropsMock.mockClear() + featureTipActionsPropsMock.mockClear() + }) + + it('uses the established feature-tip layout and durable copy', () => { + const html = renderDialog() + + expect(html).toContain('TIP') + expect(html).toContain('Dictate into any pane') + expect(html).not.toContain('Turn speech into text wherever') + expect(html).not.toContain('is here') + }) + + it('shows the live dictation shortcut and voice settings path', () => { + const html = renderDialog() + + expect(html).toContain('⌘') + expect(html).toContain('E') + expect(html).toContain('to start voice dictation. Press') + expect(html).toContain('again to stop.') + expect(html).toContain('Settings → Voice') + }) + + it('uses generic instructions instead of a shortcut the user has unassigned', () => { + shortcutMock.mockReturnValue({ keys: [], doubleTap: false }) + + const html = renderDialog() + + expect(html).toContain( + 'Assign a dictation shortcut before starting voice dictation in a focused pane.' + ) + expect(html).not.toContain('to start voice dictation. Press') + expect(html).not.toContain('Ctrl') + }) + + it('moves initial focus to the primary action', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + await act(async () => { + root.render( + + ) + }) + const event = { preventDefault: vi.fn() } as unknown as Event + + dialogContentPropsMock.mock.lastCall?.[0].onOpenAutoFocus(event) + + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(document.activeElement?.textContent).toBe('Primary action') + + await act(async () => root.unmount()) + container.remove() + }) +}) diff --git a/src/renderer/src/components/feature-tips/VoiceDictationTipDialog.tsx b/src/renderer/src/components/feature-tips/VoiceDictationTipDialog.tsx new file mode 100644 index 00000000000..5cdb98445f4 --- /dev/null +++ b/src/renderer/src/components/feature-tips/VoiceDictationTipDialog.tsx @@ -0,0 +1,132 @@ +import { useRef, type JSX } from 'react' +import type { FeatureTip } from '../../../../shared/feature-tips' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel' +import { translate } from '@/i18n/i18n' +import { FeatureTipActions } from './FeatureTipActions' +import { VoiceDictationFeatureTipVisual } from './VoiceDictationFeatureTipVisual' + +export function VoiceDictationTipDialog({ + open, + tip, + primaryBusy, + onOpenChange, + onPrimaryAction, + onSkip, + onVoiceSettingsClick +}: { + open: boolean + tip: FeatureTip + primaryBusy: boolean + onOpenChange: (open: boolean) => void + onPrimaryAction: () => void + onSkip: () => void + onVoiceSettingsClick: () => void +}): JSX.Element { + const shortcut = useShortcutKeyDetails('voice.dictation') + const primaryButtonRef = useRef(null) + + return ( + + { + event.preventDefault() + primaryButtonRef.current?.focus() + }} + > +
+ +
+ + {tip.eyebrow.toUpperCase()} + + + {tip.title} + + + {shortcut.keys.length > 0 ? ( + + {translate( + 'featureTips.voice.focusPaneInstruction', + 'Focus a terminal, editor, or agent prompt, then press' + )}{' '} + {' '} + {translate( + 'featureTips.voice.startInstruction', + 'to start voice dictation. Press' + )}{' '} + {' '} + {translate('featureTips.voice.stopInstruction', 'again to stop.')} + + ) : ( + + {translate( + 'featureTips.voice.unassignedInstruction', + 'Assign a dictation shortcut before starting voice dictation in a focused pane.' + )} + + )} + + {translate( + 'featureTips.voice.settingsInstruction', + 'Change the model, dictation mode, or shortcut anytime in' + )}{' '} + + . + + +
+
+ + + + +
+ +
+
+ +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx index 187e161eccc..6925ac3e746 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalOrchestrationDialog.freshness.test.tsx @@ -37,6 +37,7 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ vi.mock('@/components/settings/CliSkillRuntimeSetup', () => ({ buildSkillCommandForRuntime: (command: string) => command, + buildSkillSetupTerminalCommand: (command: string) => command, ensureWslCliAvailableForAgentSkillTerminal: vi.fn(), getWslCliDistroRequest: () => undefined })) diff --git a/src/renderer/src/components/landing-preflight-runtime-boundary.test.ts b/src/renderer/src/components/landing-preflight-runtime-boundary.test.ts new file mode 100644 index 00000000000..a52c516f161 --- /dev/null +++ b/src/renderer/src/components/landing-preflight-runtime-boundary.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PreflightStatus } from '../../../preload/api-types' +import type { Repo } from '../../../shared/types' +import { useAppStore } from '../store' +import { useLandingPreflightRuntime } from './landing-preflight-runtime' + +const refresh = vi.fn().mockResolvedValue(undefined) +const invalidate = vi.fn() + +const status = (overrides: Partial = {}): PreflightStatus => ({ + git: { installed: true }, + gh: { installed: false, authenticated: false }, + ...overrides +}) + +const githubRepo: Repo = { + id: 'github', + path: '/repos/github', + displayName: 'github', + badgeColor: '#000000', + addedAt: 0, + kind: 'git', + upstream: { owner: 'orca', repo: 'orca' } +} + +beforeEach(() => { + vi.useFakeTimers() + refresh.mockClear() + invalidate.mockClear() + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ refreshPreflightStatus: refresh, invalidatePreflightStatus: invalidate }) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + useAppStore.setState(useAppStore.getInitialState(), true) +}) + +describe('landing preflight runtime boundary', () => { + it('refreshes after an active runtime A to B switch without manual action', () => { + const view = renderHook(() => useLandingPreflightRuntime()) + expect(refresh).toHaveBeenCalledTimes(1) + + act(() => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'runtime-a' }, + runtimeStatusByEnvironmentId: new Map([ + ['runtime-a', { status: { runtimeId: 'a' }, connectionGeneration: 1 }] + ]) + } as never) + }) + act(() => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'runtime-b' }, + runtimeStatusByEnvironmentId: new Map([ + ['runtime-b', { status: { runtimeId: 'b' }, connectionGeneration: 1 }] + ]) + } as never) + }) + + expect(refresh).toHaveBeenCalledTimes(3) + view.unmount() + }) + + it('invalidates on disconnect and refreshes exactly once on reconnect', () => { + const view = renderHook(() => useLandingPreflightRuntime()) + act(() => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'runtime-a' }, + runtimeStatusByEnvironmentId: new Map([ + ['runtime-a', { status: { runtimeId: 'a' }, connectionGeneration: 1 }] + ]) + } as never) + }) + refresh.mockClear() + + act(() => { + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([ + ['runtime-a', { status: null, connectionGeneration: 2 }] + ]) + } as never) + }) + expect(invalidate).toHaveBeenCalledTimes(1) + expect(refresh).not.toHaveBeenCalled() + + act(() => { + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([ + ['runtime-a', { status: { runtimeId: 'a-reconnected' }, connectionGeneration: 3 }] + ]) + } as never) + }) + expect(refresh).toHaveBeenCalledTimes(1) + view.unmount() + }) + + it('keeps the banner empty while the active remote runtime is still unknown', () => { + useAppStore.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-a' } } as never) + + const view = renderHook(() => useLandingPreflightRuntime()) + + expect(invalidate).toHaveBeenCalledTimes(1) + expect(refresh).not.toHaveBeenCalled() + view.unmount() + }) + + it('keeps one active interval and removes listeners and polling on cleanup', () => { + useAppStore.setState({ repos: [githubRepo], preflightStatus: status() }) + const addEventListener = vi.spyOn(document, 'addEventListener') + const removeEventListener = vi.spyOn(document, 'removeEventListener') + const addWindowListener = vi.spyOn(window, 'addEventListener') + const removeWindowListener = vi.spyOn(window, 'removeEventListener') + const view = renderHook(() => useLandingPreflightRuntime()) + + expect(vi.getTimerCount()).toBe(1) + act(() => { + useAppStore.setState({ repos: [...useAppStore.getState().repos] }) + }) + expect(vi.getTimerCount()).toBe(1) + act(() => vi.advanceTimersByTime(30_000)) + expect(refresh).toHaveBeenCalledWith({ force: true }) + + view.unmount() + expect(vi.getTimerCount()).toBe(0) + expect(removeEventListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) + expect(removeWindowListener).toHaveBeenCalledWith('focus', expect.any(Function)) + expect(addEventListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) + expect(addWindowListener).toHaveBeenCalledWith('focus', expect.any(Function)) + addEventListener.mockRestore() + removeEventListener.mockRestore() + addWindowListener.mockRestore() + removeWindowListener.mockRestore() + }) +}) diff --git a/src/renderer/src/components/landing-preflight-runtime.ts b/src/renderer/src/components/landing-preflight-runtime.ts new file mode 100644 index 00000000000..57cc9d1c31d --- /dev/null +++ b/src/renderer/src/components/landing-preflight-runtime.ts @@ -0,0 +1,70 @@ +import { useEffect, useMemo } from 'react' +import { useAppStore } from '../store' +import { + getLandingPreflightIssues, + hasGitHubBackedProject, + type PreflightIssue +} from './landing-preflight-issues' + +export function useLandingPreflightRuntime(): { preflightIssues: PreflightIssue[] } { + const repos = useAppStore((s) => s.repos) + const preflightStatus = useAppStore((s) => s.preflightStatus) + const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) + const invalidatePreflightStatus = useAppStore((s) => s.invalidatePreflightStatus) + const activeRuntimeState = useAppStore((s) => { + const environmentId = s.settings?.activeRuntimeEnvironmentId?.trim() + if (!environmentId) { + return 'local' + } + const runtimeStatus = s.runtimeStatusByEnvironmentId.get(environmentId) + const reachability = runtimeStatus + ? runtimeStatus.status === null + ? 'unreachable' + : 'reachable' + : 'unknown' + return `${environmentId}:${runtimeStatus?.connectionGeneration ?? 0}:${reachability}` + }) + + const hasGitHubProject = useMemo(() => hasGitHubBackedProject(repos), [repos]) + const preflightIssues = useMemo( + () => + preflightStatus + ? getLandingPreflightIssues(preflightStatus, { + hasGitHubBackedProject: hasGitHubProject + }) + : [], + [preflightStatus, hasGitHubProject] + ) + + useEffect(() => { + if (activeRuntimeState !== 'local' && !activeRuntimeState.endsWith(':reachable')) { + invalidatePreflightStatus() + return + } + + void refreshPreflightStatus() + const handleWindowActive = (): void => { + if (document.visibilityState === 'visible') { + void refreshPreflightStatus({ force: true }) + } + } + document.addEventListener('visibilitychange', handleWindowActive) + window.addEventListener('focus', handleWindowActive) + return () => { + document.removeEventListener('visibilitychange', handleWindowActive) + window.removeEventListener('focus', handleWindowActive) + } + }, [activeRuntimeState, invalidatePreflightStatus, refreshPreflightStatus]) + + useEffect(() => { + if (preflightIssues.length === 0) { + return + } + const intervalId = window.setInterval(() => { + void refreshPreflightStatus({ force: true }) + }, 30000) + return () => window.clearInterval(intervalId) + }, [preflightIssues.length, refreshPreflightStatus]) + + return { preflightIssues } +} diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index 88db885a548..6be96fff55a 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -87,8 +87,12 @@ describe('HeroFlow height', () => { canGeneratePairing onCopyPairingCode={vi.fn()} networkInterfaces={[]} + customAddresses={[]} selectedAddress={undefined} + selectedAddressIsCustom={false} onSelectedAddressChange={vi.fn()} + onCustomAddressSelect={vi.fn()} + onCustomAddressRemove={vi.fn()} beforeCustomAddressChange={vi.fn().mockResolvedValue(true)} onRefreshNetworkInterfaces={vi.fn()} refreshingNetworkInterfaces={false} @@ -130,8 +134,12 @@ describe('HeroFlow height', () => { canGeneratePairing onCopyPairingCode={vi.fn()} networkInterfaces={[]} + customAddresses={[]} selectedAddress={undefined} + selectedAddressIsCustom={false} onSelectedAddressChange={vi.fn()} + onCustomAddressSelect={vi.fn()} + onCustomAddressRemove={vi.fn()} beforeCustomAddressChange={vi.fn().mockResolvedValue(true)} onRefreshNetworkInterfaces={vi.fn()} refreshingNetworkInterfaces={false} @@ -239,8 +247,12 @@ describe('HeroFlow height', () => { canGeneratePairing: true, onCopyPairingCode: vi.fn(), networkInterfaces: [], + customAddresses: [], selectedAddress: undefined, + selectedAddressIsCustom: false, onSelectedAddressChange: vi.fn(), + onCustomAddressSelect: vi.fn(), + onCustomAddressRemove: vi.fn(), beforeCustomAddressChange: vi.fn().mockResolvedValue(true), onRefreshNetworkInterfaces: vi.fn(), refreshingNetworkInterfaces: false @@ -276,8 +288,12 @@ describe('HeroFlow height', () => { canGeneratePairing: true, onCopyPairingCode: vi.fn(), networkInterfaces: [], + customAddresses: [], selectedAddress: undefined, + selectedAddressIsCustom: false, onSelectedAddressChange: vi.fn(), + onCustomAddressSelect: vi.fn(), + onCustomAddressRemove: vi.fn(), beforeCustomAddressChange: vi.fn().mockResolvedValue(true), onRefreshNetworkInterfaces: vi.fn(), refreshingNetworkInterfaces: false diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index 078ee157175..e99b7c67369 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -38,8 +38,12 @@ type HeroFlowProps = { canGeneratePairing: boolean onCopyPairingCode: () => void networkInterfaces: readonly MobileNetworkInterface[] + customAddresses: readonly string[] selectedAddress: string | undefined + selectedAddressIsCustom: boolean onSelectedAddressChange: (address: string) => void + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void beforeCustomAddressChange: (address: string) => Promise onRefreshNetworkInterfaces: () => void refreshingNetworkInterfaces: boolean @@ -72,8 +76,12 @@ export function HeroFlow({ canGeneratePairing, onCopyPairingCode, networkInterfaces, + customAddresses, selectedAddress, + selectedAddressIsCustom, onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, beforeCustomAddressChange, onRefreshNetworkInterfaces, refreshingNetworkInterfaces, @@ -227,8 +235,12 @@ export function HeroFlow({ canGeneratePairing={canGeneratePairing} onCopyPairingCode={onCopyPairingCode} networkInterfaces={networkInterfaces} + customAddresses={customAddresses} selectedAddress={selectedAddress} + selectedAddressIsCustom={selectedAddressIsCustom} onSelectedAddressChange={onSelectedAddressChange} + onCustomAddressSelect={onCustomAddressSelect} + onCustomAddressRemove={onCustomAddressRemove} beforeCustomAddressChange={beforeCustomAddressChange} onRefreshNetworkInterfaces={onRefreshNetworkInterfaces} refreshingNetworkInterfaces={refreshingNetworkInterfaces} diff --git a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx index 80bc79399cc..2be436a8001 100644 --- a/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx +++ b/src/renderer/src/components/mobile/MobileHeroPairingStep.tsx @@ -70,8 +70,12 @@ export function MobileHeroPairingStep({ canGeneratePairing, onCopyPairingCode, networkInterfaces, + customAddresses, selectedAddress, + selectedAddressIsCustom, onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, beforeCustomAddressChange, onRefreshNetworkInterfaces, refreshingNetworkInterfaces @@ -90,8 +94,12 @@ export function MobileHeroPairingStep({ canGeneratePairing: boolean onCopyPairingCode: () => void networkInterfaces: readonly MobileNetworkInterface[] + customAddresses: readonly string[] selectedAddress: string | undefined + selectedAddressIsCustom: boolean onSelectedAddressChange: (address: string) => void + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void beforeCustomAddressChange: (address: string) => Promise onRefreshNetworkInterfaces: () => void refreshingNetworkInterfaces: boolean @@ -218,8 +226,12 @@ export function MobileHeroPairingStep({ void orcaProfileAuthStatus: { state: 'connected' | 'local' } - settings: { showMobileButton: boolean; mobilePairingConnectionMode?: MobilePairingConnectionMode } + settings: { + showMobileButton: boolean + mobilePairingConnectionMode?: MobilePairingConnectionMode + mobilePairingCustomAddress?: string | null + mobilePairingCustomAddresses?: string[] + } updateSettings: () => Promise } @@ -44,6 +49,10 @@ vi.mock('./MobilePageContent', () => ({ enterFlow: () => void handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void handleAddressChange: (address: string) => void + customAddresses: readonly string[] + selectedAddressIsCustom: boolean + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void beforeCustomAddressChange: (address: string) => Promise handleContinue: () => void pairQrDataUrl: string | null @@ -51,6 +60,9 @@ vi.mock('./MobilePageContent', () => ({ pairingQrError: boolean relayMintFailure: MobileRelayMintFailure | null onRetryRelay: () => void + selectedAddress: string | undefined + loadNetworkInterfaces: () => void + refreshingNetworkInterfaces: boolean stage: string | null stepIdx: number }) => ( @@ -63,6 +75,10 @@ vi.mock('./MobilePageContent', () => ({ {props.pairingUrl ?? 'none'} {String(props.pairingQrError)} {props.relayMintFailure?.stage ?? 'none'} + {props.selectedAddress ?? 'none'} + {String(props.selectedAddressIsCustom)} + {props.customAddresses.join(',')} + {String(props.refreshingNetworkInterfaces)} @@ -81,18 +97,27 @@ vi.mock('./MobilePageContent', () => ({ + +
) })) @@ -101,6 +126,7 @@ import MobilePage from './MobilePage' describe('MobilePage pairing connection mode', () => { const getPairingQR = vi.fn() + const listNetworkInterfaces = vi.fn() beforeEach(() => { getPairingQR.mockReset().mockResolvedValue({ @@ -108,6 +134,7 @@ describe('MobilePage pairing connection mode', () => { qrDataUrl: 'data:image/png;base64,qr', pairingUrl: 'orca://pair#automatic' }) + listNetworkInterfaces.mockReset().mockResolvedValue({ interfaces: [] }) mocks.storeState = { closeMobilePage: vi.fn(), orcaProfileAuthStatus: { state: 'connected' }, @@ -120,7 +147,7 @@ describe('MobilePage pairing connection mode', () => { mobile: { getPairingQR, listDevices: vi.fn().mockResolvedValue({ devices: [] }), - listNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }) + listNetworkInterfaces }, shell: { openUrl: vi.fn() }, ui: { writeClipboardText: vi.fn().mockResolvedValue(undefined) } @@ -189,6 +216,24 @@ describe('MobilePage pairing connection mode', () => { expect(screen.getByTestId('mode')).toHaveTextContent('local-only') }) + it('restores a saved custom address for future pairing codes', async () => { + mocks.storeState.settings = { + showMobileButton: true, + mobilePairingCustomAddress: '100.126.117.25:6768' + } + await openPairingStep() + + await waitFor(() => + expect(getPairingQR).toHaveBeenCalledWith({ + address: '100.126.117.25:6768', + connectionMode: 'automatic' + }) + ) + expect(screen.getByTestId('selected-address')).toHaveTextContent('100.126.117.25:6768') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('true') + expect(screen.getByTestId('custom-addresses')).toHaveTextContent('100.126.117.25:6768') + }) + it('does not auto-mint any QR when signed out with Anywhere selected', async () => { mocks.storeState.orcaProfileAuthStatus = { state: 'local' } await openPairingStep() @@ -422,5 +467,107 @@ describe('MobilePage pairing connection mode', () => { }) ) expect(getPairingQR).toHaveBeenCalledTimes(2) + expect(mocks.storeState.updateSettings).not.toHaveBeenCalledWith({ + mobilePairingCustomAddress: 'wss://custom.example/large', + mobilePairingCustomAddresses: ['wss://custom.example/large'] + }) + }) + + it('persists a custom address after its QR preflight succeeds', async () => { + const user = userEvent.setup() + await openPairingStep() + await waitFor(() => expect(getPairingQR).toHaveBeenCalledTimes(1)) + + await user.click(screen.getByRole('button', { name: 'Confirm custom address' })) + + await waitFor(() => + expect(mocks.storeState.updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: 'wss://custom.example/large', + mobilePairingCustomAddresses: ['wss://custom.example/large'] + }) + ) + expect(screen.getByTestId('custom-addresses')).toHaveTextContent('wss://custom.example/large') + }) + + it('removes the active custom address and remints with a discovered fallback', async () => { + mocks.storeState.settings = { + showMobileButton: true, + mobilePairingCustomAddress: 'wss://custom.example/large', + mobilePairingCustomAddresses: ['wss://custom.example/large', 'second.example:6768'] + } + listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + const user = userEvent.setup() + await openPairingStep() + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('wss://custom.example/large') + ) + + await user.click(screen.getByRole('button', { name: 'Remove custom address' })) + + expect(mocks.storeState.updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: ['second.example:6768'] + }) + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.0.0.2') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('false') + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ + address: '10.0.0.2', + connectionMode: 'automatic', + rotate: true + }) + ) + }) + + it('keeps custom intent when the saved address is also discovered', async () => { + mocks.storeState.settings = { + showMobileButton: true, + mobilePairingCustomAddress: '10.0.0.2', + mobilePairingCustomAddresses: ['10.0.0.2'] + } + listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + + await openPairingStep() + + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.0.0.2') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('true') + }) + + it('keeps a custom address when an older network refresh resolves', async () => { + let resolveRefresh: ((value: Record) => void) | undefined + listNetworkInterfaces.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByTestId('stage')).toHaveTextContent('intro')) + await user.click(screen.getByRole('button', { name: 'Enter flow' })) + await waitFor(() => expect(listNetworkInterfaces).toHaveBeenCalledOnce()) + expect(screen.getByTestId('refreshing-addresses')).toHaveTextContent('true') + + await user.click(screen.getByRole('button', { name: 'Confirm custom address' })) + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('wss://custom.example/large') + ) + expect(listNetworkInterfaces).toHaveBeenCalledOnce() + + resolveRefresh?.({ interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] }) + + await waitFor(() => + expect(screen.getByTestId('refreshing-addresses')).toHaveTextContent('false') + ) + expect(screen.getByTestId('selected-address')).toHaveTextContent('wss://custom.example/large') + expect(getPairingQR).not.toHaveBeenCalledWith({ + address: '10.0.0.2', + connectionMode: 'automatic', + rotate: true + }) }) }) diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index d45083bc1d4..c6a258f3283 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -1,13 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import type { Platform, StepIndex } from './MobileHero' import type { IosChannel } from './mobile-platform-copy' -import { - selectRefreshedNetworkAddress, - type MobileNetworkInterface -} from '../settings/mobile-network-interface-selection' +import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' import { translate } from '@/i18n/i18n' import { useMobilePageEscape } from './use-mobile-page-escape' import { MobilePageContent } from './MobilePageContent' @@ -22,6 +19,10 @@ import { useMobilePairingQrInvalidation } from './use-mobile-pairing-qr-invalida import { useMobileInstallActions } from './use-mobile-install-actions' import { useMobilePagePairedDevices } from './use-mobile-page-paired-devices' import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' +import { + type MobilePairingAddressChange, + useMobilePairingAddressPreference +} from './use-mobile-pairing-address-preference' export default function MobilePage(): React.JSX.Element { const [stepIdx, setStepIdx] = useState(0) @@ -39,11 +40,23 @@ export default function MobilePage(): React.JSX.Element { const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode() const [networkInterfaces, setNetworkInterfaces] = useState([]) - const [selectedAddress, setSelectedAddress] = useState(undefined) - // Why: tracks whether `selectedAddress` came from the user typing a - // manual value rather than from an OS-enumerated interface, so the - // refresh path can keep their choice instead of snapping back to LAN. - const [addressIsManual, setAddressIsManual] = useState(false) + const pairingAddressChangeRef = useRef<(change: MobilePairingAddressChange) => void>(() => {}) + const notifyPairingAddressChange = useCallback( + (change: MobilePairingAddressChange): void => pairingAddressChangeRef.current(change), + [] + ) + const { + selectedAddress, + selectedAddressIsCustom, + customAddresses, + selectAddress: handleAddressChange, + selectCustomAddress: handleCustomAddressSelect, + removeCustomAddress: handleCustomAddressRemove, + selectAddressAfterRefresh + } = useMobilePairingAddressPreference({ + networkInterfaces, + onSelectionInvalidated: notifyPairingAddressChange + }) const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false) const hasGeneratedRef = useRef(false) const pairingRequestIdRef = useRef(0) @@ -77,6 +90,34 @@ export default function MobilePage(): React.JSX.Element { setPairLoading, setRelayMintFailure }) + useLayoutEffect(() => { + pairingAddressChangeRef.current = ({ address, source }) => { + const pairingContext = { connectionMode, signedIn } + if (source === 'user') { + if (canMintMobilePairingOffer(pairingContext)) { + void generatePairing(true, address ?? '') + } + return + } + if (source === 'refresh') { + if (hasGeneratedRef.current && canMintMobilePairingOffer(pairingContext)) { + void generatePairing(true, address) + } + return + } + const shouldRegenerate = hasGeneratedRef.current || pairLoading + pairingRequestIdRef.current += 1 + hasGeneratedRef.current = false + setPairQrDataUrl(null) + setPairingUrl(null) + setPairingQrError(false) + setRelayMintFailure(null) + setPairLoading(false) + if (shouldRegenerate && canMintMobilePairingOffer(pairingContext)) { + void generatePairing(true, address ?? '') + } + } + }, [connectionMode, generatePairing, pairLoading, signedIn]) const handleConnectionModeChange = useCallback( (nextMode: MobilePairingConnectionMode): void => { @@ -145,34 +186,7 @@ export default function MobilePage(): React.JSX.Element { const result = await window.api.mobile.listNetworkInterfaces() if (mountedRef.current) { setNetworkInterfaces(result.interfaces) - } - // Resolve the new address before committing it so we can detect a real - // change and remint the QR — otherwise the QR keeps encoding the stale - // endpoint after a network refresh swaps the active interface. - const newAddress = selectRefreshedNetworkAddress( - selectedAddress, - result.interfaces, - addressIsManual - ) - if (mountedRef.current) { - // Why: selectRefreshedNetworkAddress can rewrite selectedAddress - // (e.g. when a refresh surfaces a tailnet interface and the user - // had been on LAN). Re-derive `addressIsManual` from the new - // value so the next refresh doesn't snap the user back to LAN - // just because they once picked a non-tailnet interface. - setSelectedAddress(newAddress) - const nextIsManual = - newAddress !== undefined && - !result.interfaces.some((iface) => iface.address === newAddress) - setAddressIsManual(nextIsManual) - } - if ( - newAddress !== selectedAddress && - hasGeneratedRef.current && - canMintMobilePairingOffer({ connectionMode, signedIn }) && - mountedRef.current - ) { - void generatePairing(true, newAddress) + selectAddressAfterRefresh(result.interfaces) } } catch { // Network list is non-critical; the QR will still mint with default routing. @@ -181,7 +195,7 @@ export default function MobilePage(): React.JSX.Element { setRefreshingNetworkInterfaces(false) } } - }, [selectedAddress, generatePairing, mountedRef, addressIsManual, connectionMode, signedIn]) + }, [mountedRef, selectAddressAfterRefresh]) useEffect(() => { if (stage !== 'flow') { @@ -190,21 +204,6 @@ export default function MobilePage(): React.JSX.Element { void loadNetworkInterfaces() }, [stage, loadNetworkInterfaces]) - const handleAddressChange = useCallback( - (address: string) => { - setSelectedAddress(address) - // Why: if the picked address is not in the OS-enumerated list, it is - // a user-typed manual entry — remember that so the next refresh does - // not snap it back to a tailnet/LAN fallback. - const isManual = !networkInterfaces.some((iface) => iface.address === address) - setAddressIsManual(isManual) - if (canMintMobilePairingOffer({ connectionMode, signedIn })) { - void generatePairing(true, address) - } - }, - [generatePairing, networkInterfaces, connectionMode, signedIn] - ) - const beforeCustomAddressChange = useCallback( async (address: string): Promise => { if (!canMintMobilePairingOffer({ connectionMode, signedIn })) { @@ -311,6 +310,10 @@ export default function MobilePage(): React.JSX.Element { generatePairing={(rotate) => void generatePairing(rotate)} canGeneratePairing={canGenerate} handleAddressChange={handleAddressChange} + customAddresses={customAddresses} + selectedAddressIsCustom={selectedAddressIsCustom} + onCustomAddressSelect={handleCustomAddressSelect} + onCustomAddressRemove={handleCustomAddressRemove} beforeCustomAddressChange={beforeCustomAddressChange} handleBack={handleBack} handleContinue={handleContinue} diff --git a/src/renderer/src/components/mobile/MobilePageContent.tsx b/src/renderer/src/components/mobile/MobilePageContent.tsx index a5f4078b0c0..c0b44c18fd5 100644 --- a/src/renderer/src/components/mobile/MobilePageContent.tsx +++ b/src/renderer/src/components/mobile/MobilePageContent.tsx @@ -24,6 +24,10 @@ type MobilePageContentProps = { generatePairing: (rotate: boolean) => void canGeneratePairing: boolean handleAddressChange: (address: string) => void + customAddresses: readonly string[] + selectedAddressIsCustom: boolean + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void beforeCustomAddressChange: (address: string) => Promise handleBack: () => void handleContinue: () => void @@ -66,6 +70,10 @@ export function MobilePageContent({ generatePairing, canGeneratePairing, handleAddressChange, + customAddresses, + selectedAddressIsCustom, + onCustomAddressSelect, + onCustomAddressRemove, beforeCustomAddressChange, handleBack, handleContinue, @@ -141,8 +149,12 @@ export function MobilePageContent({ canGeneratePairing={canGeneratePairing} onCopyPairingCode={copyPairingCode} networkInterfaces={networkInterfaces} + customAddresses={customAddresses} selectedAddress={selectedAddress} + selectedAddressIsCustom={selectedAddressIsCustom} onSelectedAddressChange={handleAddressChange} + onCustomAddressSelect={onCustomAddressSelect} + onCustomAddressRemove={onCustomAddressRemove} beforeCustomAddressChange={beforeCustomAddressChange} onRefreshNetworkInterfaces={loadNetworkInterfaces} refreshingNetworkInterfaces={refreshingNetworkInterfaces} diff --git a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx index 23a0b0006ca..bd21eed05fb 100644 --- a/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx +++ b/src/renderer/src/components/mobile/NetworkInterfacePicker.tsx @@ -4,12 +4,22 @@ import { AddressPicker, type AddressOption } from '../network/AddressPicker' import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' -// Why: both pairing entry points must expose every endpoint form supported by the main process. +function formatCustomAddressLabel(address: string): string { + return translate( + 'auto.components.mobile.NetworkInterfacePicker.custom-option', + '{{address}} (custom)', + { address } + ) +} export type NetworkInterfacePickerProps = { networkInterfaces: readonly MobileNetworkInterface[] + customAddresses: readonly string[] selectedAddress: string | undefined + selectedAddressIsCustom: boolean onSelectedAddressChange: (address: string) => void + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void beforeCustomAddressChange?: (address: string) => boolean | Promise disabled?: boolean className?: string @@ -18,8 +28,12 @@ export type NetworkInterfacePickerProps = { export function NetworkInterfacePicker({ networkInterfaces, + customAddresses, selectedAddress, + selectedAddressIsCustom, onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, beforeCustomAddressChange, disabled = false, className, @@ -33,20 +47,37 @@ export function NetworkInterfacePicker({ })), [networkInterfaces] ) + const customOptions = useMemo( + () => + customAddresses.map((address) => ({ + value: address, + label: formatCustomAddressLabel(address) + })), + [customAddresses] + ) return ( + formatCustomLabel={formatCustomAddressLabel} + customSectionLabel={translate( + 'auto.components.mobile.NetworkInterfacePicker.custom-section', + 'Custom' + )} + removeCustomLabel={(address) => translate( - 'auto.components.mobile.NetworkInterfacePicker.custom-option', - '{{address}} (custom)', + 'auto.components.mobile.NetworkInterfacePicker.remove-custom', + 'Remove {{address}}', { address } ) } diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.ts b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.ts new file mode 100644 index 00000000000..be9a14da8d8 --- /dev/null +++ b/src/renderer/src/components/mobile/use-mobile-pairing-address-preference.ts @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { + selectRefreshedNetworkAddress, + type MobileNetworkInterface +} from '../settings/mobile-network-interface-selection' +import { + useMobilePairingCustomAddress, + useMobilePairingCustomAddresses +} from './use-mobile-pairing-custom-address' +import { + addMobilePairingCustomAddress, + removeMobilePairingCustomAddress +} from '../../../../shared/mobile-pairing-custom-address' + +function haveSameAddresses(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((address, index) => address === right[index]) +} + +export type MobilePairingAddressChange = { + address: string | undefined + source: 'external' | 'refresh' | 'user' +} + +export function useMobilePairingAddressPreference(args: { + networkInterfaces: readonly MobileNetworkInterface[] + onSelectionInvalidated: (change: MobilePairingAddressChange) => void +}): { + selectedAddress: string | undefined + selectedAddressIsCustom: boolean + customAddresses: readonly string[] + selectAddress: (address: string) => void + selectCustomAddress: (address: string) => void + removeCustomAddress: (address: string) => void + selectAddressAfterRefresh: (interfaces: readonly MobileNetworkInterface[]) => void +} { + const { networkInterfaces, onSelectionInvalidated } = args + const updateSettings = useAppStore((state) => state.updateSettings) + const savedCustomAddress = useMobilePairingCustomAddress() + const savedCustomAddresses = useMobilePairingCustomAddresses() + const [selectedAddress, setSelectedAddress] = useState(savedCustomAddress) + const [selectedAddressIsCustom, setSelectedAddressIsCustom] = useState( + savedCustomAddress !== undefined + ) + const [customAddresses, setCustomAddresses] = useState(savedCustomAddresses) + const selectedAddressRef = useRef(selectedAddress) + const selectedAddressIsManualRef = useRef(savedCustomAddress !== undefined) + const customAddressesRef = useRef(savedCustomAddresses) + const observedCustomAddressRef = useRef(savedCustomAddress) + const pendingCustomAddressWritesRef = useRef<(string | undefined)[]>([]) + + const selectAddressAfterRefresh = useCallback( + (interfaces: readonly MobileNetworkInterface[]): void => { + const nextAddress = selectRefreshedNetworkAddress( + selectedAddressRef.current, + interfaces, + selectedAddressIsManualRef.current + ) + if (nextAddress === selectedAddressRef.current) { + return + } + selectedAddressRef.current = nextAddress + selectedAddressIsManualRef.current = false + setSelectedAddress(nextAddress) + setSelectedAddressIsCustom(false) + onSelectionInvalidated({ address: nextAddress, source: 'refresh' }) + }, + [onSelectionInvalidated] + ) + + const commitAddress = useCallback( + (address: string, isManual: boolean): void => { + const addressChanged = selectedAddressRef.current !== address + if (!addressChanged && selectedAddressIsManualRef.current === isManual) { + return + } + selectedAddressRef.current = address + selectedAddressIsManualRef.current = isManual + setSelectedAddress(address) + setSelectedAddressIsCustom(isManual) + const customAddress = isManual ? address : undefined + const pendingWrites = pendingCustomAddressWritesRef.current + const effectiveCustomAddress = + pendingWrites.length > 0 ? pendingWrites.at(-1) : observedCustomAddressRef.current + if (customAddress !== effectiveCustomAddress) { + pendingWrites.push(customAddress) + if (customAddress) { + const nextCustomAddresses = addMobilePairingCustomAddress( + customAddressesRef.current, + customAddress + ) + customAddressesRef.current = nextCustomAddresses + setCustomAddresses(nextCustomAddresses) + void updateSettings({ + mobilePairingCustomAddress: customAddress, + mobilePairingCustomAddresses: nextCustomAddresses + }) + } else { + void updateSettings({ mobilePairingCustomAddress: null }) + } + } + if (addressChanged) { + onSelectionInvalidated({ address, source: 'user' }) + } + }, + [onSelectionInvalidated, updateSettings] + ) + + const selectAddress = useCallback( + (address: string): void => { + commitAddress(address, !networkInterfaces.some((iface) => iface.address === address)) + }, + [commitAddress, networkInterfaces] + ) + + const selectCustomAddress = useCallback( + (address: string): void => commitAddress(address, true), + [commitAddress] + ) + + const removeCustomAddress = useCallback( + (address: string): void => { + const nextCustomAddresses = removeMobilePairingCustomAddress( + customAddressesRef.current, + address + ) + if (haveSameAddresses(nextCustomAddresses, customAddressesRef.current)) { + return + } + customAddressesRef.current = nextCustomAddresses + setCustomAddresses(nextCustomAddresses) + const removingSelection = + selectedAddressIsManualRef.current && selectedAddressRef.current === address + if (!removingSelection) { + void updateSettings({ mobilePairingCustomAddresses: nextCustomAddresses }) + return + } + const nextAddress = selectRefreshedNetworkAddress(undefined, networkInterfaces) + const addressChanged = selectedAddressRef.current !== nextAddress + selectedAddressRef.current = nextAddress + selectedAddressIsManualRef.current = false + setSelectedAddress(nextAddress) + setSelectedAddressIsCustom(false) + pendingCustomAddressWritesRef.current.push(undefined) + void updateSettings({ + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: nextCustomAddresses + }) + if (addressChanged) { + onSelectionInvalidated({ address: nextAddress, source: 'user' }) + } + }, + [networkInterfaces, onSelectionInvalidated, updateSettings] + ) + + useEffect(() => { + if (savedCustomAddress === observedCustomAddressRef.current) { + return + } + observedCustomAddressRef.current = savedCustomAddress + const pendingWrites = pendingCustomAddressWritesRef.current + const acknowledgedWriteIndex = pendingWrites.indexOf(savedCustomAddress) + if (acknowledgedWriteIndex !== -1) { + pendingWrites.splice(0, acknowledgedWriteIndex + 1) + return + } + pendingWrites.length = 0 + const nextAddress = + savedCustomAddress ?? selectRefreshedNetworkAddress(undefined, networkInterfaces) + const addressChanged = selectedAddressRef.current !== nextAddress + selectedAddressRef.current = nextAddress + selectedAddressIsManualRef.current = savedCustomAddress !== undefined + setSelectedAddress(nextAddress) + setSelectedAddressIsCustom(savedCustomAddress !== undefined) + if (addressChanged) { + onSelectionInvalidated({ address: nextAddress, source: 'external' }) + } + }, [networkInterfaces, onSelectionInvalidated, savedCustomAddress]) + + useEffect(() => { + if (pendingCustomAddressWritesRef.current.length > 0) { + return + } + if (haveSameAddresses(savedCustomAddresses, customAddressesRef.current)) { + return + } + customAddressesRef.current = savedCustomAddresses + setCustomAddresses(savedCustomAddresses) + }, [savedCustomAddresses]) + + return { + selectedAddress, + selectedAddressIsCustom, + customAddresses, + selectAddress, + selectCustomAddress, + removeCustomAddress, + selectAddressAfterRefresh + } +} diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-custom-address.ts b/src/renderer/src/components/mobile/use-mobile-pairing-custom-address.ts new file mode 100644 index 00000000000..7d92f8d72d2 --- /dev/null +++ b/src/renderer/src/components/mobile/use-mobile-pairing-custom-address.ts @@ -0,0 +1,23 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { + addMobilePairingCustomAddress, + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../../../../shared/mobile-pairing-custom-address' + +export function useMobilePairingCustomAddress(): string | undefined { + const savedAddress = useAppStore((state) => state.settings?.mobilePairingCustomAddress) + return normalizeMobilePairingCustomAddress(savedAddress) ?? undefined +} + +export function useMobilePairingCustomAddresses(): string[] { + const savedAddresses = useAppStore((state) => state.settings?.mobilePairingCustomAddresses) + const savedAddress = useAppStore((state) => state.settings?.mobilePairingCustomAddress) + return useMemo(() => { + const normalized = normalizeMobilePairingCustomAddresses(savedAddresses) + return typeof savedAddress === 'string' + ? addMobilePairingCustomAddress(normalized, savedAddress) + : normalized + }, [savedAddress, savedAddresses]) +} diff --git a/src/renderer/src/components/network/AddressPicker.tsx b/src/renderer/src/components/network/AddressPicker.tsx index 07cbee99d24..e2ecb5ad8b0 100644 --- a/src/renderer/src/components/network/AddressPicker.tsx +++ b/src/renderer/src/components/network/AddressPicker.tsx @@ -1,13 +1,10 @@ -import React, { useState } from 'react' -import { Plus } from 'lucide-react' -import { - Select, - SelectContent, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue -} from '../ui/select' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Check, ChevronDown, Plus, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Button } from '../ui/button' +import { Command, CommandGroup, CommandItem, CommandList } from '../ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { CustomAddressDialog, type CustomAddressDialogCopy, @@ -19,20 +16,80 @@ export type AddressOption = { label: string } -// Why: a sentinel Select value for the footer action. It is never committed as -// a real address; selecting it opens the custom-address dialog instead. -const ADD_CUSTOM_VALUE = '__add_custom_address__' +const EMPTY_ADDRESS_OPTIONS: readonly AddressOption[] = [] + +type AddressPickerItemProps = { + option: AddressOption + selected: boolean + commandValue: string + onSelect: () => void + onRemove?: () => void + removeLabel?: string +} + +function AddressPickerItem({ + option, + selected, + commandValue, + onSelect, + onRemove, + removeLabel +}: AddressPickerItemProps): React.JSX.Element { + return ( +
+ + + {option.label} + + {onRemove ? ( + + + + + + {removeLabel} + + + ) : null} +
+ ) +} export type AddressPickerProps = { options: readonly AddressOption[] + customOptions?: readonly AddressOption[] value: string | undefined + valueIsCustom?: boolean onValueChange: (value: string) => void + onCustomValueChange?: (value: string) => void + onCustomRemove?: (value: string) => void beforeCustomConfirm?: (value: string) => boolean | Promise - // Why: a value that isn't one of `options` is a custom entry; this renders - // its display label (e.g. `${value} (custom)`) so the Select can show it — - // Radix Select only displays values that have a matching item. formatCustomLabel: (value: string) => string addCustomLabel: string + customSectionLabel?: string + removeCustomLabel?: (value: string) => string customDialogCopy: CustomAddressDialogCopy validateCustom: CustomAddressValidator customInputId: string @@ -43,17 +100,20 @@ export type AddressPickerProps = { id?: string } -// Note (crash cluster C6, React #185 in settings): the throwing dispatch is in -// @radix-ui/react-select's SelectItem unmount cleanup, not in our code — this -// file holds no unmount-phase setState, so there is nothing here to guard. -// Item churn here is derived from props only; re-audit if that stops holding. +// Why: removable saved rows have two actions, which cannot be represented by a Select option. export function AddressPicker({ options, + customOptions = EMPTY_ADDRESS_OPTIONS, value, + valueIsCustom, onValueChange, + onCustomValueChange, + onCustomRemove, beforeCustomConfirm, formatCustomLabel, addCustomLabel, + customSectionLabel, + removeCustomLabel, customDialogCopy, validateCustom, customInputId, @@ -63,52 +123,244 @@ export function AddressPicker({ className, id }: AddressPickerProps): React.JSX.Element { + const [pickerOpen, setPickerOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false) - + const [commandValue, setCommandValue] = useState('') + const [listId, setListId] = useState() + const listRef = useRef(null) + const restoreFocusAfterRemovalRef = useRef(false) + const typeaheadRef = useRef({ query: '', updatedAt: 0 }) + const handleListRef = useCallback((node: HTMLDivElement | null) => { + listRef.current = node + setListId(node?.id) + }, []) const isCustomSelection = - value !== undefined && value !== '' && !options.some((option) => option.value === value) + value !== undefined && + value !== '' && + (valueIsCustom ?? !options.some((option) => option.value === value)) + const displayedCustomOptions = useMemo(() => { + if ( + !isCustomSelection || + value === undefined || + customOptions.some((option) => option.value === value) + ) { + return customOptions + } + return [...customOptions, { value, label: formatCustomLabel(value) }] + }, [customOptions, formatCustomLabel, isCustomSelection, value]) + const selectedOption = + (isCustomSelection + ? displayedCustomOptions.find((option) => option.value === value) + : options.find((option) => option.value === value)) ?? + displayedCustomOptions.find((option) => option.value === value) + const selectedCommandValue = value ? `${isCustomSelection ? 'custom' : 'detected'}:${value}` : '' + const customValueChange = onCustomValueChange ?? onValueChange + const firstCommandValue = + selectedCommandValue || + (options[0] + ? `detected:${options[0].value}` + : displayedCustomOptions[0] + ? `custom:${displayedCustomOptions[0].value}` + : 'add-custom-address') - const handleValueChange = (next: string): void => { - if (next === ADD_CUSTOM_VALUE) { - setDialogOpen(true) + useEffect(() => { + if (!pickerOpen) { return } - onValueChange(next) + const commandValueExists = + commandValue === 'add-custom-address' || + options.some((option) => commandValue === `detected:${option.value}`) || + displayedCustomOptions.some((option) => commandValue === `custom:${option.value}`) + if (!commandValueExists) { + setCommandValue(firstCommandValue) + } + }, [commandValue, displayedCustomOptions, firstCommandValue, options, pickerOpen]) + + useEffect(() => { + if (!pickerOpen || !restoreFocusAfterRemovalRef.current) { + return + } + restoreFocusAfterRemovalRef.current = false + listRef.current?.focus() + }, [displayedCustomOptions, pickerOpen]) + + useEffect(() => { + if (!pickerOpen) { + return + } + const frame = window.requestAnimationFrame(() => { + const list = listRef.current + const activeOption = list?.querySelector('[cmdk-item][aria-selected="true"]') + if (list && activeOption?.id) { + list.setAttribute('aria-activedescendant', activeOption.id) + } + }) + return () => window.cancelAnimationFrame(frame) + }, [commandValue, displayedCustomOptions, options, pickerOpen]) + + const handlePickerOpenChange = (nextOpen: boolean): void => { + typeaheadRef.current = { query: '', updatedAt: 0 } + if (nextOpen) { + setCommandValue(firstCommandValue) + } + setPickerOpen(nextOpen) + } + + const handleCommandKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === ' ') { + event.preventDefault() + listRef.current?.querySelector('[cmdk-item][aria-selected="true"]')?.click() + return + } + if ( + event.key.length !== 1 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.nativeEvent.isComposing + ) { + return + } + event.preventDefault() + const now = Date.now() + const previous = typeaheadRef.current + const query = now - previous.updatedAt > 700 ? event.key : previous.query + event.key + typeaheadRef.current = { query, updatedAt: now } + const repeatedKey = [...query].every((character) => character === query[0]) + const prefix = (repeatedKey ? event.key : query).toLocaleLowerCase() + const items = [ + ...options.map((option) => ({ command: `detected:${option.value}`, label: option.label })), + ...displayedCustomOptions.map((option) => ({ + command: `custom:${option.value}`, + label: option.label + })), + { command: 'add-custom-address', label: addCustomLabel } + ] + const currentIndex = items.findIndex((item) => item.command === commandValue) + const nextItem = [...items.slice(currentIndex + 1), ...items.slice(0, currentIndex + 1)].find( + (item) => item.label.toLocaleLowerCase().startsWith(prefix) + ) + if (nextItem) { + setCommandValue(nextItem.command) + } + } + + const selectValue = (next: string, custom: boolean): void => { + setPickerOpen(false) + if (custom) { + customValueChange(next) + } else { + onValueChange(next) + } } const handleCustomConfirm = async (next: string): Promise => { if (beforeCustomConfirm && !(await beforeCustomConfirm(next))) { return false } - onValueChange(next) + customValueChange(next) return true } return ( <> - + + {selectedOption?.label ?? placeholder} + + + + + { + event.preventDefault() + listRef.current?.focus() + }} + > + + + {options.length > 0 ? ( + + {options.map((option) => ( + selectValue(option.value, false)} + /> + ))} + + ) : null} + {displayedCustomOptions.length > 0 ? ( + 0 && 'border-t border-border pt-1')} + > + {displayedCustomOptions.map((option) => ( + selectValue(option.value, true)} + onRemove={ + onCustomRemove + ? () => { + restoreFocusAfterRemovalRef.current = true + onCustomRemove(option.value) + } + : undefined + } + removeLabel={removeCustomLabel?.(option.value)} + /> + ))} + + ) : null} + 0 || displayedCustomOptions.length > 0) && + 'border-t border-border pt-1' + )} + > + { + setPickerOpen(false) + setDialogOpen(true) + }} + className="text-muted-foreground data-[selected=true]:text-foreground" + > + + {addCustomLabel} + + + + + + ( - (counts, check) => { - const conclusion = getCheckConclusion(check) - // Why: the shared classifier counts a skipped check as passing, so bucketing it separately - // made this pane say "2 passing" for the same list the Checks panel called "5 passing". - // action_required stays its own bucket: it blocks merge but is not a red failure. - if (classifyCheckOutcome(check) === 'passed') { - counts.passing += 1 - } else if (conclusion === 'action_required') { - counts.needsAction += 1 - } else if (['failure', 'cancelled', 'timed_out'].includes(conclusion)) { - counts.failing += 1 - } else if (conclusion === 'neutral') { - counts.neutral += 1 - } else { - counts.pending += 1 - } - return counts - }, - { passing: 0, failing: 0, needsAction: 0, pending: 0, neutral: 0 } - ) + // Why: every bucket is read off the shared rollup rather than re-derived, so these panes cannot + // disagree with the checks pill. action_required is the one split back out: it blocks merge like + // a failure but reads amber, not red. + const summary = summarizeProviderChecks(checks) + const needsAction = checks.filter( + (check) => getCheckConclusion(check) === 'action_required' + ).length + return { + passing: summary.passed, + failing: summary.failed - needsAction, + needsAction, + pending: summary.pending, + neutral: summary.neutral + } +} + +/** `tone` keys the caller's `CHECK_COLOR`/`CHECK_ICON` maps so styling stays with the surface. */ +export type PRCheckCountChip = { + tone: 'success' | 'failure' | 'action_required' | 'pending' | 'neutral' + label: string +} + +/** + * The count chips above the check list on the PR page and the work-item dialog. One builder so the + * two panes cannot word the same bucket differently — the neutral bucket read "skipped" here while + * the sidebar Checks panel called the identical checks "unresolved". + */ +export function getCheckCountChips(counts: PRCheckCounts): PRCheckCountChip[] { + const chips: PRCheckCountChip[] = [] + if (counts.passing > 0) { + chips.push({ + tone: 'success', + label: translate('auto.components.pr-check-counts.passingChip', '{{value0}} passing', { + value0: counts.passing + }) + }) + } + if (counts.failing > 0) { + chips.push({ + tone: 'failure', + label: translate('auto.components.pr-check-counts.failingChip', '{{value0}} failing', { + value0: counts.failing + }) + }) + } + if (counts.needsAction > 0) { + chips.push({ + tone: 'action_required', + label: translate( + 'auto.components.pr-check-counts.needsActionChip', + '{{value0}} action required', + { value0: counts.needsAction } + ) + }) + } + if (counts.pending > 0) { + chips.push({ + tone: 'pending', + label: translate('auto.components.pr-check-counts.pendingChip', '{{value0}} pending', { + value0: counts.pending + }) + }) + } + if (counts.neutral > 0) { + chips.push({ + tone: 'neutral', + label: translate('auto.components.pr-check-counts.unresolvedChip', '{{value0}} unresolved', { + value0: counts.neutral + }) + }) + } + return chips } export function getChecksSummaryLabel(checks: readonly PRCheckDetail[]): string { diff --git a/src/renderer/src/components/provider-check-classification-parity.test.ts b/src/renderer/src/components/provider-check-classification-parity.test.ts index 91830041bec..688f61a978e 100644 --- a/src/renderer/src/components/provider-check-classification-parity.test.ts +++ b/src/renderer/src/components/provider-check-classification-parity.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { deriveTaskPagePRCheckSummary } from './task-page-pr-check-summary' +import { getCheckCountChips, getCheckCounts } from './pr-check-counts' import { derivePipelineStatus } from '../../../main/gitlab/mappers' import { gitLabPipelineJobsToPRChecks } from '../../../shared/gitlab-pipeline-checks' import { derivePRCheckStatus, derivePRCheckStatusFromRollup } from '../../../shared/pr-check-status' @@ -171,6 +172,24 @@ describe('provider check classification parity', () => { const summary = { ...expected, total: checks.length } expect(summarizeProviderChecks(checks)).toEqual(summary) expect(deriveTaskPagePRCheckSummary(checks)).toEqual(summary) + // Why: the PR page and the work-item dialog share these counts; their absence here is how + // their neutral chip kept saying "skipped" for what the sidebar calls "unresolved". + const paneCounts = getCheckCounts(checks) + expect({ + passed: paneCounts.passing, + // Both panes split action_required into its own amber chip; it is still the failed bucket. + failed: paneCounts.failing + paneCounts.needsAction, + pending: paneCounts.pending, + neutral: paneCounts.neutral + }).toEqual({ + passed: expected.passed, + failed: expected.failed, + pending: expected.pending, + neutral: expected.neutral + }) + expect(getCheckCountChips(paneCounts).find((chip) => chip.tone === 'neutral')?.label).toBe( + expected.neutral > 0 ? `${expected.neutral} unresolved` : undefined + ) expect(derivePRCheckStatus(checks)).toBe(expected.state) expect(derivePRCheckStatusFromRollup(checks.map(toGraphQLRollup))).toBe(expected.state) if (gitLabJobStatuses) { diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts index 8421e4b56d6..1e00ac163b5 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { + createGitStatusRefreshPacing, createGitStatusRefreshScheduler, + type GitStatusRefreshPacing, type GitStatusRefreshReason } from './git-status-refresh-scheduler' @@ -19,7 +21,8 @@ async function flushMicrotasks(): Promise { } function createScheduler( - task: (request: { reason: GitStatusRefreshReason; signal: AbortSignal }) => Promise + task: (request: { reason: GitStatusRefreshReason; signal: AbortSignal }) => Promise, + pacing?: GitStatusRefreshPacing ) { return createGitStatusRefreshScheduler(task, { safetyIntervalMs: 60_000, @@ -29,7 +32,8 @@ function createScheduler( idleMultiplier: 5, changeSignalMultiplier: 1, maxIntervalMs: 5 * 60_000 - } + }, + ...(pacing ? { pacing } : {}) }) } @@ -258,6 +262,116 @@ describe('createGitStatusRefreshScheduler', () => { expect(task).toHaveBeenCalledTimes(2) }) + it('keeps the activity floor across scheduler recreation when pacing is shared', async () => { + vi.useFakeTimers() + const task = vi.fn(async () => {}) + const pacing = createGitStatusRefreshPacing() + + const first = createScheduler(task, pacing) + first.resumeSafety() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(1) + first.dispose() + + // A rebuild (execution-host/push-target change) must not grant a fresh + // immediate run; the floor from the previous run still applies. + const second = createScheduler(task, pacing) + second.resumeSafety() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(2999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + }) + + it('keeps slow-scan backoff across scheduler recreation when pacing is shared', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const pacing = createGitStatusRefreshPacing() + + const first = createScheduler(task, pacing) + first.resumeSafety() + await vi.advanceTimersByTimeAsync(30_000) + calls[0]?.resolve() + await flushMicrotasks() + first.dispose() + + const second = createScheduler(task, pacing) + second.resumeSafety() + await flushMicrotasks() + // The 30s scan duration still paces the next run: max(3s floor, 1x 30s). + await vi.advanceTimersByTimeAsync(29_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + }) + + it('does not let an older disposed run erase replacement backoff', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const pacing = createGitStatusRefreshPacing() + + const first = createScheduler(task, pacing) + first.resumeSafety() + first.dispose() + + const second = createScheduler(task, pacing) + second.resumeSafety() + await vi.advanceTimersByTimeAsync(30_000) + calls[1]?.resolve() + await flushMicrotasks() + + await vi.advanceTimersByTimeAsync(1) + calls[0]?.resolve() + await flushMicrotasks() + second.signal() + + await vi.advanceTimersByTimeAsync(29_998) + expect(task).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(3) + }) + + it('lets an aborted old run pace repeated rebuilds until a replacement finishes', async () => { + vi.useFakeTimers() + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const pacing = createGitStatusRefreshPacing() + + const first = createScheduler(task, pacing) + first.resumeSafety() + first.dispose() + + const second = createScheduler(task, pacing) + second.resumeSafety() + calls[0]?.resolve() + await flushMicrotasks() + second.dispose() + + const third = createScheduler(task, pacing) + third.resumeSafety() + expect(task).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(2999) + expect(task).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(3) + }) + it('cleans up debounce and safety timers on dispose', async () => { vi.useFakeTimers() const task = vi.fn(async () => {}) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.ts index f33a6d7afc7..43459befd47 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-scheduler.ts @@ -2,6 +2,25 @@ import { slowTaskRequiredIdleMs, type SlowTaskBackoffOptions } from './coalesced export type GitStatusRefreshReason = 'activity' | 'safety' +// Why: run pacing must be shareable across scheduler instances — a rebuild +// (execution-host or push-target change) that resets it lets sustained change +// signals run git at the bare debounce, bypassing the #7983 floor. +export type GitStatusRefreshPacing = { + lastRunEndedAt: number + lastRunDurationMs: number + nextRunId: number + latestFinishedRunId: number +} + +export function createGitStatusRefreshPacing(): GitStatusRefreshPacing { + return { + lastRunEndedAt: -Infinity, + lastRunDurationMs: 0, + nextRunId: 0, + latestFinishedRunId: 0 + } +} + export type GitStatusRefreshScheduler = { resumeSafety: () => void pause: () => void @@ -27,6 +46,7 @@ export function createGitStatusRefreshScheduler( // task backoff when the previous scan itself was slow. activityMinGapMs: number slowTaskBackoff: SlowTaskBackoffOptions + pacing?: GitStatusRefreshPacing } ): GitStatusRefreshScheduler { let disposed = false @@ -37,8 +57,7 @@ export function createGitStatusRefreshScheduler( let activityTimerFiresAt = Infinity let safetyTimer: ReturnType | null = null let activeController: AbortController | null = null - let lastRunEndedAt = -Infinity - let lastRunDurationMs = 0 + const pacing = options.pacing ?? createGitStatusRefreshPacing() const clearActivityTimer = (): void => { if (activityTimer !== null) { @@ -56,7 +75,7 @@ export function createGitStatusRefreshScheduler( const requiredActivityIdleMs = (): number => slowTaskRequiredIdleMs( - lastRunDurationMs, + pacing.lastRunDurationMs, options.slowTaskBackoff.changeSignalMultiplier, options.activityMinGapMs, options.slowTaskBackoff.maxIntervalMs @@ -68,7 +87,7 @@ export function createGitStatusRefreshScheduler( const delay = Math.max( options.safetyIntervalMs, slowTaskRequiredIdleMs( - lastRunDurationMs, + pacing.lastRunDurationMs, options.slowTaskBackoff.idleMultiplier, 0, options.slowTaskBackoff.maxIntervalMs @@ -91,7 +110,7 @@ export function createGitStatusRefreshScheduler( return } const now = Date.now() - const delay = Math.max(minDelayMs, lastRunEndedAt + requiredActivityIdleMs() - now) + const delay = Math.max(minDelayMs, pacing.lastRunEndedAt + requiredActivityIdleMs() - now) if (delay <= 0) { startRun('activity') return @@ -121,6 +140,7 @@ export function createGitStatusRefreshScheduler( clearSafetyTimer() inFlight = true const startedAt = Date.now() + const runId = ++pacing.nextRunId const controller = new AbortController() activeController = controller let result: Promise @@ -134,12 +154,17 @@ export function createGitStatusRefreshScheduler( // Status refresh errors are transient; the next signal or safety run retries. }) .finally(() => { - lastRunEndedAt = Date.now() - // Why: cancelled scans never delivered a useful result, so their wall - // time must not stretch the next activity/safety gap. Otherwise hide → - // reveal after a slow abort waits out the aborted scan's full duration - // before the catch-up refresh can start. - lastRunDurationMs = controller.signal.aborted ? 0 : Math.max(0, lastRunEndedAt - startedAt) + if (runId > pacing.latestFinishedRunId) { + pacing.latestFinishedRunId = runId + pacing.lastRunEndedAt = Date.now() + // Why: cancelled scans never delivered a useful result, so their wall + // time must not stretch the next activity/safety gap. Otherwise hide → + // reveal after a slow abort waits out the aborted scan's full duration + // before the catch-up refresh can start. + pacing.lastRunDurationMs = controller.signal.aborted + ? 0 + : Math.max(0, pacing.lastRunEndedAt - startedAt) + } if (activeController === controller) { activeController = null } diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts index 3fab77c327f..2f9c3ddabdb 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.rerender.test.ts @@ -172,6 +172,13 @@ describe('useGitStatusPolling rerender stability', () => { // Should trigger an immediate poll on the new worktree (total 2 calls) // without having to wait for the 3000ms timer. expect(refreshMock).toHaveBeenCalledTimes(2) + + await act(async () => { + useAppStore.setState({ activeWorktreeId: WORKTREE_ID }) + }) + await flushMicrotasks() + + expect(refreshMock).toHaveBeenCalledTimes(3) }) it('refreshes immediately when Source Control becomes visible', async () => { @@ -241,6 +248,42 @@ describe('useGitStatusPolling rerender stability', () => { expect(refreshMock.mock.calls[1]?.[0].request.reuseLineStats).toBe(true) }) + it('keeps the activity floor when the execution host flaps for the same worktree', async () => { + await renderHook() + await flushMicrotasks() + // Mount refresh settles and stamps pacing for this worktree. + expect(refreshMock).toHaveBeenCalledTimes(1) + + // Flap the resolved execution host back and forth. Each flip rebuilds the + // scheduler; pacing must survive or every flip grants an immediate run + // (the rc.3 storm: sustained git at the debounce floor). + await act(async () => { + useAppStore.setState({ + worktreesByRepo: { + [REPO_ID]: [{ ...worktree, hostId: 'runtime:env-2' }], + [REPO_ID2]: [worktree2] + } + }) + }) + await flushMicrotasks() + await act(async () => { + useAppStore.setState({ + worktreesByRepo: { + [REPO_ID]: [worktree], + [REPO_ID2]: [worktree2] + } + }) + }) + await flushMicrotasks() + expect(refreshMock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(2999) + expect(refreshMock).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + await flushMicrotasks() + expect(refreshMock).toHaveBeenCalledTimes(2) + }) + it('aborts and rejects stale work when the execution host changes', async () => { let resolveFirst!: () => void const firstRefresh = new Promise((resolve) => { diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 8511d796630..d22e6591c41 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -15,7 +15,9 @@ import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh' import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh' import { useStaleConflictOperationPolling } from './stale-conflict-operation-poll' import { + createGitStatusRefreshPacing, createGitStatusRefreshScheduler, + type GitStatusRefreshPacing, type GitStatusRefreshReason, type GitStatusRefreshScheduler } from './git-status-refresh-scheduler' @@ -162,8 +164,22 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const statusRefreshGenerationRef = useRef(0) const statusSchedulerRef = useRef(null) + // Why: pacing belongs to the current worktree, not to scheduler instances — + // execution-host/push-target rebuilds must not reset it, or a flapping host id + // lets sustained change signals run git at the bare debounce. + const statusPacingRef = useRef<{ + key: string + pacing: GitStatusRefreshPacing + } | null>(null) useEffect(() => { const generation = ++statusRefreshGenerationRef.current + const pacingKey = `${activeWorktreeId}\0${worktreePath}` + let pacing = + statusPacingRef.current?.key === pacingKey ? statusPacingRef.current.pacing : undefined + if (!pacing) { + pacing = createGitStatusRefreshPacing() + statusPacingRef.current = { key: pacingKey, pacing } + } const scheduler = createGitStatusRefreshScheduler( ({ reason, signal }) => runFetchStatusRef.current({ @@ -179,7 +195,8 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { safetyIntervalMs: STATUS_SAFETY_INTERVAL_MS, activityDebounceMs: STATUS_ACTIVITY_DEBOUNCE_MS, activityMinGapMs: STATUS_ACTIVITY_MIN_GAP_MS, - slowTaskBackoff: SLOW_GIT_POLL_BACKOFF + slowTaskBackoff: SLOW_GIT_POLL_BACKOFF, + pacing } ) statusSchedulerRef.current = scheduler diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index 59dd184bab1..795caf9b5cf 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -48,7 +48,7 @@ describe('AccountsPane', () => { expect(markup).toContain('Account location') expect(markup).toContain('aria-label="Account location"') - expect(markup).toContain('role="radio" aria-checked="true" disabled=""') + expect(markup).toContain('role="radio" aria-checked="true" aria-disabled="true"') }) it('selects the WSL account location under auto when the global project runtime is WSL', () => { @@ -70,8 +70,8 @@ describe('AccountsPane', () => { ) expect(markup).toContain('aria-label="Account location"') - // The resolved WSL radio is the checked option (disabled while capabilities load). - expect(markup).toContain('role="radio" aria-checked="true" disabled=""') + // The resolved WSL radio is the checked option (unavailable while capabilities load). + expect(markup).toContain('role="radio" aria-checked="true" aria-disabled="true"') } finally { if (originalOwnUserAgent) { Object.defineProperty(globalThis.navigator, 'userAgent', originalOwnUserAgent) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index ee021d9698d..8db579cfa2f 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -5,6 +5,7 @@ import { IntegrationStatusPill } from '../integration-status-pill' import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill' import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal' import { AgentSkillSetupFailureNotice } from './AgentSkillSetupFailureNotice' +import { buildSkillSetupTerminalCommand } from './CliSkillRuntimeSetup' import type { AgentSkillSetupPanelProps } from './agent-skill-setup-panel-props' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' @@ -386,10 +387,11 @@ export function AgentSkillSetupPanel({ + {/* The copied string above stays as built; only what we run is adapted. */} { } }) + it('keeps the npx preflight in the PowerShell-forced setup terminal', () => { + const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration']) + const windowsHost = { runtime: 'host', label: 'Windows' } as const + const previous = useAppStore.getState() + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'git-bash' } + }) + + try { + const copied = buildSkillCommandForRuntime(installCommand, windowsHost, 'win32') + expect(copied).toBe(installCommand) + // Orca forces its own setup terminal to powershell.exe, where cmd.exe works. + expect(buildSkillSetupTerminalCommand(copied, 'powershell.exe', 'win32')).toBe( + `${windowsNpxPreflightPrefix}${windowsNpxGuidance}) else (${installCommand})"` + ) + } finally { + useAppStore.setState({ settings: previous.settings }) + } + }) + + it('does not re-wrap the setup terminal command when no shell override applies', () => { + const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration']) + const previous = useAppStore.getState() + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'cmd.exe' } + }) + + try { + const copied = buildSkillCommandForRuntime( + installCommand, + { runtime: 'host', label: 'Windows' }, + 'win32' + ) + expect(buildSkillSetupTerminalCommand(copied, undefined, 'win32')).toBe(copied) + // An already-wrapped command must not gain a second preflight. + expect(buildSkillSetupTerminalCommand(copied, 'powershell.exe', 'win32')).toBe(copied) + } finally { + useAppStore.setState({ settings: previous.settings }) + } + }) + + it('leaves WSL and non-Windows setup terminal commands untouched', () => { + const wslCommand = buildSkillCommandForRuntime( + 'npx skills add orchestration --global', + { runtime: 'wsl', wslDistro: 'Ubuntu', label: 'WSL Ubuntu' }, + 'win32' + ) + + expect(buildSkillSetupTerminalCommand(wslCommand, 'powershell.exe', 'win32')).toBe(wslCommand) + expect( + buildSkillSetupTerminalCommand('npx skills add orchestration --global', undefined, 'linux') + ).toBe('npx skills add orchestration --global') + }) + it('keeps the bare reinstall rewrite for POSIX-family Windows skill updates', () => { const installCommand = buildAgentFeatureSkillInstallCommand(['orchestration']) const previous = useAppStore.getState() diff --git a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx index 005b5dfc5c6..039c2035d59 100644 --- a/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx +++ b/src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx @@ -89,7 +89,11 @@ export function buildSkillCommandForRuntime( currentPlatform ) if (resolvedRuntime.runtime !== 'wsl') { - return wrapWindowsSkillCommandWithNpxPrerequisite(normalizedCommand, currentPlatform) + return wrapWindowsSkillCommandWithNpxPrerequisite( + normalizedCommand, + currentPlatform, + 'copied-command' + ) } const distroArg = resolvedRuntime.wslDistro?.trim() @@ -127,9 +131,43 @@ function normalizeWindowsSkillUpdateCommand( return buildAgentFeatureSkillInstallCommand([updateMatch[1]]) } +/** + * Where a built skill command is going: the user's clipboard (their own shell) + * or the setup terminal Orca spawns itself. + */ +type SkillCommandTarget = 'copied-command' | 'orca-setup-terminal' + +/** + * Re-adds the npx preflight for Orca's own setup terminal, which + * `getAgentSkillTerminalShellOverride` forces onto powershell.exe. The copied + * string stays bare for POSIX-family shells; only the executed one is wrapped. + */ +export function buildSkillSetupTerminalCommand( + copiedCommand: string, + terminalShellOverride: string | undefined, + currentPlatform = getSkillCommandPlatform() +): string { + if (!isSetupTerminalForcedToPowerShell(terminalShellOverride)) { + return copiedCommand + } + return wrapWindowsSkillCommandWithNpxPrerequisite( + copiedCommand, + currentPlatform, + 'orca-setup-terminal' + ) +} + +function isSetupTerminalForcedToPowerShell(terminalShellOverride: string | undefined): boolean { + const trimmedOverride = terminalShellOverride?.trim() + return ( + Boolean(trimmedOverride) && resolveWindowsShellStartupFamily(trimmedOverride) === 'powershell' + ) +} + function wrapWindowsSkillCommandWithNpxPrerequisite( command: string, - currentPlatform: NodeJS.Platform + currentPlatform: NodeJS.Platform, + target: SkillCommandTarget ): string { const trimmedCommand = command.trim() if ( @@ -140,7 +178,7 @@ function wrapWindowsSkillCommandWithNpxPrerequisite( // Why: the copied command lands in the user's configured shell, and MSYS // shells rewrite cmd.exe's leading /d /s /c switches into drive paths, // starting an interactive cmd session instead of running the payload. - isPosixFamilyWindowsShellConfigured() || + (target === 'copied-command' && isPosixFamilyWindowsShellConfigured()) || !/^npx\s+skills\s+(?:add|update)\b/i.test(trimmedCommand) ) { return command diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx new file mode 100644 index 00000000000..f4f97c1bb64 --- /dev/null +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { DeveloperPermissionState } from '../../../../shared/developer-permissions-types' +import { FULL_DISK_ACCESS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' +import { DeveloperPermissionsPane } from './DeveloperPermissionsPane' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + Object.assign(window, { + api: { + developerPermissions: { + getStatus: vi.fn( + async (): Promise => [ + { id: 'full-disk-access', status: 'denied' } + ] + ), + request: vi.fn() + } + } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + Reflect.deleteProperty(window, 'api') +}) + +it('highlights the Full Disk Access row for a targeted Settings navigation', async () => { + await act(async () => { + root.render( + + ) + }) + + const row = container.querySelector( + `[data-settings-section="${FULL_DISK_ACCESS_SETTINGS_TARGET_ID}"]` + ) + expect(row?.dataset.highlighted).toBe('true') + expect(row?.className).toContain('data-[highlighted=true]:bg-accent') + expect(row?.className).toContain('data-[highlighted=true]:ring-ring/50') + + await act(async () => root.render()) + expect(row?.dataset.highlighted).toBeUndefined() +}) diff --git a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx index aa5b5a5694b..cee43cfbab4 100644 --- a/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx +++ b/src/renderer/src/components/settings/DeveloperPermissionsPane.tsx @@ -23,6 +23,10 @@ import { Button } from '../ui/button' import { translate } from '@/i18n/i18n' export { getDeveloperPermissionsPaneSearchEntries } from './developer-permissions-search' +type DeveloperPermissionsPaneProps = { + highlightedSettingId?: string | null +} + type PermissionDefinition = { id: DeveloperPermissionId label: string @@ -105,7 +109,7 @@ const PERMISSIONS: PermissionDefinition[] = [ get description() { return translate( 'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8', - "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Grant this to Orca and Orca Helper to reduce those prompts. Then quit Orca, end any surviving Orca Helper process, and reopen Orca." + "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Grant this to Orca to reduce those prompts. Then quit and reopen Orca." ) }, actionLabel: 'Open Settings', @@ -202,7 +206,9 @@ function statusClass(status: DeveloperPermissionStatus | undefined): string { return 'border-border bg-muted text-muted-foreground' } -export function DeveloperPermissionsPane(): React.JSX.Element { +export function DeveloperPermissionsPane({ + highlightedSettingId = null +}: DeveloperPermissionsPaneProps): React.JSX.Element { const [states, setStates] = useState([]) const [loading, setLoading] = useState(true) const [pendingId, setPendingId] = useState(null) @@ -340,9 +346,15 @@ export function DeveloperPermissionsPane(): React.JSX.Element { {PERMISSIONS.map((permission) => { const status = stateById.get(permission.id) const pending = pendingId === permission.id + const settingId = `developer-permissions-${permission.id}` return ( -
+
{permission.icon}
diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 7095ab97163..1b325ed4766 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -10,6 +10,7 @@ import { translate } from '@/i18n/i18n' import { getUpdateCheckClickOptions, getUpdateCheckHint } from '@/lib/update-check-click-options' import { GeneralRemoteServerUpdates } from './GeneralRemoteServerUpdates' import { ReleaseChannelSection } from './ReleaseChannelSection' +import { getReleaseNotesUrlForVersion } from '../../../../shared/release-channel' export function GeneralUpdateSettingsSection(): React.JSX.Element { const updateStatus = useAppStore((s) => s.updateStatus) @@ -184,8 +185,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { {updateStatus.source !== 'local' && ( > = {} ) { const onSelectedAddressChange = vi.fn() + const onCustomAddressSelect = vi.fn() + const onCustomAddressRemove = vi.fn() const onRefreshNetworkInterfaces = vi.fn() const onGenerateQr = vi.fn() const props: React.ComponentProps = { connectionMode: 'local-only', connectionPathControl:
path
, networkInterfaces: [LAN, TAILNET], + customAddresses: [], selectedAddress: TAILNET.address, + selectedAddressIsCustom: false, onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, refreshingNetworkInterfaces: false, onRefreshNetworkInterfaces, loading: false, @@ -40,7 +46,15 @@ function renderSection( ) - return { ...rendered, user, onSelectedAddressChange, onGenerateQr } + return { + ...rendered, + user, + props, + onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, + onGenerateQr + } } describe('MobilePairingSetupSection', () => { @@ -92,6 +106,114 @@ describe('MobilePairingSetupSection', () => { expect(onSelectedAddressChange).toHaveBeenCalledWith('192.168.1.24') }) + it('lists and removes saved custom addresses without selecting them', async () => { + const first = 'first.example:6768' + const second = 'second.example:6768' + const { user, onSelectedAddressChange, onCustomAddressSelect, onCustomAddressRemove } = + renderSection({ customAddresses: [first, second] }) + + await user.click(screen.getByRole('combobox')) + expect(screen.getByText('Custom')).toBeVisible() + expect(screen.getByRole('option', { name: `${first} (custom)` })).toBeVisible() + expect(screen.getByRole('option', { name: `${second} (custom)` })).toBeVisible() + const addOption = screen.getByRole('option', { name: 'Add custom address…' }) + expect( + screen + .getByRole('option', { name: `${second} (custom)` }) + .compareDocumentPosition(addOption) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + + const remove = screen.getByRole('button', { name: `Remove ${first}` }) + remove.focus() + expect(remove).toHaveFocus() + await user.click(remove) + + expect(onCustomAddressRemove).toHaveBeenCalledWith(first) + expect(onCustomAddressSelect).not.toHaveBeenCalled() + expect(onSelectedAddressChange).not.toHaveBeenCalled() + }) + + it('restores list focus after removing a custom address with the keyboard', async () => { + const address = 'first.example:6768' + const { user, props, rerender, onCustomAddressSelect } = renderSection({ + customAddresses: [address] + }) + + await user.click(screen.getByRole('combobox')) + const remove = screen.getByRole('button', { name: `Remove ${address}` }) + remove.focus() + await user.keyboard('{Enter}') + rerender( + + + + ) + + expect(screen.getByRole('listbox', { name: 'Network address to advertise' })).toHaveFocus() + expect(onCustomAddressSelect).not.toHaveBeenCalled() + }) + + it('selects a saved custom address through the custom path', async () => { + const address = '100.64.1.20' + const { user, onSelectedAddressChange, onCustomAddressSelect } = renderSection({ + customAddresses: [address], + selectedAddressIsCustom: true + }) + + await user.click(screen.getByRole('combobox')) + const customOption = screen.getByRole('option', { name: `${address} (custom)` }) + expect(customOption).toHaveAttribute('data-current', 'true') + await user.click(customOption) + + expect(onCustomAddressSelect).toHaveBeenCalledWith(address) + expect(onSelectedAddressChange).not.toHaveBeenCalled() + }) + + it('keeps keyboard selection working with removable custom rows', async () => { + const address = 'first.example:6768' + const { user, onCustomAddressSelect } = renderSection({ customAddresses: [address] }) + + const trigger = screen.getByRole('combobox') + await user.click(trigger) + const list = screen.getByRole('listbox', { name: 'Network address to advertise' }) + expect(list).toHaveFocus() + expect(trigger).toHaveAttribute('aria-controls', list.id) + const selectedOption = screen.getByRole('option', { name: '100.64.1.20 (tailscale0)' }) + await vi.waitFor(() => expect(list).toHaveAttribute('aria-activedescendant', selectedOption.id)) + await user.keyboard('{ArrowDown}{Enter}') + + expect(onCustomAddressSelect).toHaveBeenCalledWith(address) + }) + + it('supports prefix typeahead across custom addresses', async () => { + const address = 'zebra.example:6768' + const { user, onCustomAddressSelect } = renderSection({ + customAddresses: ['alpha.example:6768', address] + }) + + await user.click(screen.getByRole('combobox')) + await user.keyboard('z') + const list = screen.getByRole('listbox', { name: 'Network address to advertise' }) + const customOption = screen.getByRole('option', { name: `${address} (custom)` }) + await vi.waitFor(() => expect(list).toHaveAttribute('aria-activedescendant', customOption.id)) + await user.keyboard('{Enter}') + + expect(onCustomAddressSelect).toHaveBeenCalledWith(address) + }) + + it('selects the highlighted custom address with Space', async () => { + const address = 'first.example:6768' + const { user, onCustomAddressSelect, onCustomAddressRemove } = renderSection({ + customAddresses: [address] + }) + + await user.click(screen.getByRole('combobox')) + await user.keyboard('{ArrowDown} ') + + expect(onCustomAddressSelect).toHaveBeenCalledWith(address) + expect(onCustomAddressRemove).not.toHaveBeenCalled() + }) + it('generates a pairing code', async () => { const { user, onGenerateQr } = renderSection() await user.click(screen.getByRole('button', { name: 'Generate QR code' })) diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx index 12d63791f61..8367b49fdbe 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -13,8 +13,12 @@ type MobilePairingSetupSectionProps = { canGenerate?: boolean connectionPathControl: ReactNode networkInterfaces: MobileNetworkInterface[] + customAddresses: readonly string[] selectedAddress: string | undefined + selectedAddressIsCustom: boolean onSelectedAddressChange: (address: string) => void + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void refreshingNetworkInterfaces: boolean onRefreshNetworkInterfaces: () => void loading: boolean @@ -28,8 +32,12 @@ export function MobilePairingSetupSection({ canGenerate = true, connectionPathControl, networkInterfaces, + customAddresses, selectedAddress, + selectedAddressIsCustom, onSelectedAddressChange, + onCustomAddressSelect, + onCustomAddressRemove, refreshingNetworkInterfaces, onRefreshNetworkInterfaces, loading, @@ -71,8 +79,12 @@ export function MobilePairingSetupSection({
diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 03bc813188b..948cc96615d 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -26,6 +26,8 @@ type StoreState = { settings: { mobileAutoRestoreFitMs: number | null mobilePairingConnectionMode?: MobilePairingConnectionMode + mobilePairingCustomAddress?: string | null + mobilePairingCustomAddresses?: string[] } updateSettings: (patch: Record) => Promise recordFeatureInteraction: (feature: string) => void @@ -71,12 +73,25 @@ vi.mock('./MobilePairingSetupSection', () => ({ canGenerate?: boolean loading: boolean connectionPathControl: React.ReactNode + networkInterfaces: { name: string; address: string }[] + customAddresses: readonly string[] + selectedAddress: string | undefined + selectedAddressIsCustom: boolean + onSelectedAddressChange: (address: string) => void + onCustomAddressSelect: (address: string) => void + onCustomAddressRemove: (address: string) => void + refreshingNetworkInterfaces: boolean + onRefreshNetworkInterfaces: () => void onGenerateQr: () => void }) => (
{props.connectionMode} {String(props.canGenerate)} {String(props.loading)} + {props.selectedAddress ?? 'none'} + {String(props.selectedAddressIsCustom)} + {props.customAddresses.join(',')} + {String(props.refreshingNetworkInterfaces)} {props.connectionPathControl} {/* Mirror the real Generate gate (loading/canGenerate) so a stuck loading flag surfaces as a disabled control the tests can catch. */} @@ -87,6 +102,22 @@ vi.mock('./MobilePairingSetupSection', () => ({ > Generate + + + +
) })) @@ -376,6 +407,195 @@ describe('MobilePane pairing connection mode', () => { expect(screen.getByTestId('mode')).toHaveTextContent('local-only') }) + it('restores a saved custom address for future pairing codes', async () => { + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: '100.126.117.25:6768' + } + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + const user = userEvent.setup() + render() + + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent('100.126.117.25:6768') + ) + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => + expect(getPairingQR).toHaveBeenCalledWith({ + address: '100.126.117.25:6768', + connectionMode: 'automatic' + }) + ) + }) + + it('persists a custom address and clears it when a discovered address is selected', async () => { + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + const user = userEvent.setup() + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + + await user.click(screen.getByRole('button', { name: 'choose-custom-address' })) + expect(updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: '100.126.117.25:6768', + mobilePairingCustomAddresses: ['100.126.117.25:6768'] + }) + expect(screen.getByTestId('selected-address')).toHaveTextContent('100.126.117.25:6768') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('true') + expect(screen.getByTestId('custom-addresses')).toHaveTextContent('100.126.117.25:6768') + + await user.click(screen.getByRole('button', { name: 'choose-discovered-address' })) + expect(updateSettings).toHaveBeenCalledWith({ mobilePairingCustomAddress: null }) + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.0.0.2') + expect(screen.getByTestId('custom-addresses')).toHaveTextContent('100.126.117.25:6768') + }) + + it('keeps the current pairing code when the active custom address is reselected', async () => { + const customAddress = '100.126.117.25:6768' + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: customAddress, + mobilePairingCustomAddresses: [customAddress] + } + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr')) + getPairingQR.mockClear() + updateSettings.mockClear() + + await user.click(screen.getByRole('button', { name: 'choose-custom-address' })) + + expect(updateSettings).not.toHaveBeenCalled() + expect(getPairingQR).not.toHaveBeenCalled() + expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr') + }) + + it('keeps the current pairing code when only custom address intent changes', async () => { + const address = '100.126.117.25:6768' + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: address, + mobilePairingCustomAddresses: [address] + } + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Tailscale', address }] + }) + const user = userEvent.setup() + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr')) + getPairingQR.mockClear() + updateSettings.mockClear() + + await user.click(screen.getByRole('button', { name: 'choose-discovered-address' })) + + expect(updateSettings).toHaveBeenCalledWith({ mobilePairingCustomAddress: null }) + expect(getPairingQR).not.toHaveBeenCalled() + expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('false') + + updateSettings.mockClear() + await user.click(screen.getByRole('button', { name: 'choose-custom-address' })) + expect(updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: address, + mobilePairingCustomAddresses: [address] + }) + updateSettings.mockClear() + await user.click(screen.getByRole('button', { name: 'remove-custom-address' })) + + expect(updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: [] + }) + expect(getPairingQR).not.toHaveBeenCalled() + expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('false') + }) + + it('removes the selected custom address and falls back to discovery', async () => { + const customAddress = '100.126.117.25:6768' + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: customAddress, + mobilePairingCustomAddresses: [customAddress, 'second.example:6768'] + } + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] + }) + const user = userEvent.setup() + render() + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent(customAddress) + ) + + await user.click(screen.getByRole('button', { name: 'remove-custom-address' })) + + expect(updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: ['second.example:6768'] + }) + expect(screen.getByTestId('selected-address')).toHaveTextContent('10.0.0.2') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('false') + }) + + it('removes an inactive custom address without changing the selection', async () => { + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: 'second.example:6768', + mobilePairingCustomAddresses: ['100.126.117.25:6768', 'second.example:6768'] + } + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'remove-custom-address' })) + + expect(updateSettings).toHaveBeenCalledWith({ + mobilePairingCustomAddresses: ['second.example:6768'] + }) + expect(screen.getByTestId('selected-address')).toHaveTextContent('second.example:6768') + expect(screen.getByTestId('selected-address-is-custom')).toHaveTextContent('true') + }) + + it('keeps a saved custom override when discovery later stops listing it', async () => { + const customAddress = '100.126.117.25:6768' + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingCustomAddress: customAddress + } + mocks.listNetworkInterfaces.mockResolvedValueOnce({ + interfaces: [{ name: 'Tailscale', address: customAddress }] + }) + const user = userEvent.setup() + render() + await waitFor(() => + expect(screen.getByTestId('selected-address')).toHaveTextContent(customAddress) + ) + + let resolveRefresh: ((value: Record) => void) | undefined + mocks.listNetworkInterfaces.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve + }) + ) + await user.click(screen.getByRole('button', { name: 'refresh-addresses' })) + expect(screen.getByTestId('refreshing-addresses')).toHaveTextContent('true') + + resolveRefresh?.({ interfaces: [{ name: 'Ethernet', address: '10.0.0.2' }] }) + await waitFor(() => + expect(screen.getByTestId('refreshing-addresses')).toHaveTextContent('false') + ) + + expect(screen.getByTestId('selected-address')).toHaveTextContent(customAddress) + expect(updateSettings).not.toHaveBeenCalled() + }) + it('discards a Relay QR that resolves after signing out mid-generate', async () => { const user = userEvent.setup() let resolveQr: ((value: Record) => void) | undefined diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index f7f1ae0c8be..00fb7f34399 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -8,10 +8,7 @@ import { usePairedMobileDevices } from '../mobile/paired-mobile-devices' import { useMobilePairingDevicePolling } from './mobile-pairing-device-polling' -import { - selectRefreshedNetworkAddress, - type MobileNetworkInterface -} from './mobile-network-interface-selection' +import type { MobileNetworkInterface } from './mobile-network-interface-selection' import { MobilePairingQrSection } from './MobilePairingQrSection' import { MobilePairedDevicesSection } from './MobilePairedDevicesSection' import { MobileAutoRestoreFitSection } from './MobileAutoRestoreFitSection' @@ -26,6 +23,7 @@ import { } from '../../../../shared/mobile-pairing-connection-mode' import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' import { useMobilePairingConnectionMode } from '../mobile/use-mobile-pairing-connection-mode' +import { useMobilePairingAddressPreference } from '../mobile/use-mobile-pairing-address-preference' export { getMobilePaneSearchEntries } from './mobile-pane-search' export function MobilePane(): React.JSX.Element { @@ -39,7 +37,6 @@ export function MobilePane(): React.JSX.Element { const [loading, setLoading] = useState(false) const [qrEnlarged, setQrEnlarged] = useState(false) const [networkInterfaces, setNetworkInterfaces] = useState([]) - const [selectedAddress, setSelectedAddress] = useState(undefined) const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false) const [codeCopied, setCodeCopied] = useState(false) const [deviceCountAtQr, setDeviceCountAtQr] = useState(null) @@ -55,8 +52,6 @@ export function MobilePane(): React.JSX.Element { // Tracks the mode we last acted on so the connectionMode effect can tell a // cross-window preference sync apart from our own path change. const handledModeRef = useRef(connectionMode) - // Latest address without stale-closure risk inside loadNetworkInterfaces. - const selectedAddressRef = useRef(selectedAddress) // Ref mirrors of QR-visible / loading so invalidatePairing stays stable and // cannot make loadNetworkInterfaces re-fetch on every generate. const qrDisplayedRef = useRef(false) @@ -100,6 +95,19 @@ export function MobilePane(): React.JSX.Element { setRotateNextQr(true) } }, []) + const invalidatePairingAddress = useCallback(() => invalidatePairing(), [invalidatePairing]) + const { + selectedAddress, + selectedAddressIsCustom, + customAddresses, + selectAddress: handleSelectedAddressChange, + selectCustomAddress: handleCustomAddressSelect, + removeCustomAddress: handleCustomAddressRemove, + selectAddressAfterRefresh + } = useMobilePairingAddressPreference({ + networkInterfaces, + onSelectionInvalidated: invalidatePairingAddress + }) // Why: a Relay QR minted while signed in must not linger on a now-signed-out // desktop — Generate is disabled in that state. Invalidate any pending relay @@ -135,17 +143,7 @@ export function MobilePane(): React.JSX.Element { const result = await window.api.mobile.listNetworkInterfaces() if (mountedRef.current) { setNetworkInterfaces(result.interfaces) - const nextAddress = selectRefreshedNetworkAddress( - selectedAddressRef.current, - result.interfaces - ) - if (nextAddress !== selectedAddressRef.current) { - selectedAddressRef.current = nextAddress - setSelectedAddress(nextAddress) - // A refresh moved the active interface; invalidate so a shown QR - // can't keep encoding the previous endpoint. - invalidatePairing() - } + selectAddressAfterRefresh(result.interfaces) } } catch { if (opts.notifyOnError && mountedRef.current) { @@ -162,7 +160,7 @@ export function MobilePane(): React.JSX.Element { } } }, - [mountedRef, invalidatePairing] + [mountedRef, selectAddressAfterRefresh] ) const generateQR = useCallback( @@ -324,16 +322,6 @@ export function MobilePane(): React.JSX.Element { } }, [connectionMode, mountedRef, relayMintFailure, selectedAddress]) - const handleSelectedAddressChange = useCallback( - (address: string): void => { - setSelectedAddress(address) - selectedAddressRef.current = address - // Switching endpoints: a shown QR now encodes the old address. - invalidatePairing() - }, - [invalidatePairing] - ) - // Why: another window can persist a different path; the shared hook syncs // connectionMode here without routing through changeConnectionMode. Treat // that external change like a user path change so a QR for the old policy @@ -409,8 +397,12 @@ export function MobilePane(): React.JSX.Element { /> } networkInterfaces={networkInterfaces} + customAddresses={customAddresses} selectedAddress={selectedAddress} + selectedAddressIsCustom={selectedAddressIsCustom} onSelectedAddressChange={handleSelectedAddressChange} + onCustomAddressSelect={handleCustomAddressSelect} + onCustomAddressRemove={handleCustomAddressRemove} refreshingNetworkInterfaces={refreshingNetworkInterfaces} onRefreshNetworkInterfaces={() => void loadNetworkInterfaces({ notifyOnError: true })} loading={loading} diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index 29a7acdfe84..4911965938a 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -31,12 +31,19 @@ const CHANNEL_DESCRIPTIONS: Record = { } function formatBuildLabel(build: ReleaseBuild): string { + // Why the release's own title wins: the hourly workflow composes it + // (`1.4.163 • 01 • 07-31 13:54 • e698241`), so this row is the same string the + // GitHub releases list shows — one thing to search for in either place, rather + // than two renderings of the same build that have to be matched up by eye. + if (build.name) { + return build.name + } const stamp = parseHourlyVersionStamp(build.version) if (!stamp) { return build.version } - // Why: an hourly's semver tail is an opaque timestamp; show it as local time so - // "which build was that" is answerable at a glance. + // Fallback for hourlies cut before that naming; retention ages them out in ~3 + // days. An hourly's semver tail is an opaque timestamp, so show it as a date. return `${build.version.split('-')[0]} · ${stamp.toLocaleString(undefined, { month: 'short', day: 'numeric', diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 19d8621cbc1..bd7d31f9652 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -592,10 +592,14 @@ export function RuntimeEnvironmentsPane({ await window.api.runtimeEnvironments.disconnect({ selector: environment.id }) // Why: disconnect is non-destructive; keep the saved server but show the // user that this live client is no longer attached to it. - useAppStore.getState().setRuntimeEnvironmentStatus(environment.id, { - status: null, - checkedAt: Date.now() - }) + useAppStore.getState().setRuntimeEnvironmentStatus( + environment.id, + { + status: null, + checkedAt: Date.now() + }, + { suppressDisconnectToast: true } + ) if (mountedRef.current) { setDetailsByEnvironmentId((current) => ({ ...current, diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 71db4c822de..17a251bea15 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -177,6 +177,7 @@ const SETTINGS_NAV_GROUP_BY_ID = new Map( const SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID = 'shortcuts-escape-confirm' const SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS = 2200 +const SETTINGS_TARGET_HIGHLIGHT_MS = 3_000 function getSettingsSectionId( pane: SettingsNavTarget, @@ -372,6 +373,9 @@ function Settings(): React.JSX.Element { getInitialMountedSectionIds ) const [pendingNavRequestTick, setPendingNavRequestTick] = useState(0) + const [highlightedSettingsTargetId, setHighlightedSettingsTargetId] = useState( + null + ) const [quickCommandAddIntentSignal, setQuickCommandAddIntentSignal] = useState(0) const [sshHostAddIntentSignal, setSshHostAddIntentSignal] = useState(0) const [remoteServerAddIntentSignal, setRemoteServerAddIntentSignal] = useState(0) @@ -446,6 +450,17 @@ function Settings(): React.JSX.Element { } }, []) + useEffect(() => { + if (!highlightedSettingsTargetId) { + return + } + const timeout = window.setTimeout( + () => setHighlightedSettingsTargetId(null), + SETTINGS_TARGET_HIGHLIGHT_MS + ) + return () => window.clearTimeout(timeout) + }, [highlightedSettingsTargetId]) + const requestFontSuggestions = useCallback((): void => { if (installedFontsLoadedRef.current || installedFontsLoadPromiseRef.current) { return @@ -661,6 +676,11 @@ function Settings(): React.JSX.Element { } pendingNavSectionRef.current = paneSectionId pendingScrollTargetRef.current = settingsNavigationTarget.sectionId ?? paneSectionId + setHighlightedSettingsTargetId( + settingsNavigationTarget.pane === 'developer-permissions' + ? (settingsNavigationTarget.sectionId ?? null) + : null + ) // Why: ensure Appearance's nested status-bar section is open before scrolling so the row is visible. if (settingsNavigationTarget.pane === 'appearance') { const accordion = resolveAppearanceAccordionDeepLink(settingsNavigationTarget.sectionId) @@ -1696,7 +1716,9 @@ function Settings(): React.JSX.Element { searchEntries={getSectionSearchEntries('developer-permissions')} > {isSectionMounted('developer-permissions') ? ( - + ) : null} ) : null} diff --git a/src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx new file mode 100644 index 00000000000..00e1b728748 --- /dev/null +++ b/src/renderer/src/components/settings/SettingsFormControls.segmented-control.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '../ui/tooltip' +import { SettingsSegmentedControl } from './SettingsFormControls' + +afterEach(cleanup) + +function renderControl(onChange = vi.fn()): { onChange: ReturnType } { + render( + + + value="stable" + onChange={onChange} + options={[ + { value: 'stable', label: 'Stable' }, + { + value: 'hourly', + label: 'Hourly', + disabled: true, + ariaLabel: 'Hourly (macOS only)', + tooltip: 'Hourly builds are produced only for macOS.' + } + ]} + /> + + ) + return { onChange } +} + +describe('SettingsSegmentedControl unavailable options', () => { + // Why: a native disabled button leaves the tab order, so keyboard users can never + // focus it to open the tooltip explaining why the option is unavailable. + it('marks unavailable options aria-disabled so their tooltip stays reachable', async () => { + renderControl() + + const hourly = screen.getByRole('radio', { name: 'Hourly (macOS only)' }) + expect(hourly.hasAttribute('disabled')).toBe(false) + expect(hourly.getAttribute('aria-disabled')).toBe('true') + + fireEvent.focus(hourly) + expect( + await screen.findAllByText('Hourly builds are produced only for macOS.') + ).not.toHaveLength(0) + }) + + it('ignores clicks on unavailable options', () => { + const { onChange } = renderControl() + + fireEvent.click(screen.getByRole('radio', { name: 'Hourly (macOS only)' })) + expect(onChange).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('radio', { name: 'Stable' })) + expect(onChange).toHaveBeenCalledWith('stable') + }) +}) diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index a05146f51f0..6730b4a49fb 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -179,7 +179,10 @@ export function SettingsSegmentedControl({ role="radio" aria-checked={active} aria-label={opt.ariaLabel} - disabled={opt.disabled} + // Why aria-disabled, not disabled: a native disabled button leaves the tab + // order, so keyboard users can never focus it to open the tooltip explaining + // why the option is unavailable. The click guard below keeps it inert. + aria-disabled={opt.disabled} onClick={() => { if (!opt.disabled) { onChange(opt.value) diff --git a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts index f0bab20bf65..26287618412 100644 --- a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts +++ b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts @@ -170,7 +170,11 @@ describe('AgentSkillSetupPanel installed-command call sites', () => { ) expect(source).toContain('buildSkillCommandForRuntime(') - expect(source).toContain('command={skillCommand}') + // The copied string stays bare for POSIX-family shells; the forced-PowerShell + // setup terminal keeps the npx preflight. + expect(source).toContain('writeClipboardText(skillCommand)') + expect(source).toContain('buildSkillSetupTerminalCommand(') + expect(source).toContain('command={setupTerminalCommand}') expect(source).toContain('shellOverride={activeSkillRuntime.terminalShellOverride}') expect(source).not.toContain('command={ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND}') // This terminal auto-pastes with no install gate, so a repair-required runtime diff --git a/src/renderer/src/components/sidebar/WorkspaceSleepMenuItems.tsx b/src/renderer/src/components/sidebar/WorkspaceSleepMenuItems.tsx new file mode 100644 index 00000000000..a9c2013d491 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceSleepMenuItems.tsx @@ -0,0 +1,69 @@ +import type { JSX } from 'react' +import { Moon } from 'lucide-react' +import { DropdownMenuItem } from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' + +type WorkspaceSleepMenuItemsProps = { + isMultiContext: boolean + sleepLabel: string + sleepDisabled: boolean + descendantCount: number + subtreeSleepDisabled: boolean + onSleep: () => void + onSleepSubtree: () => void +} + +export function WorkspaceSleepMenuItems({ + isMultiContext, + sleepLabel, + sleepDisabled, + descendantCount, + subtreeSleepDisabled, + onSleep, + onSleepSubtree +}: WorkspaceSleepMenuItemsProps): JSX.Element { + return ( + <> + + + + + {sleepLabel} + + + + {isMultiContext + ? translate( + 'auto.components.sidebar.WorktreeContextMenu.7d190f7d2b', + 'Close all active panels in the selected workspaces to free up memory and CPU.' + ) + : translate( + 'auto.components.sidebar.WorktreeContextMenu.0918b35e4f', + 'Close all active panels in this workspace to free up memory and CPU.' + )} + + + {!isMultiContext && descendantCount > 0 ? ( + + + + + {translate( + 'auto.components.sidebar.WorktreeContextMenu.sleepWithDescendants', + 'Sleep with Descendants ({{value0}})', + { value0: descendantCount } + )} + + + + {translate( + 'auto.components.sidebar.WorktreeContextMenu.sleepWithDescendantsDescription', + 'Close active panels in this workspace and every nested descendant to free up memory and CPU.' + )} + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts index 7bbcb23b847..31f2e297f68 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest' import { - hasSleepableWorkspaceActivity, isContextWorktreeDeletable, shouldUseNativeContextMenu, shouldIgnoreNestedWorktreeContextMenuScope, @@ -217,28 +216,6 @@ describe('parent picker context menu affordance', () => { }) }) -describe('hasSleepableWorkspaceActivity', () => { - it('treats preserved empty PTY arrays as slept, not live', () => { - expect( - hasSleepableWorkspaceActivity('wt-1', { 'wt-1': [{ id: 'tab-1' }] }, { 'tab-1': [] }, {}) - ).toBe(false) - }) - - it('detects live terminal and browser activity', () => { - expect( - hasSleepableWorkspaceActivity( - 'wt-1', - { 'wt-1': [{ id: 'tab-1' }] }, - { 'tab-1': ['pty-1'] }, - {} - ) - ).toBe(true) - expect(hasSleepableWorkspaceActivity('wt-1', {}, {}, { 'wt-1': [{ id: 'browser-1' }] })).toBe( - true - ) - }) -}) - describe('project removal from workspace context menus', () => { it('routes primary workspace rows to project removal in non-repo grouped views', () => { const gitRepo = { id: 'repo-1' } diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 898e406ed8f..74b6aba635a 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -19,7 +19,6 @@ import { Bell, BellOff, CircleX, - Moon, Pencil, Pin, PinOff, @@ -44,7 +43,6 @@ import type { import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow' import { runSleepWorktrees } from './sleep-worktree-flow' import { activateAndRevealWorktree } from '@/lib/worktree-activation' -import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { getCyclicProjectedWorktreeLineageIds, @@ -57,6 +55,11 @@ import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' import { WorktreeParentPickerPopover } from './WorktreeParentPickerPopover' import { WorktreeDeveloperMenu } from './WorktreeDeveloperMenu' import { getEligibleWorktreeParents } from './worktree-parent-candidates' +import { + hasSleepableWorkspaceActivity, + useWorkspaceLineageMenuActions +} from './workspace-lineage-menu-actions' +import { WorkspaceSleepMenuItems } from './WorkspaceSleepMenuItems' import { isEventTargetInsideCurrentTarget } from './worktree-card-dom-events' import { translate } from '@/i18n/i18n' import { @@ -190,18 +193,6 @@ function getWorktreeParentPickerAnchor( return scope } -function hasSleepableWorkspaceActivity( - worktreeId: string, - tabsByWorktree: Record, - ptyIdsByTabId: Record, - browserTabsByWorktree: Record -): boolean { - const tabs = tabsByWorktree[worktreeId] ?? [] - const hasLiveTerminal = tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) - const hasBrowser = (browserTabsByWorktree[worktreeId] ?? []).length > 0 - return hasLiveTerminal || hasBrowser -} - function shouldRemoveProjectFromContextMenu( repo: Pick | null | undefined, worktree: Pick @@ -403,14 +394,31 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const sleepableWorktrees = useMemo( () => activeContextWorktrees.filter((item) => - hasSleepableWorkspaceActivity(item.id, tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree) + hasSleepableWorkspaceActivity(item.id, { + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree + }) ), [activeContextWorktrees, browserTabsByWorktree, ptyIdsByTabId, tabsByWorktree] ) + const lineageMenuActions = useWorkspaceLineageMenuActions({ + enabled: !isMultiContext, + parent: worktree, + worktrees: allWorktrees, + lineageById: worktreeLineageById, + activity: { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree } + }) + const lineageDescendantCount = lineageMenuActions.descendants.length + const subtreeSleepableWorktrees = lineageMenuActions.sleepableTargets const deletingContext = useMemo( () => activeContextWorktrees.some((item) => deleteStateByWorktreeId[item.id]?.isDeleting), [activeContextWorktrees, deleteStateByWorktreeId] ) + const deletingSubtree = lineageMenuActions.targets.some( + (item) => deleteStateByWorktreeId[item.id]?.isDeleting + ) + const contextDeletePending = isMultiContext ? deletingContext : deletingSubtree const contextWorkspaceStatus = useMemo(() => { const [first, ...rest] = activeContextWorktrees if (!first) { @@ -597,16 +605,24 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ openModal ]) + const sleepWorktreesAfterMenuClose = useCallback( + (worktreeIds: string[]) => { + setMenuOpenState(false) + // Let Radix tear down before sleeping can remount the virtualized sidebar. + window.setTimeout(() => { + void runSleepWorktrees(worktreeIds) + }, 50) + }, + [setMenuOpenState] + ) + const handleCloseTerminals = useCallback(() => { - const worktreeIds = sleepableWorktrees.map((item) => item.id) - setMenuOpenState(false) - // Why: Sleep can remount the sidebar when it clears the active workspace. - // Let Radix finish closing the menu first so its focus/portal teardown - // cannot scroll the virtualized list during that remount. - window.setTimeout(() => { - void runSleepWorktrees(worktreeIds) - }, 50) - }, [setMenuOpenState, sleepableWorktrees]) + sleepWorktreesAfterMenuClose(sleepableWorktrees.map((item) => item.id)) + }, [sleepWorktreesAfterMenuClose, sleepableWorktrees]) + + const handleSleepSubtree = useCallback(() => { + sleepWorktreesAfterMenuClose(subtreeSleepableWorktrees.map((item) => item.id)) + }, [sleepWorktreesAfterMenuClose, subtreeSleepableWorktrees]) const handleDelete = useCallback(() => { // Folder mode handled inline because it routes to a different modal; @@ -771,7 +787,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ /> 0 ? 'w-60' : 'w-52', contentClassName)} sideOffset={0} align="start" onPointerUpCapture={suppressOpeningPointerEvent} @@ -951,28 +967,15 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ ) : null} - - - - - {sleepLabel} - - - - {isMultiContext - ? translate( - 'auto.components.sidebar.WorktreeContextMenu.7d190f7d2b', - 'Close all active panels in the selected workspaces to free up memory and CPU.' - ) - : translate( - 'auto.components.sidebar.WorktreeContextMenu.0918b35e4f', - 'Close all active panels in this workspace to free up memory and CPU.' - )} - - + {/* Why: primary checkout rows can't be git-worktree-removed, so keep a disabled Delete Worktree for parity with non-primary cards and pair it with the enabled Remove Project action below. */} @@ -1005,7 +1008,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ variant="destructive" onSelect={handleDelete} disabled={ - deletingContext || + contextDeletePending || (!isMultiContext && worktree.isMainWorktree && !removesProject) || (isMultiContext && batchDeleteWorktrees.length === 0) } @@ -1019,7 +1022,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ } > - {deletingContext + {contextDeletePending ? translate('auto.components.sidebar.WorktreeContextMenu.b42391d8bf', 'Deleting…') : isMultiContext ? deleteLabel @@ -1033,7 +1036,15 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ 'auto.components.sidebar.WorktreeContextMenu.f5ac91531d', 'Remove Project from Orca' ) - : translate('auto.components.sidebar.WorktreeContextMenu.f4475537d8', 'Delete')} + : lineageDescendantCount > 0 + ? translate( + 'auto.components.sidebar.WorktreeContextMenu.deleteWithDescendants', + 'Delete with Descendants…' + ) + : translate( + 'auto.components.sidebar.WorktreeContextMenu.f4475537d8', + 'Delete' + )} @@ -1071,7 +1082,6 @@ export { CLOSE_ALL_CONTEXT_MENUS_EVENT, WORKTREE_CONTEXT_MENU_SCOPE_ATTR, WORKTREE_NATIVE_CONTEXT_MENU_ATTR, - hasSleepableWorkspaceActivity, isContextWorktreeDeletable, getWorktreeParentPickerAnchor, getWorktreeParentPickerLabel, diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts new file mode 100644 index 00000000000..cc9d4646584 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import { + getWorkspaceLineageMenuActions, + hasSleepableWorkspaceActivity +} from './workspace-lineage-menu-actions' + +function makeWorktree(id: string): Worktree { + return { + id, + instanceId: `${id}-instance`, + repoId: 'repo-1', + path: `/workspaces/${id}`, + head: 'abc123', + branch: id, + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 + } +} + +function makeLineage(child: Worktree, parent: Worktree): WorktreeLineage { + return { + worktreeId: child.id, + worktreeInstanceId: child.instanceId ?? '', + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId ?? '', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } +} + +describe('workspace lineage menu actions', () => { + it('collects recursive descendants and only targets workspaces with active panels for sleep', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + const grandchild = makeWorktree('grandchild') + const actions = getWorkspaceLineageMenuActions({ + parent, + worktrees: [parent, child, grandchild], + lineageById: { + [child.id]: makeLineage(child, parent), + [grandchild.id]: makeLineage(grandchild, child) + }, + activity: { + tabsByWorktree: { + [parent.id]: [{ id: 'parent-tab' }], + [child.id]: [{ id: 'child-tab' }], + [grandchild.id]: [{ id: 'grandchild-tab' }] + }, + ptyIdsByTabId: { + 'parent-tab': [], + 'child-tab': ['child-pty'], + 'grandchild-tab': [] + }, + browserTabsByWorktree: { + [grandchild.id]: [{ id: 'grandchild-browser' }] + } + } + }) + + expect(actions.descendants.map((target) => target.id)).toEqual(['child', 'grandchild']) + expect(actions.targets.map((target) => target.id)).toEqual(['parent', 'child', 'grandchild']) + expect(actions.sleepableTargets.map((target) => target.id)).toEqual(['child', 'grandchild']) + }) + + it('does not expose stale descendants through the recursive action scope', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + + const actions = getWorkspaceLineageMenuActions({ + parent, + worktrees: [parent, child], + lineageById: { + [child.id]: { + ...makeLineage(child, parent), + parentWorktreeInstanceId: 'stale-parent-instance' + } + }, + activity: { + tabsByWorktree: { [child.id]: [{ id: 'child-tab' }] }, + ptyIdsByTabId: { 'child-tab': ['child-pty'] }, + browserTabsByWorktree: {} + } + }) + + expect(actions.descendants).toEqual([]) + expect(actions.sleepableTargets).toEqual([]) + }) +}) + +describe('hasSleepableWorkspaceActivity', () => { + it('treats preserved empty PTY arrays as slept, not live', () => { + expect( + hasSleepableWorkspaceActivity('wt-1', { + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + ptyIdsByTabId: { 'tab-1': [] }, + browserTabsByWorktree: {} + }) + ).toBe(false) + }) + + it('detects live terminal and browser activity', () => { + expect( + hasSleepableWorkspaceActivity('wt-1', { + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + browserTabsByWorktree: {} + }) + ).toBe(true) + expect( + hasSleepableWorkspaceActivity('wt-1', { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] } + }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts new file mode 100644 index 00000000000..64dd1260534 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -0,0 +1,69 @@ +import { useMemo } from 'react' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' + +type WorkspaceActivityMaps = { + tabsByWorktree: Record + ptyIdsByTabId: Record + browserTabsByWorktree: Record +} + +export type WorkspaceLineageMenuActions = { + descendants: Worktree[] + targets: Worktree[] + sleepableTargets: Worktree[] +} + +export function hasSleepableWorkspaceActivity( + worktreeId: string, + { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree }: WorkspaceActivityMaps +): boolean { + const tabs = tabsByWorktree[worktreeId] ?? [] + return ( + tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) || + (browserTabsByWorktree[worktreeId] ?? []).length > 0 + ) +} + +export function getWorkspaceLineageMenuActions(args: { + parent: Worktree + worktrees: readonly Worktree[] + lineageById: Record + activity: WorkspaceActivityMaps +}): WorkspaceLineageMenuActions { + const { descendants } = getWorkspaceDeleteLineage(args.parent, args.worktrees, args.lineageById) + const targets = [args.parent, ...descendants] + return { + descendants, + targets, + sleepableTargets: targets.filter((target) => + hasSleepableWorkspaceActivity(target.id, args.activity) + ) + } +} + +const EMPTY_LINEAGE_MENU_ACTIONS: WorkspaceLineageMenuActions = { + descendants: [], + targets: [], + sleepableTargets: [] +} + +export function useWorkspaceLineageMenuActions( + args: Parameters[0] & { enabled: boolean } +): WorkspaceLineageMenuActions { + const { enabled, parent, worktrees, lineageById, activity } = args + const { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree } = activity + return useMemo( + () => + enabled + ? getWorkspaceLineageMenuActions({ + parent, + worktrees, + lineageById, + activity: { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree } + }) + : EMPTY_LINEAGE_MENU_ACTIONS, + [browserTabsByWorktree, enabled, lineageById, parent, ptyIdsByTabId, tabsByWorktree, worktrees] + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 7d6dae4770c..c5a7f9606b2 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -765,7 +765,7 @@ describe('buildRows with pinned worktrees', () => { ) }) - it('splits all project setups when one host has duplicate checkouts', () => { + it('splits only the surface with duplicate checkouts', () => { const localRepoB: Repo = { ...repo, id: 'repo-local-b', @@ -822,10 +822,143 @@ describe('buildRows with pinned worktrees', () => { expect(headers.map((row) => row.key)).toEqual([ 'project:github:stablyai/orca::setup:repo-1', 'project:github:stablyai/orca::setup:repo-local-b', - 'project:github:stablyai/orca::setup:repo-remote' + 'project:github:stablyai/orca' ]) }) + it('counts projection twins for one directory as one checkout', () => { + const runtimeHostId = 'runtime:m2-air' + const runtimeRepo: Repo = { + ...remoteRepo, + id: 'repo-runtime', + path: '/home/alice/orca-runtime', + connectionId: undefined, + executionHostId: runtimeHostId + } + const runtimeWorktree: Worktree = { + ...remoteWorktree, + id: 'wt-runtime', + repoId: runtimeRepo.id, + path: '/home/alice/orca-runtime-feature' + } + const runtimeSetup: ProjectHostSetup = { + ...projectHostSetups[1]!, + id: runtimeRepo.id, + repoId: runtimeRepo.id, + path: runtimeRepo.path, + hostId: runtimeHostId, + executionHostId: runtimeHostId + } + const derivedSetup: ProjectHostSetup = { + ...projectHostSetups[1]!, + hostId: runtimeHostId, + connectionId: 'intel mac', + executionHostId: runtimeHostId + } + const authoritativeSetup: ProjectHostSetup = { + ...derivedSetup, + id: 'setup-authoritative', + path: `${derivedSetup.path}/`, + connectionId: null, + executionHostId: 'ssh:intel%20mac' + } + const grouping = { + projects: [{ ...project, sourceRepoIds: [remoteRepo.id, runtimeRepo.id] }], + projectHostSetups: [runtimeSetup, derivedSetup, authoritativeSetup] + } + + expect([ + getGroupKeyForWorktree( + 'repo', + remoteWorktree, + new Map([[remoteRepo.id, remoteRepo]]), + null, + undefined, + undefined, + grouping + ), + getGroupKeyForWorktree( + 'repo', + runtimeWorktree, + new Map([[runtimeRepo.id, runtimeRepo]]), + null, + undefined, + undefined, + grouping + ) + ]).toEqual(['project:github:stablyai/orca', 'project:github:stablyai/orca']) + }) + + it('keeps Git hosts grouped when folder setups share the project identity', () => { + const windowsHostId = 'runtime:windows-server' + const windowsRepo: Repo = { + ...repo, + id: 'repo-windows', + path: 'C:\\Users\\neil\\orca\\orca', + executionHostId: windowsHostId + } + const folderRepoA: Repo = { + ...repo, + id: 'folder-qa-a', + path: '/tmp/pr11751-folder-qa', + displayName: 'pr11751-folder-qa', + kind: 'folder' + } + const folderRepoB: Repo = { + ...folderRepoA, + id: 'folder-qa-b', + path: '/tmp/pr11767-runtime-folder', + displayName: 'pr11767-runtime-folder' + } + const setups: ProjectHostSetup[] = [ + projectHostSetups[0]!, + { ...projectHostSetups[0]!, id: 'projection-twin' }, + projectHostSetups[1]!, + { + ...projectHostSetups[0]!, + id: windowsRepo.id, + repoId: windowsRepo.id, + path: windowsRepo.path, + hostId: windowsHostId, + executionHostId: windowsHostId + }, + { + ...projectHostSetups[0]!, + id: folderRepoA.id, + repoId: folderRepoA.id, + path: folderRepoA.path, + displayName: folderRepoA.displayName, + kind: 'folder' + }, + { + ...projectHostSetups[0]!, + id: folderRepoB.id, + repoId: folderRepoB.id, + path: folderRepoB.path, + displayName: folderRepoB.displayName, + kind: 'folder' + } + ] + const repos = [repo, remoteRepo, windowsRepo, folderRepoA, folderRepoB] + const grouping = { + projects: [{ ...project, sourceRepoIds: repos.map((entry) => entry.id) }], + projectHostSetups: setups + } + + const groupKeys = repos.map((entry) => + getGroupKeyForWorktree( + 'repo', + { ...worktree, id: `wt-${entry.id}`, repoId: entry.id, path: entry.path }, + new Map([[entry.id, entry]]), + null, + undefined, + undefined, + grouping + ) + ) + expect(new Set(groupKeys)).toEqual(new Set(['project:github:stablyai/orca'])) + }) + it('keeps a provisioned runtime copy under the project header alongside a same-host checkout', () => { const runtimeRepoB: Repo = { ...repo, diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index cfc0955a398..777e9fcfbe6 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -39,10 +39,14 @@ import { LOCAL_EXECUTION_HOST_ID, getRepoExecutionHostId, getWorktreeExecutionHostId, + toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { parseWslUncPath } from '../../../../shared/wsl-paths' -import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { + isWindowsAbsolutePathLike, + normalizeRuntimePathForComparison +} from '../../../../shared/cross-platform-path' import { getCyclicProjectedWorktreeLineageIds, getLineageRenderInfo @@ -177,28 +181,43 @@ type WorktreeGroupEntry = { type ProjectGroupingIndex = { projectById: Map setupByRepoId: Map - projectIdsRequiringSetupGroups: Set + surfaceKeysRequiringSetupGroups: Set } const projectGroupingIndexCache = new WeakMap() -// Why: `provisioned` setups are ephemeral recipe-created runtime copies that -// nest under the project header; every other method is a real user checkout. See #5374. +// Why: provisioned and folder setups are not independent Git checkouts. function isDistinctUserCheckout(setup: ProjectHostSetup): boolean { - return setup.setupMethod !== 'provisioned' + return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder' } +// Why: execution target and filesystem namespace independently identify a surface. function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string { + return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}` +} + +function getExecutionSurface(setup: ProjectHostSetup): string { + const connectionId = setup.connectionId?.trim() + if (connectionId) { + return toSshExecutionHostId(connectionId) + } + return setup.executionHostId?.trim() || setup.hostId +} + +// Why: projection twins differ by row identity, not checkout directory. +function getCheckoutIdentity(setup: ProjectHostSetup): string { + return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id +} + +function getPathSurface(setup: ProjectHostSetup): string { const wslPath = parseWslUncPath(setup.path) if (wslPath) { - // Why: Windows host and WSL on one machine are separate execution surfaces; - // only duplicate checkouts within one surface make project grouping ambiguous. - return `${setup.projectId}::${setup.hostId}::wsl:${wslPath.distro.toLowerCase()}` + return `wsl:${wslPath.distro.toLowerCase()}` } if (isWindowsAbsolutePathLike(setup.path)) { - return `${setup.projectId}::${setup.hostId}::windows-host` + return 'windows-host' } - return `${setup.projectId}::${setup.hostId}::default` + return 'default' } function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupingIndex | null { @@ -215,9 +234,7 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin projectGroupingIndexCache.set(model, null) return null } - // Count real user checkouts per host surface (provisioned copies excluded so - // they keep nesting); more than one on a surface makes that project ambiguous. - const checkoutsByProjectSurface = new Map() + const checkoutsByProjectSurface = new Map>() for (const setup of projectHostSetups) { if (!isDistinctUserCheckout(setup)) { continue @@ -225,21 +242,21 @@ function buildProjectGroupingIndex(model?: ProjectGroupingModel): ProjectGroupin const key = getProjectSetupSurfaceKey(setup) const existing = checkoutsByProjectSurface.get(key) if (existing) { - existing.count += 1 + existing.add(getCheckoutIdentity(setup)) } else { - checkoutsByProjectSurface.set(key, { projectId: setup.projectId, count: 1 }) + checkoutsByProjectSurface.set(key, new Set([getCheckoutIdentity(setup)])) } } - const projectIdsRequiringSetupGroups = new Set() - for (const { projectId, count } of checkoutsByProjectSurface.values()) { - if (count > 1) { - projectIdsRequiringSetupGroups.add(projectId) + const surfaceKeysRequiringSetupGroups = new Set() + for (const [surfaceKey, checkouts] of checkoutsByProjectSurface) { + if (checkouts.size > 1) { + surfaceKeysRequiringSetupGroups.add(surfaceKey) } } const index = { projectById: new Map(projects.map((project) => [project.id, project])), setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), - projectIdsRequiringSetupGroups + surfaceKeysRequiringSetupGroups } projectGroupingIndexCache.set(model, index) return index @@ -268,11 +285,10 @@ function getProjectGroupingForRepo( } } if ( - projectIndex?.projectIdsRequiringSetupGroups.has(setup.projectId) && + projectIndex?.surfaceKeysRequiringSetupGroups.has(getProjectSetupSurfaceKey(setup)) && isDistinctUserCheckout(setup) ) { - // Why: independent user checkouts of one project on the same host surface - // can't be safely merged, so each keeps its own sidebar entry. See #5374. + // Why: only the ambiguous surface needs checkout-specific headers. return { key: `project:${project.id}::setup:${repoId}`, label: repo?.displayName ?? setup.displayName, diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index d67fb82cf80..42049cb5a25 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -254,7 +254,11 @@ export function SshStatusSegment({ async (environmentId: string): Promise => { try { await window.api.runtimeEnvironments.disconnect({ selector: environmentId }) - setRuntimeEnvironmentStatus(environmentId, { status: null, checkedAt: Date.now() }) + setRuntimeEnvironmentStatus( + environmentId, + { status: null, checkedAt: Date.now() }, + { suppressDisconnectToast: true } + ) recordFeatureInteraction('ssh') } catch (err) { toast.error( diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts index ca5b211d588..718b4cf5cba 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-coordinator.test.ts @@ -40,6 +40,17 @@ function terminalTarget(overrides: Partial = {}): TerminalP } } +function bracketedChunkedPlan() { + return planTerminalPaste({ + text: '0123456789abcdef', + source: 'keyboard', + target: terminalTarget(), + terminalBracketedPasteMode: true, + maxDirectBytes: 4, + maxChunkBytes: 4 + }) +} + function getPastePayloadCorpusText(name: string): string { const entry = PASTE_PAYLOAD_CORPUS.find((item) => item.name === name) if (!entry) { @@ -651,6 +662,133 @@ describe('terminal paste coordinator', () => { ]) }) + it('closes an opened bracketed paste when a payload write is rejected', async () => { + const writes: string[] = [] + const writePty = vi.fn<(data: string) => boolean>((data) => { + writes.push(data) + return data === BRACKETED_PASTE_START + }) + + const result = await executeTerminalPastePlan(bracketedChunkedPlan(), { + pasteText: vi.fn(), + writePty, + isTargetCurrent: () => true, + canContinue: () => true, + yieldToEventLoop: async () => {} + }) + + expect(result).toMatchObject({ status: 'cancelled', reason: 'target-disconnected' }) + expect(writes).toEqual([BRACKETED_PASTE_START, '0123', BRACKETED_PASTE_END]) + }) + + it('closes an opened bracketed paste when a payload write exceeds the safety timeout', async () => { + vi.useFakeTimers() + try { + const writes: string[] = [] + const writePty = vi.fn((data: string) => { + writes.push(data) + return data === BRACKETED_PASTE_START ? true : new Promise(() => {}) + }) + + const execution = executeTerminalPastePlan(bracketedChunkedPlan(), { + pasteText: vi.fn(), + writePty, + isTargetCurrent: () => true, + canContinue: () => true, + yieldToEventLoop: async () => {}, + operationTimeoutMs: 25 + }) + // Why: the payload write and the best-effort close each burn their own budget. + await vi.advanceTimersByTimeAsync(25) + await vi.advanceTimersByTimeAsync(25) + + await expect(execution).resolves.toMatchObject({ + status: 'cancelled', + reason: 'operation-timeout' + }) + expect(writes).toEqual([BRACKETED_PASTE_START, '0123', BRACKETED_PASTE_END]) + } finally { + vi.useRealTimers() + } + }) + + it('reports a timed-out bracketed close on a stale target as an operation timeout', async () => { + vi.useFakeTimers() + try { + let current = true + const writes: string[] = [] + const writePty = vi.fn((data: string) => { + writes.push(data) + return data === BRACKETED_PASTE_END ? new Promise(() => {}) : true + }) + + const execution = executeTerminalPastePlan(bracketedChunkedPlan(), { + pasteText: vi.fn(), + writePty, + isTargetCurrent: () => current, + canContinue: () => true, + yieldToEventLoop: async () => { + current = false + }, + operationTimeoutMs: 25 + }) + await vi.advanceTimersByTimeAsync(25) + await vi.advanceTimersByTimeAsync(25) + + await expect(execution).resolves.toMatchObject({ + status: 'cancelled', + reason: 'operation-timeout' + }) + expect(writes).toEqual([BRACKETED_PASTE_START, BRACKETED_PASTE_END]) + } finally { + vi.useRealTimers() + } + }) + + it('skips the bracketed close when the paste target is already gone', async () => { + // Why: canContinue false means the pane/PTY is unmounted, so an end marker has + // nowhere to land — the frame dies with the target. + let writable = true + const writes: string[] = [] + + const result = await executeTerminalPastePlan(bracketedChunkedPlan(), { + pasteText: vi.fn(), + writePty: (data) => { + writes.push(data) + writable = false + return true + }, + isTargetCurrent: () => true, + canContinue: () => writable, + yieldToEventLoop: async () => {} + }) + + expect(result).toMatchObject({ status: 'cancelled', reason: 'target-disconnected' }) + expect(writes).toEqual([BRACKETED_PASTE_START]) + }) + + it('closes an opened bracketed paste when the PTY writer throws', async () => { + const writes: string[] = [] + const writePty = vi.fn<(data: string) => boolean>((data) => { + writes.push(data) + if (data !== BRACKETED_PASTE_START && data !== BRACKETED_PASTE_END) { + throw new Error('writer gone') + } + return true + }) + + await expect( + executeTerminalPastePlan(bracketedChunkedPlan(), { + pasteText: vi.fn(), + writePty, + isTargetCurrent: () => true, + canContinue: () => true, + yieldToEventLoop: async () => {} + }) + ).rejects.toThrow('writer gone') + expect(writes).toEqual([BRACKETED_PASTE_START, '0123', BRACKETED_PASTE_END]) + }) + it('rejects oversized payloads before touching xterm or the PTY', async () => { const plan = planTerminalPaste({ text: 'x'.repeat(12), diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts index f194f71f171..96d62a062d5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-executor.ts @@ -76,44 +76,81 @@ async function executeTerminalPastePlanNow( let chunksWritten = 0 let bracketedPasteOpen = false - for (const chunk of iterateTerminalPastePlanChunks(plan)) { - if (isTargetCurrent && !isTargetCurrent()) { - if (bracketedPasteOpen && (!canContinue || canContinue())) { - const closeResult = await runTerminalPasteOperationWithTimeout( - () => writePty(BRACKETED_PASTE_END), - operationTimeoutMs - ) - if (closeResult.timedOut) { - return finish('cancelled', chunksWritten, 'operation-timeout') - } - if (closeResult.value) { - chunksWritten += 1 - } - } - return finish('cancelled', chunksWritten, 'stale-target') + // Why: best-effort — a close that hangs or throws must not mask the real exit reason. + const closeBracketedPasteFrame = async (): Promise<{ timedOut: boolean }> => { + if (!bracketedPasteOpen) { + return { timedOut: false } } + bracketedPasteOpen = false if (canContinue && !canContinue()) { - return finish('cancelled', chunksWritten, 'target-disconnected') + return { timedOut: false } } - const writeResult = await runTerminalPasteOperationWithTimeout( - () => writePty(chunk), - operationTimeoutMs - ) - if (writeResult.timedOut) { - return finish('cancelled', chunksWritten, 'operation-timeout') + try { + const closeResult = await runTerminalPasteOperationWithTimeout( + () => writePty(BRACKETED_PASTE_END), + operationTimeoutMs + ) + if (closeResult.timedOut) { + return { timedOut: true } + } + if (closeResult.value) { + chunksWritten += 1 + } + } catch { + // The frame stays open only because the writer itself is gone; nothing left to do. } - if (!writeResult.value) { - return finish('cancelled', chunksWritten, 'target-disconnected') - } - chunksWritten += 1 - if (chunk === BRACKETED_PASTE_START) { - bracketedPasteOpen = true - } else if (chunk === BRACKETED_PASTE_END) { - bracketedPasteOpen = false - } - await yieldToEventLoop() + return { timedOut: false } } - return finish('pasted', chunksWritten) + + const writeChunks = async (): Promise => { + for (const chunk of iterateTerminalPastePlanChunks(plan)) { + if (isTargetCurrent && !isTargetCurrent()) { + return { status: 'cancelled', reason: 'stale-target' } + } + if (canContinue && !canContinue()) { + return { status: 'cancelled', reason: 'target-disconnected' } + } + const writeResult = await runTerminalPasteOperationWithTimeout( + () => writePty(chunk), + operationTimeoutMs + ) + // Why: a failed close chunk is already the close attempt; retrying would emit a stray end. + if (writeResult.timedOut) { + bracketedPasteOpen = bracketedPasteOpen && chunk !== BRACKETED_PASTE_END + return { status: 'cancelled', reason: 'operation-timeout' } + } + if (!writeResult.value) { + bracketedPasteOpen = bracketedPasteOpen && chunk !== BRACKETED_PASTE_END + return { status: 'cancelled', reason: 'target-disconnected' } + } + chunksWritten += 1 + if (chunk === BRACKETED_PASTE_START) { + bracketedPasteOpen = true + } else if (chunk === BRACKETED_PASTE_END) { + bracketedPasteOpen = false + } + await yieldToEventLoop() + } + return { status: 'pasted' } + } + + let outcome!: ChunkedPasteOutcome + let closeTimedOut = false + try { + outcome = await writeChunks() + } finally { + // Why: every exit, including an unexpected writer rejection, must leave the frame closed. + closeTimedOut = (await closeBracketedPasteFrame()).timedOut + } + if (closeTimedOut && outcome.reason === 'stale-target') { + return finish('cancelled', chunksWritten, 'operation-timeout') + } + return finish(outcome.status, chunksWritten, outcome.reason) +} + +type ChunkedPasteOutcome = { + status: TerminalPasteExecutionResult['status'] + reason?: TerminalPasteExecutionReason } export function getTerminalPasteOperationTimeoutMs(plan: TerminalPastePlan): number { diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx index 14ae07e6b51..47becbaa7e0 100644 --- a/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.test.tsx @@ -9,6 +9,7 @@ import { UI_LANGUAGE_SPANISH } from '../../../shared/ui-language' import { useAppStore } from '@/store' import { usePluginLanguagePackStore } from '@/store/plugin-language-packs' import { i18n } from '@/i18n/i18n' +import { FULL_DISK_ACCESS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import { MacosTccPromptNoticeHost } from './MacosTccPromptNoticeHost' import { useMacosTccPromptNotice } from './useMacosTccPromptNotice' @@ -95,7 +96,7 @@ it('isolates plugin language-pack discovery from its parent render path', async expect(subscribeToMacosTccPromptNotice).toHaveBeenCalledOnce() }) -it('keeps the notice open until the user closes it', async () => { +it('connects the notice to the macOS prompt and keeps it open until closed', async () => { useAppStore.setState({ settings: { ...getDefaultSettings('/tmp'), uiLanguage: 'en' } }) @@ -111,6 +112,13 @@ it('keeps the notice open until the user closes it', async () => { showNotice?.({ promptCount: 1 }, acknowledge) + expect(toastWarning).toHaveBeenCalledWith( + 'Seeing “Orca would like to access…” prompts?', + expect.objectContaining({ + description: + 'Permission messages from macOS may appear when an agent or terminal tool running in Orca attempts to access protected files. Grant Full Disk Access in Settings to reduce these prompts.' + }) + ) const options = toastWarning.mock.calls[0]?.[1] as | { duration?: number; onDismiss?: () => void } | undefined @@ -141,4 +149,9 @@ it('acknowledges when opening Settings closes the notice', async () => { | undefined options?.action?.onClick() expect(acknowledge).toHaveBeenCalledOnce() + expect(useAppStore.getState().settingsNavigationTarget).toEqual({ + pane: 'developer-permissions', + repoId: null, + sectionId: FULL_DISK_ACCESS_SETTINGS_TARGET_ID + }) }) diff --git a/src/renderer/src/hooks/useMacosTccPromptNotice.ts b/src/renderer/src/hooks/useMacosTccPromptNotice.ts index 5da212cab49..e62464a0b6f 100644 --- a/src/renderer/src/hooks/useMacosTccPromptNotice.ts +++ b/src/renderer/src/hooks/useMacosTccPromptNotice.ts @@ -6,6 +6,7 @@ import { useAppStore } from '@/store' import { usePluginLanguagePackStore } from '@/store/plugin-language-packs' import { translate } from '@/i18n/i18n' import { resolveUiLocale } from '@/i18n/supported-languages' +import { FULL_DISK_ACCESS_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import { dismissMacosTccPromptNotice, subscribeToMacosTccPromptNotice @@ -41,12 +42,12 @@ export function useMacosTccPromptNotice(): void { toast.warning( translate( 'auto.hooks.useMacosTccPromptNotice.title', - 'Reduce repeated macOS file-access prompts' + 'Seeing “Orca would like to access…” prompts?' ), { description: translate( 'auto.hooks.useMacosTccPromptNotice.description', - 'macOS attributes file access by your agents and terminal tools to Orca. Granting Full Disk Access reduces these prompts.' + 'Permission messages from macOS may appear when an agent or terminal tool running in Orca attempts to access protected files. Grant Full Disk Access in Settings to reduce these prompts.' ), duration: Infinity, onDismiss: acknowledge, @@ -55,7 +56,11 @@ export function useMacosTccPromptNotice(): void { onClick: () => { acknowledge() openSettingsPage() - openSettingsTarget({ pane: 'developer-permissions', repoId: null }) + openSettingsTarget({ + pane: 'developer-permissions', + repoId: null, + sectionId: FULL_DISK_ACCESS_SETTINGS_TARGET_ID + }) } }, cancel: { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 98b6ff3d646..a648e68d409 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -322,6 +322,14 @@ "f518e89aa5": "Project already exists in that profile", "f03ae7f27b": "Failed to transfer project" } + }, + "runtime": { + "status": { + "runtimeHostDisconnectedDescription": "Check that Orca is running on this server and that your network connection is working, then try again.", + "runtimeHostUnreachableNamed": "Can't reach {{hostName}}", + "runtimeHostUnreachable": "Can't reach Orca server", + "tryAgain": "Try again" + } } } }, @@ -826,8 +834,8 @@ "pasteTooLarge": "Paste is too large." }, "useMacosTccPromptNotice": { - "title": "Reduce repeated macOS file-access prompts", - "description": "macOS attributes file access by your agents and terminal tools to Orca. Granting Full Disk Access reduces these prompts.", + "title": "Seeing “Orca would like to access…” prompts?", + "description": "Permission messages from macOS may appear when an agent or terminal tool running in Orca attempts to access protected files. Grant Full Disk Access in Settings to reduce these prompts.", "openSettings": "Open Settings", "dismiss": "Don't show again" } @@ -839,7 +847,11 @@ "9263e75f49": "Codex is using the previous account", "d3e8a1f4b2": "Account switched", "9375620cc3": "Restart this session to use {{value0}}. It stays on the previous account until you do.", - "6133594b12": "Keep old account" + "6133594b12": "Keep old account", + "8f0d5c92a1": "Codex setup changed", + "3ea91b5c07": "This Codex session is using an outdated configuration", + "e6b7139d2a": "Restart this session to load your current Codex configuration.", + "7b1d20f4c8": "Keep current session" }, "FirstLaunchBanner": { "b9e1b966c7": "Dismiss notice", @@ -893,11 +905,6 @@ "83ac703dda": "Assignees", "00ccdf9b5a": "Status", "2aa9acdf34": "Edit labels on GitHub", - "d23bbb6416": "{{value0}} skipped", - "18f80e1329": "{{value0}} pending", - "b1ac991806": "{{value0}} failing", - "checksNeedActionChip": "{{value0}} action required", - "311d0cee55": "{{value0}} passing", "e52bed9264": "No checks reported yet", "90020cc1f3": "This pull request has no reported checks yet.", "ecffebc251": "No checks found", @@ -1391,11 +1398,6 @@ "bc215fea4d": "+ Label", "b936cc51a4": "Closed", "7b8f6bf6d8": "Open", - "e6ad0a8d06": "{{value0}} skipped", - "88267924d5": "{{value0}} pending", - "ae2a34c7b8": "{{value0}} failing", - "checksNeedActionChip": "{{value0}} action required", - "7c5035931a": "{{value0}} passing", "a18d01cda3": "No checks reported yet", "3912daf310": "This pull request has no reported checks yet.", "45877f5089": "No checks found", @@ -4590,7 +4592,10 @@ "setParentWorkspace": "Set Parent Worktree...", "workspaceSection": "Workspace", "primaryDeleteDisabled": "Primary worktree — can't be deleted. Remove the project instead.", - "deleteWorktree": "Delete Worktree" + "deleteWorktree": "Delete Worktree", + "sleepWithDescendants": "Sleep with Descendants ({{value0}})", + "sleepWithDescendantsDescription": "Close active panels in this workspace and every nested descendant to free up memory and CPU.", + "deleteWithDescendants": "Delete with Descendants…" }, "WorktreeList": { "7a8b9c0d1e": "Update required", @@ -5729,7 +5734,7 @@ "e7bb06007c": "LAN", "4a73f5217a": "Apple Events for scripts that control other local apps.", "e119f0d66b": "Automation", - "7ca17b62c8": "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Grant this to Orca and Orca Helper to reduce those prompts. Then quit Orca, end any surviving Orca Helper process, and reopen Orca.", + "7ca17b62c8": "macOS names Orca when the agents it runs read other apps' data, because Orca is the responsible process for terminal commands. Grant this to Orca to reduce those prompts. Then quit and reopen Orca.", "c566bca278": "Full Disk Access", "9f35980756": "Keystroke injection, window control, and UI automation tools.", "5b2f22ca2d": "Accessibility", @@ -11918,7 +11923,9 @@ "NetworkInterfacePicker": { "trigger-label": "Network address to advertise", "custom-option": "{{address}} (custom)", - "add-custom": "Add custom address…" + "add-custom": "Add custom address…", + "custom-section": "Custom", + "remove-custom": "Remove {{address}}" }, "WindowsFirewallNotice": { "repair-success": "Windows Firewall now allows Orca Mobile on private networks", @@ -14378,6 +14385,13 @@ "title": "Workspace created from a local base", "description": "The remote-tracking ref \"{{value0}}\" was unavailable, so Orca used local \"{{value1}}\" instead. This workspace may not include the latest remote changes.", "dismiss": "Got it" + }, + "pr-check-counts": { + "passingChip": "{{value0}} passing", + "failingChip": "{{value0}} failing", + "pendingChip": "{{value0}} pending", + "needsActionChip": "{{value0}} action required", + "unresolvedChip": "{{value0}} unresolved" } }, "i18n": { @@ -14676,5 +14690,19 @@ "unknown": "Restart Orca to try again." } } + }, + "featureTips": { + "voice": { + "demoPrompt": "Review this diff for edge cases and add tests for anything you find.", + "startDictation": "Start dictation", + "agentPromptTitle": "Agent prompt", + "listening": "Listening...", + "focusPaneInstruction": "Focus a terminal, editor, or agent prompt, then press", + "startInstruction": "to start voice dictation. Press", + "stopInstruction": "again to stop.", + "unassignedInstruction": "Assign a dictation shortcut before starting voice dictation in a focused pane.", + "settingsInstruction": "Change the model, dictation mode, or shortcut anytime in", + "settingsLink": "Settings → Voice" + } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ae9d9dcff4a..289d66b826a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -802,8 +802,8 @@ "pasteTooLarge": "El contenido pegado es demasiado grande." }, "useMacosTccPromptNotice": { - "title": "Reduce los avisos repetidos de acceso a archivos de macOS", - "description": "macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.", + "title": "¿Ves avisos de “Orca quiere acceder…”?", + "description": "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.", "openSettings": "Abrir ajustes", "dismiss": "No volver a mostrar" } @@ -869,11 +869,6 @@ "83ac703dda": "Asignados", "00ccdf9b5a": "Estado", "2aa9acdf34": "Editar etiquetas en GitHub", - "d23bbb6416": "{{value0}} omitido", - "18f80e1329": "{{value0}} pendiente", - "b1ac991806": "{{value0}} fallando", - "checksNeedActionChip": "{{value0}} requiere acción", - "311d0cee55": "{{value0}} pasando", "e52bed9264": "Aún no se reportaron checks", "90020cc1f3": "Este pull request aún no tiene checks reportados.", "ecffebc251": "No se encontraron checks", @@ -1363,11 +1358,6 @@ "bc215fea4d": "+ Etiqueta", "b936cc51a4": "Cerrado", "7b8f6bf6d8": "Abierto", - "e6ad0a8d06": "{{value0}} omitido", - "88267924d5": "{{value0}} pendiente", - "ae2a34c7b8": "{{value0}} fallando", - "checksNeedActionChip": "{{value0}} requiere acción", - "7c5035931a": "{{value0}} pasando", "a18d01cda3": "Aún no se reportaron checks", "3912daf310": "Este pull request aún no tiene checks reportados.", "45877f5089": "No se encontraron checks", @@ -5696,7 +5686,7 @@ "e7bb06007c": "LAN", "4a73f5217a": "Apple Events para scripts que controlan otras apps locales.", "e119f0d66b": "Automatización", - "7ca17b62c8": "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.", + "7ca17b62c8": "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.", "c566bca278": "Acceso total al disco", "9f35980756": "Herramientas de inyección de teclas, control de ventanas y automatización de UI.", "5b2f22ca2d": "Accesibilidad", @@ -14289,6 +14279,13 @@ "useManualTerminalWorktreeParking": { "cannotPark": "These terminals cannot be parked safely." } + }, + "pr-check-counts": { + "passingChip": "{{value0}} pasando", + "failingChip": "{{value0}} fallando", + "pendingChip": "{{value0}} pendiente", + "needsActionChip": "{{value0}} requiere acción", + "unresolvedChip": "{{value0}} sin resolver" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ac17b89b683..79b6d5f51b6 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -802,8 +802,8 @@ "pasteTooLarge": "貼り付け内容が大きすぎます。" }, "useMacosTccPromptNotice": { - "title": "macOSのファイルアクセス権限の確認を減らす", - "description": "エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。", + "title": "「Orca がアクセスしようとしています…」という確認が表示されますか?", + "description": "Orca で実行中のエージェントやターミナルツールが保護されたファイルにアクセスしようとすると、macOS の権限メッセージが表示されることがあります。これらの確認を減らすには、設定でフルディスクアクセスを許可してください。", "openSettings": "設定を開く", "dismiss": "今後表示しない" } @@ -869,11 +869,6 @@ "83ac703dda": "担当者", "00ccdf9b5a": "状態", "2aa9acdf34": "GitHub でラベルを編集する", - "d23bbb6416": "{{value0}} はスキップされました", - "18f80e1329": "{{value0}} 保留中", - "b1ac991806": "{{value0}} は失敗しました", - "checksNeedActionChip": "{{value0}} 件の対応が必要", - "311d0cee55": "{{value0}} 通過", "e52bed9264": "まだチェックは報告されていません", "90020cc1f3": "この PR にはまだチェックが報告されていません。", "ecffebc251": "チェックが見つかりませんでした", @@ -1363,11 +1358,6 @@ "bc215fea4d": "+ ラベル", "b936cc51a4": "クローズ", "7b8f6bf6d8": "オープン", - "e6ad0a8d06": "{{value0}} はスキップされました", - "88267924d5": "{{value0}} 保留中", - "ae2a34c7b8": "{{value0}} は失敗しました", - "checksNeedActionChip": "{{value0}} 件の対応が必要", - "7c5035931a": "{{value0}} 通過", "a18d01cda3": "まだチェックは報告されていません", "3912daf310": "この PR にはまだチェックが報告されていません。", "45877f5089": "チェックが見つかりませんでした", @@ -5681,7 +5671,7 @@ "e7bb06007c": "LAN", "4a73f5217a": "他のローカル アプリを制御するスクリプトの Apple Events。", "e119f0d66b": "オートメーション", - "7ca17b62c8": "Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。", + "7ca17b62c8": "Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca にこの権限を許可してください。その後、Orca を終了して再度開いてください。", "c566bca278": "フルディスクアクセス", "9f35980756": "キーストローク インジェクション、ウィンドウ コントロール、UI 自動化ツール。", "5b2f22ca2d": "アクセシビリティ", @@ -14289,6 +14279,13 @@ "useManualTerminalWorktreeParking": { "cannotPark": "These terminals cannot be parked safely." } + }, + "pr-check-counts": { + "passingChip": "{{value0}} 通過", + "failingChip": "{{value0}} は失敗しました", + "pendingChip": "{{value0}} 保留中", + "needsActionChip": "{{value0}} 件の対応が必要", + "unresolvedChip": "{{value0}} 件未解決" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b483967528b..fc471188b89 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -802,8 +802,8 @@ "pasteTooLarge": "붙여넣기 내용이 너무 큽니다." }, "useMacosTccPromptNotice": { - "title": "반복되는 macOS 파일 접근 권한 요청 줄이기", - "description": "에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.", + "title": "“Orca에서 접근하려고 합니다…” 권한 요청이 표시되나요?", + "description": "Orca에서 실행 중인 에이전트나 터미널 도구가 보호된 파일에 접근하려고 하면 macOS 권한 메시지가 표시될 수 있습니다. 이러한 요청을 줄이려면 설정에서 전체 디스크 접근 권한을 허용하세요.", "openSettings": "설정 열기", "dismiss": "다시 표시하지 않기" } @@ -869,11 +869,6 @@ "83ac703dda": "담당자", "00ccdf9b5a": "상태", "2aa9acdf34": "GitHub에서 라벨 편집", - "d23bbb6416": "{{value0}} 건너뜀", - "18f80e1329": "{{value0}} 보류 중", - "b1ac991806": "{{value0}} 실패", - "checksNeedActionChip": "{{value0}}개 조치 필요", - "311d0cee55": "{{value0}} 통과", "e52bed9264": "아직 보고된 체크가 없습니다.", "90020cc1f3": "이 PR에는 아직 보고된 체크가 없습니다.", "ecffebc251": "체크를 찾을 수 없습니다.", @@ -1363,11 +1358,6 @@ "bc215fea4d": "+ 라벨", "b936cc51a4": "닫힘", "7b8f6bf6d8": "열림", - "e6ad0a8d06": "{{value0}} 건너뛰었습니다", - "88267924d5": "{{value0}} 보류 중", - "ae2a34c7b8": "{{value0}} 실패", - "checksNeedActionChip": "{{value0}}개 조치 필요", - "7c5035931a": "{{value0}} 통과", "a18d01cda3": "아직 보고된 체크가 없습니다.", "3912daf310": "이 PR에는 아직 보고된 체크가 없습니다.", "45877f5089": "체크를 찾을 수 없습니다.", @@ -5681,7 +5671,7 @@ "e7bb06007c": "LAN", "4a73f5217a": "다른 로컬 앱을 제어하는 ​​스크립트를 위한 Apple 이벤트.", "e119f0d66b": "자동화", - "7ca17b62c8": "Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.", + "7ca17b62c8": "Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca에 이 권한을 허용하세요. 그런 다음 Orca를 종료했다가 다시 여세요.", "c566bca278": "전체 디스크 접근 권한", "9f35980756": "키 입력 주입, 창 제어 및 UI 자동화 도구입니다.", "5b2f22ca2d": "접근성", @@ -14289,6 +14279,13 @@ "useManualTerminalWorktreeParking": { "cannotPark": "These terminals cannot be parked safely." } + }, + "pr-check-counts": { + "passingChip": "{{value0}} 통과", + "failingChip": "{{value0}} 실패", + "pendingChip": "{{value0}} 보류 중", + "needsActionChip": "{{value0}}개 조치 필요", + "unresolvedChip": "{{value0}}개 미해결" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 75e5cea0482..9a2037479b7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -802,8 +802,8 @@ "pasteTooLarge": "粘贴内容过大。" }, "useMacosTccPromptNotice": { - "title": "减少重复出现的 macOS 文件访问权限提示", - "description": "macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。", + "title": "看到“Orca 想要访问…”提示?", + "description": "当 Orca 中运行的代理或终端工具尝试访问受保护的文件时,macOS 可能会显示权限信息。请在“设置”中授予“完全磁盘访问权限”,以减少此类提示。", "openSettings": "打开设置", "dismiss": "不再显示" } @@ -869,11 +869,6 @@ "83ac703dda": "负责人", "00ccdf9b5a": "状态", "2aa9acdf34": "在 GitHub 上编辑标签", - "d23bbb6416": "{{value0}} 已跳过", - "18f80e1329": "{{value0}} 待处理", - "b1ac991806": "{{value0}} 失败", - "checksNeedActionChip": "{{value0}} 个需要操作", - "311d0cee55": "{{value0}} 通过", "e52bed9264": "尚未报告任何 CI 检查", "90020cc1f3": "此PR尚未报告检查。", "ecffebc251": "未找到检查", @@ -1363,11 +1358,6 @@ "bc215fea4d": "+ 标签", "b936cc51a4": "已关闭", "7b8f6bf6d8": "进行中", - "e6ad0a8d06": "{{value0}} 已跳过", - "88267924d5": "{{value0}} 待处理", - "ae2a34c7b8": "{{value0}} 失败", - "checksNeedActionChip": "{{value0}} 个需要操作", - "7c5035931a": "{{value0}} 通过", "a18d01cda3": "尚未报告任何 CI 检查", "3912daf310": "此PR尚未报告检查。", "45877f5089": "未找到检查", @@ -5681,7 +5671,7 @@ "e7bb06007c": "局域网", "4a73f5217a": "用于控制其他本地应用程序的脚本的 Apple 事件。", "e119f0d66b": "自动化", - "7ca17b62c8": "当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。", + "7ca17b62c8": "当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 授予此权限,以减少此类提示。然后退出并重新打开 Orca。", "c566bca278": "完全磁盘访问权限", "9f35980756": "击键注入、窗口控制和 UI 自动化工具。", "5b2f22ca2d": "无障碍", @@ -13572,10 +13562,10 @@ "2dc9ee84d0": "3小时", "521f77cd58": "1小时", "e5ad263ae5": "30分钟", - "529dc6c0b7": "没有恩典", + "529dc6c0b7": "无宽限期", "3d70c185c8": "如果 Orca 或执行主机在计划时间不可用,Orca 会在该窗口内变得可用时运行一次错过的事件。较旧的错过运行将被跳过。", - "3df53d554a": "错过运行恩典帮助", - "fc089e5fde": "优雅" + "3df53d554a": "错过运行宽限期帮助", + "fc089e5fde": "宽限期" }, "AutomationPrecheckFields": { "d2a2ac89ac": "10分钟", @@ -14289,6 +14279,13 @@ "useManualTerminalWorktreeParking": { "cannotPark": "These terminals cannot be parked safely." } + }, + "pr-check-counts": { + "passingChip": "{{value0}} 通过", + "failingChip": "{{value0}} 失败", + "pendingChip": "{{value0}} 待处理", + "needsActionChip": "{{value0}} 个需要操作", + "unresolvedChip": "{{value0}} 个未解决" } }, "i18n": { diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index 3b1865eae6d..9f31770be7e 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -32,7 +32,9 @@ export async function sendAgentDraftPasteContent( ) } -async function sendAgentDraftPasteContentNow( +// Why: callers that must keep extra PTY writes (e.g. submit Enter) inside the same +// transaction take the lock themselves; taking it again here would deadlock. +export async function sendAgentDraftPasteContentNow( settings: Pick | null | undefined, ptyId: string, content: string, diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index 5f7f71ca94a..e6b7d41951f 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -356,6 +356,35 @@ describe('agent sleep coordinator', () => { expect(shutdown).not.toHaveBeenCalled() }) + it('rechecks dispatch settlement before shutdown', async () => { + vi.useFakeTimers() + const completed = { + ...entry(), + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'completed' as const + } + } + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined), { + agentStatusByPaneKey: { [completed.paneKey]: completed } + }) + startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + + await vi.advanceTimersByTimeAsync(1000) + useAppStore.setState({ + agentStatusByPaneKey: { + [completed.paneKey]: { + ...completed, + orchestration: { ...completed.orchestration, dispatchStatus: 'dispatched' } + } + } + }) + await vi.advanceTimersByTimeAsync(1000) + + expect(shutdown).not.toHaveBeenCalled() + }) + it('restarts confirmation when a foreground terminal visit refreshes idle state', async () => { vi.useFakeTimers() vi.setSystemTime(NOW) diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index d29eaa8aab4..4c3a550642f 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -134,6 +134,67 @@ describe('agent sleep planner', () => { ).toEqual([]) }) + it('blocks done panes until their live subagent roster clears', () => { + const withIdleTeammate = entry({ + subagents: [ + { + id: 'reviewer-1', + agentType: 'reviewer', + state: 'idle', + startedAt: OLD + } + ] + }) + expect( + plannedPaneKeys( + snapshot({ agentStatusByPaneKey: { [withIdleTeammate.paneKey]: withIdleTeammate } }) + ) + ).toEqual([]) + + const cleared = { ...withIdleTeammate, subagents: undefined } + expect( + plannedPaneKeys(snapshot({ agentStatusByPaneKey: { [cleared.paneKey]: cleared } })) + ).toEqual([cleared.paneKey]) + }) + + it.each([undefined, 'pending', 'dispatched'] as const)( + 'blocks orchestration panes while dispatch status is %s', + (dispatchStatus) => { + const orchestrated = entry({ + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + ...(dispatchStatus ? { dispatchStatus } : {}) + } + }) + + expect( + plannedPaneKeys( + snapshot({ agentStatusByPaneKey: { [orchestrated.paneKey]: orchestrated } }) + ) + ).toEqual([]) + } + ) + + it.each(['completed', 'failed', 'circuit_broken'] as const)( + 'allows orchestration panes after authoritative %s settlement', + (dispatchStatus) => { + const orchestrated = entry({ + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus + } + }) + + expect( + plannedPaneKeys( + snapshot({ agentStatusByPaneKey: { [orchestrated.paneKey]: orchestrated } }) + ) + ).toEqual([orchestrated.paneKey]) + } + ) + it('requires the idle threshold and blocks input after done', () => { const fresh = entry({ updatedAt: NOW - 1_000 }) expect( diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 8f33daed474..6920d18ed1e 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -110,6 +110,12 @@ function getEntryTabId(entry: AgentStatusEntry): string | null { return parsePaneKey(entry.paneKey)?.tabId ?? null } +// Why: provider done hooks can fire mid-Dispatch; only runtime-confirmed settlement makes sleep safe. +const hasUnsettledOrUnknownDispatch = ({ orchestration }: AgentStatusEntry): boolean => + orchestration + ? !['completed', 'failed', 'circuit_broken'].includes(orchestration.dispatchStatus ?? '') + : false + function getEligiblePane(args: { entry: AgentStatusEntry tab: TerminalTab @@ -140,6 +146,8 @@ function getEligiblePane(args: { if ( entry.state !== 'done' || entry.interrupted === true || + Boolean(entry.subagents?.length) || + hasUnsettledOrUnknownDispatch(entry) || (sleepingRecord && !hasOnlyLivePiCompatibleRecoveryIdentity) ) { return null diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index c5e4ddd2963..a5149dc79f2 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -8,6 +8,7 @@ import { iterateAgentDraftPasteContentChunks, pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady, + POST_PASTE_SUBMIT_DELAY_MS, sendAgentDraftPasteContent, sendBracketedPasteToRunningAgent, submitPromptToAgentPty @@ -540,6 +541,35 @@ describe('pasteDraftWhenAgentReady', () => { expect(testState.sendRuntimePtyInputVerified).toHaveBeenNthCalledWith(2, {}, 'pty-1', '\r') }) + it('holds the PTY transaction across the paste and its submit Enter', async () => { + const writes: string[] = [] + testState.sendRuntimePtyInputVerified.mockImplementation( + async (_settings: unknown, _ptyId: string, data: string) => { + writes.push(data) + return true + } + ) + + const submit = sendBracketedPasteToRunningAgent({ ptyId: 'pty-1', content: ISSUE_URL }) + await flushMicrotasks() + // Competing chunked paste on the same PTY: it must not open a frame the Enter can land in. + const competing = sendAgentDraftPasteContent( + {}, + 'pty-1', + 'y'.repeat(AGENT_DRAFT_PASTE_DIRECT_MAX_BYTES + 1) + ) + await flushMicrotasks(10) + expect(writes).toEqual([PASTED_ISSUE_URL]) + + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) + await expect(submit).resolves.toBe(true) + await expect(competing).resolves.toBe(true) + + expect(writes.indexOf('\r')).toBe(1) + expect(writes.at(2)).toBe('\x1b[200~') + expect(writes.at(-1)).toBe('\x1b[201~') + }) + it('submits to an exact PTY even when it is not the first PTY in the tab', async () => { testState.appState.ptyIdsByTabId = { 'tab-1': ['pty-left', 'pty-right'] } testState.appState.tabsByWorktree = { diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 86b0472c9c4..44495c82e29 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -10,9 +10,10 @@ import { BRACKETED_PASTE_START, sanitizeTerminalPasteText } from '@/components/terminal-pane/terminal-bracketed-paste' +import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction' import { waitForAgentReady } from './agent-ready-wait' import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' -import { sendAgentDraftPasteContent } from './agent-draft-paste-content' +import { sendAgentDraftPasteContentNow } from './agent-draft-paste-content' import { agentDeliversDraftViaNativePrefill } from './agent-native-draft-prefill' import { waitForAgentDraftInputReady } from './agent-draft-readiness' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' @@ -212,19 +213,20 @@ async function sendBracketedPasteToAgent(args: { }): Promise { const { settings = useAppStore.getState().settings, ptyId, content, submit } = args try { - const pasted = await sendAgentDraftPasteContent(settings, ptyId, content) - if (!pasted) { - return false - } - if (!submit) { - return true - } + // Why: paste + Enter must be one transaction, or a concurrent paste on this PTY + // can slip between them and submit a half-written prompt. + return await runTerminalPtyInputTransaction(ptyId, async () => { + const pasted = await sendAgentDraftPasteContentNow(settings, ptyId, content) + if (!pasted || !submit) { + return pasted + } - // Why: Claude Code can leave a prompt as editable text when paste-end and - // Enter arrive in the same PTY write. Split the submit into the next turn so - // the TUI processes bracketed-paste termination before handling Enter. - await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) - return await sendRuntimePtyInputVerified(settings, ptyId, '\r') + // Why: Claude Code can leave a prompt as editable text when paste-end and + // Enter arrive in the same PTY write. Split the submit into the next turn so + // the TUI processes bracketed-paste termination before handling Enter. + await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) + return await sendRuntimePtyInputVerified(settings, ptyId, '\r') + }) } catch { return false } diff --git a/src/renderer/src/lib/codex-session-restart-route-recheck.test.ts b/src/renderer/src/lib/codex-session-restart-route-recheck.test.ts new file mode 100644 index 00000000000..81891038754 --- /dev/null +++ b/src/renderer/src/lib/codex-session-restart-route-recheck.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { markLiveCodexSessionsForRestart } from './codex-session-restart' + +const ACCOUNT_A = 'account-a@example.com' +const ACCOUNT_B = 'account-b@example.com' +const SYSTEM_DEFAULT = 'System default' +const originalWindow = (globalThis as { window?: typeof window }).window + +function seedRouteNotice(args: { + previousAccountId: string | null + previousAccountLabel: string +}): void { + useAppStore.getState().markCodexRestartNotices([ + { + ptyId: 'pty-1', + previousAccountLabel: args.previousAccountLabel, + nextAccountLabel: SYSTEM_DEFAULT, + previousAccountId: args.previousAccountId, + nextAccountId: null, + homeRouteChanged: true + } + ]) +} + +describe('Codex restored route notice recheck', () => { + beforeEach(() => { + useAppStore.setState({ + settings: null as never, + tabsByWorktree: { + wt1: [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'wt1', + title: 'orca-1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + pendingCodexPaneRestartIds: {}, + codexRestartNoticeByPtyId: {} + }) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + inspectProcess: vi + .fn() + .mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: false }), + confirmForegroundProcess: vi.fn().mockResolvedValue(null) + }, + codexAccounts: { + ...originalWindow?.api?.codexAccounts, + listRecordedPaneLanes: vi.fn().mockResolvedValue({ 'pty-1': 'host' }), + listStalePanes: vi.fn().mockResolvedValue([]) + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('clears the notice when main confirms the launch account and route are restored', async () => { + seedRouteNotice({ previousAccountId: 'account-a', previousAccountLabel: ACCOUNT_A }) + useAppStore.getState().queueCodexPaneRestarts(['pty-1']) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: SYSTEM_DEFAULT, + nextAccountLabel: ACCOUNT_A, + previousAccountId: null, + nextAccountId: 'account-a' + }) + + expect(window.api.codexAccounts.listStalePanes).toHaveBeenCalledWith({ ptyIds: ['pty-1'] }) + expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({}) + expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({}) + }) + + it('keeps the notice while shared-home still differs from real-home', async () => { + seedRouteNotice({ previousAccountId: null, previousAccountLabel: SYSTEM_DEFAULT }) + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ + { + ptyId: 'pty-1', + launchAccountId: null, + activeAccountId: null, + reason: 'home-route-change' + } + ]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: ACCOUNT_B, + nextAccountLabel: SYSTEM_DEFAULT, + previousAccountId: 'account-b', + nextAccountId: null + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: SYSTEM_DEFAULT, + nextAccountLabel: SYSTEM_DEFAULT, + previousAccountId: null, + nextAccountId: null, + homeRouteChanged: true + }) + }) + + it('downgrades the notice when only the launch account remains stale', async () => { + seedRouteNotice({ previousAccountId: 'account-a', previousAccountLabel: ACCOUNT_A }) + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ + { + ptyId: 'pty-1', + launchAccountId: 'account-a', + activeAccountId: 'account-b', + reason: 'account-change' + } + ]) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: SYSTEM_DEFAULT, + nextAccountLabel: ACCOUNT_B, + previousAccountId: null, + nextAccountId: 'account-b' + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_B, + previousAccountId: 'account-a', + nextAccountId: 'account-b' + }) + }) + + it('preserves the notice when the authoritative recheck fails', async () => { + seedRouteNotice({ previousAccountId: 'account-a', previousAccountLabel: ACCOUNT_A }) + vi.mocked(window.api.codexAccounts.listStalePanes).mockRejectedValue(new Error('ipc failed')) + + await markLiveCodexSessionsForRestart({ + previousAccountLabel: SYSTEM_DEFAULT, + nextAccountLabel: ACCOUNT_A, + previousAccountId: null, + nextAccountId: 'account-a' + }) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: ACCOUNT_A, + nextAccountLabel: ACCOUNT_A, + previousAccountId: 'account-a', + nextAccountId: 'account-a', + homeRouteChanged: true + }) + }) +}) diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index 91eca6923e3..5a478df23f7 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -124,6 +124,7 @@ describe('markLiveCodexSessionsForRestart', () => { previousAccountLabel: ACCOUNT_A, nextAccountLabel: ACCOUNT_B }) + expect(window.api.codexAccounts.listStalePanes).not.toHaveBeenCalled() }) it('marks every live Codex split pane and ignores non-Codex panes', async () => { @@ -806,6 +807,27 @@ describe('markRestoredStaleCodexSessionsForRestart', () => { }) }) + it('keeps a system-default home-route change as a restart notice', async () => { + vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ + { + ptyId: 'pty-1', + launchAccountId: null, + activeAccountId: null, + reason: 'home-route-change' + } + ]) + + await markRestoredStaleCodexSessionsForRestart() + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: 'System default', + nextAccountLabel: 'System default', + previousAccountId: null, + nextAccountId: null, + homeRouteChanged: true + }) + }) + it('labels the system default when a pane launched without a managed account', async () => { vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ { ptyId: 'pty-1', launchAccountId: null, activeAccountId: 'account-b' } diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index 9d1faeb2611..0a8ab5ca01a 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -209,16 +209,58 @@ export async function markLiveCodexSessionsForRestart(args: { return } + const currentState = useAppStore.getState() + const restoredRouteNoticePtyIds = liveCodexSessionPtyIds.filter( + (ptyId) => currentState.codexRestartNoticeByPtyId[ptyId]?.homeRouteChanged === true + ) + const restoredRouteNoticePtyIdSet = new Set(restoredRouteNoticePtyIds) + const authoritativeStalePanes = + restoredRouteNoticePtyIds.length === 0 + ? null + : await window.api.codexAccounts + .listStalePanes({ ptyIds: restoredRouteNoticePtyIds }) + .catch(() => null) + const authoritativeStaleByPtyId = authoritativeStalePanes + ? new Map(authoritativeStalePanes.map((pane) => [pane.ptyId, pane])) + : null + if (authoritativeStaleByPtyId) { + for (const ptyId of restoredRouteNoticePtyIds) { + if (!authoritativeStaleByPtyId.has(ptyId)) { + useAppStore.getState().clearCodexRestartNotice(ptyId) + } + } + } + useAppStore.getState().markCodexRestartNotices( - liveCodexSessionPtyIds.map((ptyId) => ({ - ptyId, - previousAccountLabel: args.previousAccountLabel, - nextAccountLabel: args.nextAccountLabel, - ...(args.previousAccountId === undefined - ? {} - : { previousAccountId: args.previousAccountId }), - ...(args.nextAccountId === undefined ? {} : { nextAccountId: args.nextAccountId }) - })) + liveCodexSessionPtyIds.flatMap((ptyId) => { + if (authoritativeStaleByPtyId && restoredRouteNoticePtyIdSet.has(ptyId)) { + const stalePane = authoritativeStaleByPtyId.get(ptyId) + if (!stalePane) { + return [] + } + return [ + { + ptyId, + previousAccountLabel: args.previousAccountLabel, + nextAccountLabel: args.nextAccountLabel, + previousAccountId: stalePane.launchAccountId, + nextAccountId: stalePane.activeAccountId, + homeRouteChanged: stalePane.reason === 'home-route-change' + } + ] + } + return [ + { + ptyId, + previousAccountLabel: args.previousAccountLabel, + nextAccountLabel: args.nextAccountLabel, + ...(args.previousAccountId === undefined + ? {} + : { previousAccountId: args.previousAccountId }), + ...(args.nextAccountId === undefined ? {} : { nextAccountId: args.nextAccountId }) + } + ] + }) ) } @@ -266,7 +308,8 @@ export async function markRestoredStaleCodexSessionsForRestart(args?: { // Why the ids: main decided staleness by id, and the labels can collide. // Passing only labels hands the store a question it cannot answer. previousAccountId: pane.launchAccountId, - nextAccountId: pane.activeAccountId + nextAccountId: pane.activeAccountId, + ...(pane.reason === 'home-route-change' ? { homeRouteChanged: true as const } : {}) })) ) // Why not every stale pane: the bind sweep suppresses a "notified" pane for the diff --git a/src/renderer/src/lib/runtime-session-mirror-targets.test.ts b/src/renderer/src/lib/runtime-session-mirror-targets.test.ts new file mode 100644 index 00000000000..23f76306a7d --- /dev/null +++ b/src/renderer/src/lib/runtime-session-mirror-targets.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { getReachableRuntimeSessionMirrorTargets } from './runtime-session-mirror-targets' + +const environments = [ + { + id: 'online-env', + createdAt: 100, + pairingRevision: 101 + }, + { + id: 'offline-env', + createdAt: 200 + } +] + +describe('getReachableRuntimeSessionMirrorTargets', () => { + it('subscribes only after a runtime health probe succeeds', () => { + expect( + getReachableRuntimeSessionMirrorTargets({ + settings: { activeRuntimeEnvironmentId: 'online-env' }, + repos: [{ id: 'offline-repo', connectionId: null, executionHostId: 'runtime:offline-env' }], + runtimeEnvironments: environments, + runtimeStatusByEnvironmentId: new Map([ + [ + 'online-env', + { + status: { runtimeId: 'runtime-online' }, + connectionGeneration: 3 + } + ], + ['offline-env', { status: null, connectionGeneration: 7 }] + ]) + }) + ).toEqual([ + { + environmentId: 'online-env', + runtimeId: 'runtime-online', + connectionGeneration: 3, + pairingRevision: 101 + } + ]) + }) + + it('waits for both the saved environment and its first successful status', () => { + expect( + getReachableRuntimeSessionMirrorTargets({ + settings: { activeRuntimeEnvironmentId: 'online-env' }, + runtimeEnvironments: environments, + runtimeStatusByEnvironmentId: new Map() + }) + ).toEqual([]) + + expect( + getReachableRuntimeSessionMirrorTargets({ + settings: { activeRuntimeEnvironmentId: 'missing-env' }, + runtimeEnvironments: environments, + runtimeStatusByEnvironmentId: new Map([ + ['missing-env', { status: { runtimeId: 'runtime-missing' } }] + ]) + }) + ).toEqual([]) + }) + + it('uses creation time for environments paired before pairing revisions existed', () => { + expect( + getReachableRuntimeSessionMirrorTargets({ + settings: { activeRuntimeEnvironmentId: 'offline-env' }, + runtimeEnvironments: environments, + runtimeStatusByEnvironmentId: new Map([ + ['offline-env', { status: { runtimeId: 'runtime-recovered' } }] + ]) + }) + ).toEqual([ + { + environmentId: 'offline-env', + runtimeId: 'runtime-recovered', + connectionGeneration: 0, + pairingRevision: 200 + } + ]) + }) +}) diff --git a/src/renderer/src/lib/runtime-session-mirror-targets.ts b/src/renderer/src/lib/runtime-session-mirror-targets.ts new file mode 100644 index 00000000000..8c3c545ff28 --- /dev/null +++ b/src/renderer/src/lib/runtime-session-mirror-targets.ts @@ -0,0 +1,54 @@ +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' +import { getRuntimeSessionMirrorEnvironmentIds } from './runtime-session-mirror-owners' + +type RuntimeMirrorStatus = { + status: { runtimeId: string } | null + connectionGeneration?: number +} + +type RuntimeMirrorEnvironment = { + id: string + createdAt: number + pairingRevision?: number +} + +export type RuntimeSessionMirrorTarget = { + environmentId: string + runtimeId: string + connectionGeneration: number + pairingRevision: number +} + +export type RuntimeSessionMirrorTargetState = Omit< + WorktreeRuntimeOwnerState, + 'runtimeEnvironments' +> & { + runtimeEnvironments?: readonly RuntimeMirrorEnvironment[] + runtimeStatusByEnvironmentId?: ReadonlyMap +} + +export function getReachableRuntimeSessionMirrorTargets( + state: RuntimeSessionMirrorTargetState +): RuntimeSessionMirrorTarget[] { + const environmentById = new Map( + (state.runtimeEnvironments ?? []).map((environment) => [environment.id, environment]) + ) + const targets: RuntimeSessionMirrorTarget[] = [] + for (const environmentId of getRuntimeSessionMirrorEnvironmentIds(state)) { + const status = state.runtimeStatusByEnvironmentId?.get(environmentId) + if (!status?.status) { + continue + } + const environment = environmentById.get(environmentId) + if (!environment) { + continue + } + targets.push({ + environmentId, + runtimeId: status.status.runtimeId, + connectionGeneration: status.connectionGeneration ?? 0, + pairingRevision: environment.pairingRevision ?? environment.createdAt + }) + } + return targets +} diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 3ce9ffea086..45efa2031e2 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -56,6 +56,8 @@ const SETTINGS_NAV_TARGET_SET: ReadonlySet = new Set(SETTINGS_NAV_TARGET const SETTINGS_NAV_INTENT_SET: ReadonlySet = new Set(SETTINGS_NAV_INTENTS) export type SettingsNavTarget = (typeof SETTINGS_NAV_TARGETS)[number] +export const FULL_DISK_ACCESS_SETTINGS_TARGET_ID = 'developer-permissions-full-disk-access' + export type SettingsNavigationTarget = { pane: SettingsNavTarget repoId: string | null diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts index 693af72e69c..83663794307 100644 --- a/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.test.ts @@ -7,10 +7,14 @@ import { } from './workspace-emoji-shortcodes' describe('workspace emoji shortcodes', () => { - it('finds standard emoji by Slack-style shortcode', () => { + it('finds standard emoji by shortcode', () => { expect(searchWorkspaceEmojiShortcodes('wink', 1)).toEqual([{ emoji: '😉', shortcode: 'wink' }]) }) + it('finds the Korean flag by kr shortcode', () => { + expect(searchWorkspaceEmojiShortcodes('kr', 1)).toEqual([{ emoji: '🇰🇷', shortcode: 'kr' }]) + }) + it('ranks an exact shortcode before longer aliases', () => { expect(searchWorkspaceEmojiShortcodes('heart', 3)[0]).toMatchObject({ shortcode: 'heart' @@ -39,6 +43,13 @@ describe('workspace emoji shortcodes', () => { }) }) + it('replaces the completed kr shortcode', () => { + expect(replaceCompletedWorkspaceEmojiShortcode(':kr:', 4)).toEqual({ + value: '🇰🇷', + cursor: 4 + }) + }) + it('leaves unknown completed shortcodes unchanged', () => { expect(replaceCompletedWorkspaceEmojiShortcode(':orca_custom:', 13)).toBeNull() }) diff --git a/src/renderer/src/lib/workspace-emoji-shortcodes.ts b/src/renderer/src/lib/workspace-emoji-shortcodes.ts index e97d0267192..a75da33d2c1 100644 --- a/src/renderer/src/lib/workspace-emoji-shortcodes.ts +++ b/src/renderer/src/lib/workspace-emoji-shortcodes.ts @@ -16,7 +16,14 @@ export type WorkspaceEmojiReplacement = { value: string } -const SHORTCODE_ENTRIES = STANDARD_EMOJI_SHORTCODE_ENTRIES +const WORKSPACE_EMOJI_SHORTCODE_ADDITIONS: readonly WorkspaceEmojiSuggestion[] = [ + { emoji: '🇰🇷', shortcode: 'kr' } +] + +const SHORTCODE_ENTRIES = [ + ...STANDARD_EMOJI_SHORTCODE_ENTRIES, + ...WORKSPACE_EMOJI_SHORTCODE_ADDITIONS +] const EXACT_SHORTCODE = new Map( SHORTCODE_ENTRIES.map(({ emoji, shortcode }) => [shortcode, { emoji, shortcode }]) diff --git a/src/renderer/src/lib/worktree-operation-route.test.ts b/src/renderer/src/lib/worktree-operation-route.test.ts index fe965d9aad0..8f2ef74d3b5 100644 --- a/src/renderer/src/lib/worktree-operation-route.test.ts +++ b/src/renderer/src/lib/worktree-operation-route.test.ts @@ -241,6 +241,98 @@ describe('resolveWorktreeOperationRouteResult', () => { }) }) + describe('active workspace host selection', () => { + it('keeps the HUB transport when the paired SSH worktree is the active workspace', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:hub-private-target', + worktreesByRepo: { 'repo-1': [worktree('ssh:hub-private-target', 'hub-a')] } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:hub-private-target', runtimeEnvironmentId: 'hub-a' } + }) + }) + + it('recovers the HUB transport from the repo for an active mixed-version SSH publication', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:hub-private-target', + repos: [ + { + id: 'repo-1', + connectionId: 'hub-private-target', + executionHostId: 'runtime:hub-a' + } + ], + detectedWorktreesByRepo: { + 'repo-1': { worktrees: [worktree('ssh:hub-private-target')] } + } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:hub-private-target', runtimeEnvironmentId: 'hub-a' } + }) + }) + + it('keeps the selected host authoritative when the same ID exists on two hosts', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'runtime:hub-a', + worktreesByRepo: { 'repo-1': [worktree('local'), worktree('runtime:hub-a')] } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'runtime:hub-a', runtimeEnvironmentId: 'hub-a' } + }) + }) + + it('drops the HUB transport when two HUBs project the active SSH worktree', () => { + expect( + resolveWorktreeOperationRouteResult( + { + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:same-private-target', + worktreesByRepo: { + 'repo-1': [ + worktree('ssh:same-private-target', 'hub-a'), + worktree('ssh:same-private-target', 'hub-b') + ] + } + }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:same-private-target', runtimeEnvironmentId: null } + }) + }) + + it('keeps an unknown active worktree on its selected host', () => { + expect( + resolveWorktreeOperationRouteResult( + { activeWorktreeId: WORKTREE_ID, activeWorkspaceExecutionHostId: 'ssh:ssh-1' }, + WORKTREE_ID + ) + ).toEqual({ + kind: 'resolved', + route: { executionHostId: 'ssh:ssh-1', runtimeEnvironmentId: null } + }) + }) + }) + describe('folder workspaces', () => { const FOLDER_WORKSPACE_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const FOLDER_KEY = `folder:${FOLDER_WORKSPACE_ID}` diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index c2281b28ac0..fadd7284172 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -2,11 +2,11 @@ import type { AppState } from '@/store/types' import { getRepoExecutionHostId, parseExecutionHostId, - toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +import { addRoute, resolveExactWorktreeRoute, routeForOwner } from './worktree-owner-route' import { resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' import { findFolderWorkspaceOwner, @@ -24,7 +24,7 @@ export type WorktreeOperationRouteResolution = | { kind: 'ambiguous' } | { kind: 'missing' } -type WorktreeOperationOwnerRecord = { +export type WorktreeOperationOwnerRecord = { id: string repoId: string hostId?: ExecutionHostId @@ -32,7 +32,7 @@ type WorktreeOperationOwnerRecord = { } // settings/runtimeEnvironments come from FolderWorkspaceRuntimeOwnerState's legacy-owner base. -type WorktreeOperationRouteState = FolderWorkspaceRuntimeOwnerState & { +export type WorktreeOperationRouteState = FolderWorkspaceRuntimeOwnerState & { repos?: readonly Pick[] worktreesByRepo?: Record detectedWorktreesByRepo?: Record @@ -45,83 +45,67 @@ const repoOperationRouteIndexCache = new WeakMap< ReadonlyMap >() -function routeForOwner(owner: { - hostId?: ExecutionHostId - runtimeOwnerEnvironmentId?: string -}): WorktreeOperationRoute | null { - const runtimeOwnerEnvironmentId = owner.runtimeOwnerEnvironmentId?.trim() - if (!owner.hostId && !runtimeOwnerEnvironmentId) { +function ownerRecordsOnHost( + state: WorktreeOperationRouteState, + worktreeId: string, + executionHostId: ExecutionHostId +): WorktreeOperationOwnerRecord[] { + const owners: WorktreeOperationOwnerRecord[] = [] + const catalogs = [ + ...Object.values(state.worktreesByRepo ?? {}), + ...Object.values(state.detectedWorktreesByRepo ?? {}).map((result) => result.worktrees) + ] + for (const worktrees of catalogs) { + for (const worktree of worktrees) { + if ( + worktree.id === worktreeId && + parseExecutionHostId(worktree.hostId)?.id === executionHostId + ) { + owners.push(worktree) + } + } + } + return owners +} + +/** + * The active workspace's host selection is authoritative identity, but it carries no transport: + * an `ssh:` host reached through a paired HUB names the target, not the HUB that proxies it. Keep + * the selected host and recover the runtime owner from the matching owner rows (#11346). + */ +export function resolveActiveWorkspaceRoute( + state: WorktreeOperationRouteState, + worktreeId: string +): WorktreeOperationRoute | null { + const activeHost = + state.activeWorktreeId === worktreeId + ? parseExecutionHostId(state.activeWorkspaceExecutionHostId) + : null + if (!activeHost) { return null } - const parsedHost = parseExecutionHostId(owner.hostId) + if (activeHost.kind === 'runtime') { + return { executionHostId: activeHost.id, runtimeEnvironmentId: activeHost.environmentId } + } + // Why: only an `ssh:` selection can hide a paired HUB owner, so local stays an O(1) hot path. + if (activeHost.kind !== 'ssh') { + return { executionHostId: activeHost.id, runtimeEnvironmentId: null } + } + const environmentIds = new Set() + for (const owner of ownerRecordsOnHost(state, worktreeId, activeHost.id)) { + const resolution = resolveExactWorktreeRoute(state, owner) + if (resolution.kind === 'resolved' && resolution.route.runtimeEnvironmentId) { + environmentIds.add(resolution.route.runtimeEnvironmentId) + } + } + const environmentId = environmentIds.values().next().value return { - executionHostId: owner.hostId ?? null, - runtimeEnvironmentId: - runtimeOwnerEnvironmentId || - (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null) + executionHostId: activeHost.id, + // Why: rival HUBs projecting the same host cannot be disambiguated by the host selection alone. + runtimeEnvironmentId: environmentIds.size === 1 && environmentId ? environmentId : null } } -function addRoute( - routes: Map, - route: WorktreeOperationRoute | null -): void { - if (!route) { - return - } - routes.set(JSON.stringify(route), route) -} - -function resolveRepoRouteForSshOwner( - repos: WorktreeOperationRouteState['repos'], - owner: WorktreeOperationOwnerRecord -): WorktreeOperationRouteResolution { - if (!repos || !owner.hostId) { - return { kind: 'missing' } - } - const routes = new Map() - for (const repo of repos) { - if (repo.id !== owner.repoId) { - continue - } - const connectionHostId = repo.connectionId ? toSshExecutionHostId(repo.connectionId) : null - if (getRepoExecutionHostId(repo) !== owner.hostId && connectionHostId !== owner.hostId) { - continue - } - addRoute(routes, routeForOwner({ hostId: getRepoExecutionHostId(repo) })) - } - const route = routes.values().next().value - if (routes.size === 1 && route) { - return { kind: 'resolved', route } - } - return routes.size > 1 ? { kind: 'ambiguous' } : { kind: 'missing' } -} - -function resolveExactWorktreeRoute( - state: WorktreeOperationRouteState, - owner: WorktreeOperationOwnerRecord -): WorktreeOperationRouteResolution { - const route = routeForOwner(owner) - if (!route) { - return { kind: 'missing' } - } - if (route.runtimeEnvironmentId || parseExecutionHostId(route.executionHostId)?.kind !== 'ssh') { - return { kind: 'resolved', route } - } - // Recover an optional HUB transport only from the repo setup matching the worktree's SSH host. - const repoRoute = resolveRepoRouteForSshOwner(state.repos, owner) - if (repoRoute.kind === 'ambiguous') { - return repoRoute - } - if (repoRoute.kind === 'resolved' && repoRoute.route.runtimeEnvironmentId) { - return { - kind: 'resolved', - route: { ...route, runtimeEnvironmentId: repoRoute.route.runtimeEnvironmentId } - } - } - return { kind: 'resolved', route } -} - export function resolveWorktreeOperationRoute( state: WorktreeOperationRouteState, worktreeId: string @@ -134,18 +118,9 @@ export function resolveWorktreeOperationRouteResult( state: WorktreeOperationRouteState, worktreeId: string ): WorktreeOperationRouteResolution { - const activeHost = - state.activeWorktreeId === worktreeId - ? parseExecutionHostId(state.activeWorkspaceExecutionHostId) - : null - if (activeHost) { - return { - kind: 'resolved', - route: { - executionHostId: activeHost.id, - runtimeEnvironmentId: activeHost.kind === 'runtime' ? activeHost.environmentId : null - } - } + const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) + if (activeRoute) { + return { kind: 'resolved', route: activeRoute } } // Why: folder workspaces are not Git worktrees — they never appear in the worktree/repo // catalogs scanned below, so without this branch a plain local folder workspace reads as an diff --git a/src/renderer/src/lib/worktree-owner-route.ts b/src/renderer/src/lib/worktree-owner-route.ts new file mode 100644 index 00000000000..a370eca651c --- /dev/null +++ b/src/renderer/src/lib/worktree-owner-route.ts @@ -0,0 +1,89 @@ +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId +} from '../../../shared/execution-host' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { + WorktreeOperationOwnerRecord, + WorktreeOperationRoute, + WorktreeOperationRouteResolution, + WorktreeOperationRouteState +} from './worktree-operation-route' + +export function routeForOwner(owner: { + hostId?: ExecutionHostId + runtimeOwnerEnvironmentId?: string +}): WorktreeOperationRoute | null { + const runtimeOwnerEnvironmentId = owner.runtimeOwnerEnvironmentId?.trim() + if (!owner.hostId && !runtimeOwnerEnvironmentId) { + return null + } + const parsedHost = parseExecutionHostId(owner.hostId) + return { + executionHostId: owner.hostId ?? null, + runtimeEnvironmentId: + runtimeOwnerEnvironmentId || + (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null) + } +} + +export function addRoute( + routes: Map, + route: WorktreeOperationRoute | null +): void { + if (!route) { + return + } + routes.set(JSON.stringify(route), route) +} + +function resolveRepoRouteForSshOwner( + repos: WorktreeOperationRouteState['repos'], + owner: WorktreeOperationOwnerRecord +): WorktreeOperationRouteResolution { + if (!repos || !owner.hostId) { + return { kind: 'missing' } + } + const routes = new Map() + for (const repo of repos) { + if (repo.id !== owner.repoId) { + continue + } + const connectionHostId = repo.connectionId ? toSshExecutionHostId(repo.connectionId) : null + if (getRepoExecutionHostId(repo) !== owner.hostId && connectionHostId !== owner.hostId) { + continue + } + addRoute(routes, routeForOwner({ hostId: getRepoExecutionHostId(repo) })) + } + const route = routes.values().next().value + if (routes.size === 1 && route) { + return { kind: 'resolved', route } + } + return routes.size > 1 ? { kind: 'ambiguous' } : { kind: 'missing' } +} + +export function resolveExactWorktreeRoute( + state: WorktreeOperationRouteState, + owner: WorktreeOperationOwnerRecord +): WorktreeOperationRouteResolution { + const route = routeForOwner(owner) + if (!route) { + return { kind: 'missing' } + } + if (route.runtimeEnvironmentId || parseExecutionHostId(route.executionHostId)?.kind !== 'ssh') { + return { kind: 'resolved', route } + } + // Recover an optional HUB transport only from the repo setup matching the worktree's SSH host. + const repoRoute = resolveRepoRouteForSshOwner(state.repos, owner) + if (repoRoute.kind === 'ambiguous') { + return repoRoute + } + if (repoRoute.kind === 'resolved' && repoRoute.route.runtimeEnvironmentId) { + return { + kind: 'resolved', + route: { ...route, runtimeEnvironmentId: repoRoute.route.runtimeEnvironmentId } + } + } + return { kind: 'resolved', route } +} diff --git a/src/renderer/src/lib/worktree-runtime-owner.test.ts b/src/renderer/src/lib/worktree-runtime-owner.test.ts index 410c3b3bebf..995d7c15715 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.test.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.test.ts @@ -579,3 +579,34 @@ describe('getRuntimeSessionMirrorEnvironmentIds', () => { expect(getRuntimeSessionMirrorEnvironmentIds(localOnlyState)).toEqual([]) }) }) + +describe('active workspace host selection', () => { + const PAIRED_HUB_WORKTREE_ID = 'hub-repo::wt-paired' + const pairedHubState: WorktreeRuntimeOwnerState = { + activeWorktreeId: PAIRED_HUB_WORKTREE_ID, + activeWorkspaceExecutionHostId: 'ssh:hub-private-target', + worktreesByRepo: { + 'hub-repo': [ + { + id: PAIRED_HUB_WORKTREE_ID, + repoId: 'hub-repo', + hostId: 'ssh:hub-private-target', + runtimeOwnerEnvironmentId: 'hub-a' + } + ] + } + } + + it('keeps the HUB transport for the active paired SSH worktree', () => { + expect(getRuntimeEnvironmentIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe('hub-a') + expect(getExplicitRuntimeEnvironmentIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe( + 'hub-a' + ) + }) + + it('keeps the selected host authoritative for the active worktree', () => { + expect(getExecutionHostIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe( + 'ssh:hub-private-target' + ) + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index 0ad6b696c07..e322e834d43 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -17,6 +17,7 @@ import { getRuntimeEnvironmentIdForFolderWorkspace } from './folder-workspace-runtime-owner' import { + resolveActiveWorkspaceRoute, resolveExplicitWorktreeOperationRouteResult, resolveWorktreeOperationRouteResult } from './worktree-operation-route' @@ -62,9 +63,9 @@ export function getRuntimeEnvironmentIdForWorktree( if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { return null } - const activeHost = parseExecutionHostId(getActiveWorkspaceExecutionHostId(state, worktreeId)) - if (activeHost) { - return activeHost.kind === 'runtime' ? activeHost.environmentId : null + const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) + if (activeRoute) { + return activeRoute.runtimeEnvironmentId } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { @@ -113,9 +114,9 @@ export function getExplicitRuntimeEnvironmentIdForWorktree( if (!worktreeId) { return null } - const activeHost = parseExecutionHostId(getActiveWorkspaceExecutionHostId(state, worktreeId)) - if (activeHost) { - return activeHost.kind === 'runtime' ? activeHost.environmentId : null + const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId) + if (activeRoute) { + return activeRoute.runtimeEnvironmentId } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index 0ca21521f12..2365b25c3b7 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -428,6 +428,29 @@ describe('getRuntimeMobileSessionSyncKey', () => { expect(canSkipRuntimeMobileSessionSyncKeyBuild(after, before)).toBe(false) }) + it('changes and does not skip when a folder workspace is removed', () => { + const sharedOverrides = makeSharedOverrides() + const folderWorkspace = { + id: 'folder-1' + } as AppState['folderWorkspaces'][number] + const before = makeState({ + ...sharedOverrides, + folderWorkspaces: [folderWorkspace] + }) + const after = makeState({ + ...sharedOverrides, + folderWorkspaces: [] + }) + + expect(canSkipRuntimeMobileSessionSyncKeyBuild(after, before)).toBe(false) + expect( + runtimeMobileSessionSyncKeysEqual( + getRuntimeMobileSessionSyncKey(before), + getRuntimeMobileSessionSyncKey(after) + ) + ).toBe(false) + }) + it('changes when explicit agent status epoch changes', () => { const sharedOverrides = makeSharedOverrides() const before = getRuntimeMobileSessionSyncKey( @@ -596,6 +619,54 @@ describe('getRuntimeMobileSessionSyncKey', () => { }) describe('buildMobileSessionTabSnapshots', () => { + it('does not publish state for a removed folder workspace', () => { + const staleFolderKey = 'folder:removed-folder' + const state = makeState({ + folderWorkspaces: [], + tabsByWorktree: { + [staleFolderKey]: [{ id: 'term-1', title: 'Terminal 1' }] + } as unknown as AppState['tabsByWorktree'] + }) + + expect(buildMobileSessionTabSnapshots(state)).toEqual([]) + }) + + it('publishes state for a live folder workspace', () => { + const folderWorkspaceId = 'live-folder' + const folderKey = `folder:${folderWorkspaceId}` + const state = makeState({ + folderWorkspaces: [{ id: folderWorkspaceId } as AppState['folderWorkspaces'][number]], + tabsByWorktree: { + [folderKey]: [{ id: 'term-1', title: 'Terminal 1' }] + } as unknown as AppState['tabsByWorktree'] + }) + + expect(buildMobileSessionTabSnapshots(state)).toEqual([ + expect.objectContaining({ worktree: folderKey }) + ]) + }) + + it('evicts a removed folder workspace from the snapshot cache', () => { + const folderWorkspaceId = 'cache-eviction-folder' + const folderKey = `folder:${folderWorkspaceId}` + const liveState = makeState({ + folderWorkspaces: [{ id: folderWorkspaceId } as AppState['folderWorkspaces'][number]], + tabsByWorktree: { + [folderKey]: [{ id: 'term-1', title: 'Terminal 1' }] + } as unknown as AppState['tabsByWorktree'] + }) + const removedState = makeState({ + folderWorkspaces: [], + tabsByWorktree: liveState.tabsByWorktree + }) + + const initial = buildMobileSessionTabSnapshots(liveState)[0]! + expect(buildMobileSessionTabSnapshots(removedState)).toEqual([]) + const restored = buildMobileSessionTabSnapshots(liveState)[0]! + + expect(restored.snapshotVersion).toBeGreaterThan(initial.snapshotVersion) + }) + it('publishes browser and editor color + pin state from unified tabs', () => { const fileId = '/repo/README.md' const state = makeState({ diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 3b256b2e884..6a7db6cf022 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -25,6 +25,7 @@ import type { import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import { isClaudeManagementTitle } from '../../../shared/agent-detection' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { Tab, TabGroup, @@ -247,6 +248,7 @@ export type RuntimeMobileSessionSyncKey = { terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] runtimePaneTitlesByTabId: AppState['runtimePaneTitlesByTabId'] nativeChatLaunchDraftByTabId: AppState['nativeChatLaunchDraftByTabId'] + folderWorkspaces: AppState['folderWorkspaces'] groupsByWorktree: AppState['groupsByWorktree'] activeGroupIdByWorktree: AppState['activeGroupIdByWorktree'] layoutByWorktree: AppState['layoutByWorktree'] @@ -303,6 +305,7 @@ export function canSkipRuntimeMobileSessionSyncKeyBuild( state.terminalLayoutsByTabId === previousState.terminalLayoutsByTabId && state.runtimePaneTitlesByTabId === previousState.runtimePaneTitlesByTabId && state.nativeChatLaunchDraftByTabId === previousState.nativeChatLaunchDraftByTabId && + state.folderWorkspaces === previousState.folderWorkspaces && state.agentStatusEpoch === previousState.agentStatusEpoch && state.agentStatusByPaneKey === previousState.agentStatusByPaneKey ) @@ -340,6 +343,7 @@ export function getRuntimeMobileSessionSyncKey( terminalLayoutsByTabId: state.terminalLayoutsByTabId, runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, nativeChatLaunchDraftByTabId: state.nativeChatLaunchDraftByTabId, + folderWorkspaces: state.folderWorkspaces, groupsByWorktree: state.groupsByWorktree, activeGroupIdByWorktree: state.activeGroupIdByWorktree, layoutByWorktree: state.layoutByWorktree ?? EMPTY_LAYOUT_BY_WORKTREE, @@ -585,6 +589,7 @@ export function runtimeMobileSessionSyncKeysEqual( a.terminalLayoutsByTabId === b.terminalLayoutsByTabId && a.runtimePaneTitlesByTabId === b.runtimePaneTitlesByTabId && a.nativeChatLaunchDraftByTabId === b.nativeChatLaunchDraftByTabId && + a.folderWorkspaces === b.folderWorkspaces && a.groupsByWorktree === b.groupsByWorktree && a.activeGroupIdByWorktree === b.activeGroupIdByWorktree && a.layoutByWorktree === b.layoutByWorktree && @@ -757,6 +762,9 @@ export function buildMobileSessionTabSnapshots( // Why: high-frequency title ticks fire mobile sync; cache indexes/hashes by store-slice ref to skip rescanning editor state. const openFileIndexes = getOpenFileIndexes(state.openFiles) const editorDraftVersionByFileId = getEditorDraftVersionByFileId(state.editorDrafts) + const liveFolderWorkspaceIds = new Set( + (state.folderWorkspaces ?? []).map((workspace) => workspace.id) + ) const worktreeIds = new Set([ ...Object.keys(state.tabsByWorktree), ...Object.keys(state.groupsByWorktree), @@ -767,6 +775,14 @@ export function buildMobileSessionTabSnapshots( const snapshots: RuntimeMobileSessionTabsSnapshot[] = [] for (const worktreeId of worktreeIds) { + const workspaceScope = parseWorkspaceKey(worktreeId) + if ( + workspaceScope?.type === 'folder' && + !liveFolderWorkspaceIds.has(workspaceScope.folderWorkspaceId) + ) { + mobileSessionSnapshotCacheByWorktree.delete(worktreeId) + continue + } const activeGroupId = state.activeGroupIdByWorktree[worktreeId] ?? null const terminalTabByIdForWorktree = new Map( (state.tabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab]) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index cafe434aa94..0a52cdadc06 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -33,10 +33,8 @@ import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/sta import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream' import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership' -import { - getExplicitRuntimeEnvironmentIdForWorktree, - getRuntimeSessionMirrorEnvironmentIds -} from '@/lib/worktree-runtime-owner' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getReachableRuntimeSessionMirrorTargets } from '@/lib/runtime-session-mirror-targets' import { createWebRuntimeSessionTerminal, HOST_TERMINAL_SURFACE_SEPARATOR, @@ -2694,17 +2692,11 @@ export function applyWebSessionTabsStorePatch( export function useWebSessionTabsSync(): void { const activeWorktreeId = useAppStore((state) => state.activeWorktreeId) const runtimeSessionMirrorEnvironmentKey = useAppStore((state) => - getRuntimeSessionMirrorEnvironmentIds(state) - .map((environmentId) => { - const status = state.runtimeStatusByEnvironmentId.get(environmentId) - const environment = state.runtimeEnvironments.find( - (candidate) => candidate.id === environmentId - ) - const pairingRevision = environment - ? (environment.pairingRevision ?? environment.createdAt) - : '' - return `${environmentId}\u0001${status?.status?.runtimeId ?? ''}\u0001${status?.connectionGeneration ?? 0}\u0001${pairingRevision}` - }) + getReachableRuntimeSessionMirrorTargets(state) + .map( + ({ environmentId, runtimeId, connectionGeneration, pairingRevision }) => + `${environmentId}\u0001${runtimeId}\u0001${connectionGeneration}\u0001${pairingRevision}` + ) .join('\u0000') ) const activeWorktreeRuntimeEnvironmentId = useAppStore((state) => diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index ca9248a3ab5..345a25c0eec 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it } from 'vitest' import type { Repo, TerminalTab, Worktree } from '../../../shared/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import { isGitRepoKind } from '../../../shared/repo-kind' import type { AppState } from './types' import { getAllWorktreesFromState, @@ -288,6 +289,120 @@ describe('store selectors', () => { ).toBeNull() }) + it('keeps the repo when a paired-hub worktree reports a different execution host', () => { + // Why: withRepoHostOwnership deliberately keeps an SSH worktree's own host while its repo stays hub-owned. + const repo = makeRepo({ + id: 'hub-repo', + path: '/hub/repo', + displayName: 'hub', + executionHostId: toRuntimeExecutionHostId('hub-a') + }) + const local = makeRepo({ + id: 'hub-repo', + path: '/local/repo', + displayName: 'local', + executionHostId: 'local' + }) + const activeState = { + activeRepoId: 'hub-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('hub-private-target') + } + + for (const repos of [[repo], [local, repo], [repo, local]]) { + expect(selectRepoByIdForActiveWorkspace({ ...activeState, repos }, 'hub-repo')).toBe(repo) + } + // Why: useGitStatusPolling gates every lane on this exact expression, so a null repo silently stops polling. + const activeRepo = selectRepoByIdForActiveWorkspace( + { ...activeState, repos: [repo] }, + 'hub-repo' + ) + expect(activeRepo ? isGitRepoKind(activeRepo) : false).toBe(true) + }) + + it('fails a paired-hub repo fallback closed when rival HUB rows share the repo ID', () => { + const repoForHub = (environmentId: string) => + makeRepo({ + id: 'hub-repo', + path: `/hub/${environmentId}/repo`, + displayName: environmentId, + executionHostId: toRuntimeExecutionHostId(environmentId) + }) + + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [repoForHub('hub-a'), repoForHub('hub-b')], + activeRepoId: 'hub-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('shared-private-target') + }, + 'hub-repo' + ) + ).toBeNull() + }) + + it('still prefers the host-matching row when one repo ID spans hosts', () => { + const hub = makeRepo({ + id: 'shared-repo', + path: '/hub/repo', + displayName: 'hub', + executionHostId: toRuntimeExecutionHostId('hub-a') + }) + const ssh = makeRepo({ + id: 'shared-repo', + path: '/ssh/repo', + displayName: 'ssh', + executionHostId: toSshExecutionHostId('hub-private-target') + }) + + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [hub, ssh], + activeRepoId: 'shared-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('hub-private-target') + }, + 'shared-repo' + ) + ).toBe(ssh) + }) + + it('keeps every non-paired-hub host mismatch failing closed', () => { + // Why: the paired-hub exemption is ssh-selection-over-runtime-repo only; anything else is a wrong host. + const runtime = makeRepo({ + id: 'same-repo', + path: '/runtime/repo', + displayName: 'runtime', + executionHostId: toRuntimeExecutionHostId('env-1') + }) + const ssh = makeRepo({ + id: 'ssh-repo', + path: '/ssh/repo', + displayName: 'ssh', + executionHostId: toSshExecutionHostId('target-1') + }) + + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [runtime], + activeRepoId: 'same-repo', + activeWorkspaceExecutionHostId: toRuntimeExecutionHostId('env-2') + }, + 'same-repo' + ) + ).toBeNull() + expect( + selectRepoByIdForActiveWorkspace( + { + repos: [ssh], + activeRepoId: 'ssh-repo', + activeWorkspaceExecutionHostId: toSshExecutionHostId('target-2') + }, + 'ssh-repo' + ) + ).toBeNull() + }) + it('prefers hydrated project host setup state when present', () => { const repos = [ makeRepo({ diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 1904074f487..3e41f905875 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow' import type { Repo, Worktree, TerminalTab } from '../../../shared/types' import type { AppState } from './types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { getRepoExecutionHostId } from '../../../shared/execution-host' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' import { getProjectHostSetupProjectionFromState } from './project-host-setup-selector' import { getIndexedAllWorktrees as getCachedAllWorktrees, @@ -181,16 +181,26 @@ export function selectRepoByIdForActiveWorkspace( if (!repoId) { return null } + const repo = getCachedRepoMap(state.repos).get(repoId) ?? null if (repoId === state.activeRepoId && state.activeWorkspaceExecutionHostId) { - return ( - state.repos.find( - (repo) => - repo.id === repoId && - getRepoExecutionHostId(repo) === state.activeWorkspaceExecutionHostId - ) ?? null + const repoCandidates = state.repos.filter((candidate) => candidate.id === repoId) + const hostMatch = repoCandidates.find( + (candidate) => getRepoExecutionHostId(candidate) === state.activeWorkspaceExecutionHostId ) + if (hostMatch) { + return hostMatch + } + // Why: withRepoHostOwnership keeps a paired-hub worktree on its own SSH host while the repo + // stays hub-owned, so that one mismatch still names the right repo; every other stays closed. + if (parseExecutionHostId(state.activeWorkspaceExecutionHostId)?.kind !== 'ssh') { + return null + } + const pairedHubRepos = repoCandidates.filter( + (candidate) => parseExecutionHostId(getRepoExecutionHostId(candidate))?.kind === 'runtime' + ) + return pairedHubRepos.length === 1 ? pairedHubRepos[0] : null } - return getCachedRepoMap(state.repos).get(repoId) ?? null + return repo } export const useRepoById = (repoId: string | null) => diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index ca9bb491218..6954fcc7034 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -265,6 +265,63 @@ describe('agent status runtime orchestration metadata', () => { }) }) + it('updates runtime status for the same dispatch', () => { + vi.useFakeTimers() + const store = createTestStore() + const childPaneKey = 'tab-child:11111111-1111-4111-8111-111111111111' + + store.getState().setAgentStatus(childPaneKey, { + state: 'done', + prompt: 'child agent', + agentType: 'claude', + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'dispatched' + } + }) + store.getState().setRuntimeAgentOrchestrationByPaneKey({ + [childPaneKey]: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'completed' + } + }) + + expect(store.getState().agentStatusByPaneKey[childPaneKey].orchestration).toMatchObject({ + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'completed' + }) + }) + + it.each(['failed', 'circuit_broken'] as const)( + 'updates runtime status to %s for the same dispatch', + (dispatchStatus) => { + vi.useFakeTimers() + const store = createTestStore() + const childPaneKey = 'tab-child:11111111-1111-4111-8111-111111111111' + + store.getState().setAgentStatus(childPaneKey, { + state: 'done', + prompt: 'child agent', + agentType: 'claude', + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'dispatched' + } + }) + store.getState().setRuntimeAgentOrchestrationByPaneKey({ + [childPaneKey]: { taskId: 'task-1', dispatchId: 'ctx-1', dispatchStatus } + }) + + expect( + store.getState().agentStatusByPaneKey[childPaneKey].orchestration?.dispatchStatus + ).toBe(dispatchStatus) + } + ) + it('keeps current payload orchestration ahead of a stale runtime map entry', () => { vi.useFakeTimers() const store = createTestStore() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 578b3ca5e67..dc2fde9f603 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1090,6 +1090,7 @@ function orchestrationContextsEqual( return ( a.taskId === b.taskId && a.dispatchId === b.dispatchId && + a.dispatchStatus === b.dispatchStatus && a.taskTitle === b.taskTitle && a.displayName === b.displayName && a.parentTerminalHandle === b.parentTerminalHandle && diff --git a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts index 17932185516..abf1904cc19 100644 --- a/src/renderer/src/store/slices/cmd-j-create-actions.test.ts +++ b/src/renderer/src/store/slices/cmd-j-create-actions.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { AppState } from '../types' import { createTestStore, makeWorktree, seedStore, TEST_REPO } from './store-test-helpers' @@ -15,6 +16,11 @@ vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) +vi.mock('@/lib/web-client-location', () => ({ + isWebClientLocation: () => + Boolean((globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) +})) + const pairedWebFlag = globalThis as { __ORCA_WEB_CLIENT__?: boolean } function seedActiveWorkspace(store: ReturnType): void { @@ -165,6 +171,61 @@ describe('Cmd+J lifted creation actions', () => { expect(store.getState().tabsByWorktree['wt-1'] ?? []).toEqual([]) }) + it('does not create a local folder terminal while paired ownership is unresolved', async () => { + const folderId = 'folder-1' + const workspaceKey = folderWorkspaceKey(folderId) + const store = createTestStore() + seedStore(store, { + activeWorktreeId: workspaceKey, + settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], + folderWorkspaces: [ + { + id: folderId, + projectGroupId: 'group-1', + name: 'Folder workspace', + folderPath: '/tmp/folder', + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + ], + projectGroups: [ + { + id: 'group-1', + name: 'Folder group', + parentPath: '/tmp', + connectionId: null, + executionHostId: null, + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + ], + groupsByWorktree: { + [workspaceKey]: [ + { id: 'tab-group-1', worktreeId: workspaceKey, activeTabId: null, tabOrder: [] } + ] + }, + activeGroupIdByWorktree: { [workspaceKey]: 'tab-group-1' } + }) + + await store.getState().openNewTerminalTabInActiveWorkspace('tab-group-1') + + expect(createWebRuntimeSessionTerminalMock).not.toHaveBeenCalled() + expect(store.getState().tabsByWorktree[workspaceKey] ?? []).toEqual([]) + }) + it('creates desktop remote-server terminal tabs through the owning runtime', async () => { delete pairedWebFlag.__ORCA_WEB_CLIENT__ createWebRuntimeSessionTerminalMock.mockResolvedValue(false) diff --git a/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts index 6e8f0ede98f..a13bbdeff7f 100644 --- a/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts +++ b/src/renderer/src/store/slices/codex-restart-notice-lifecycle.test.ts @@ -186,6 +186,27 @@ describe('codex restart notices with colliding account labels', () => { expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toBeUndefined() }) + it('does not collapse an equal-account home-route change', () => { + useAppStore.getState().markCodexRestartNotices([ + { + ptyId: 'pty-1', + previousAccountLabel: 'System default', + nextAccountLabel: 'System default', + previousAccountId: null, + nextAccountId: null, + homeRouteChanged: true + } + ]) + + expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({ + previousAccountLabel: 'System default', + nextAccountLabel: 'System default', + previousAccountId: null, + nextAccountId: null, + homeRouteChanged: true + }) + }) + it('re-asks a dismissed pane when a same-labelled third account is selected', () => { switchAccountById('pty-1', 'account-a', 'account-b') useAppStore.getState().dismissCodexRestartNotices(['pty-1']) diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index d2ec08ce45d..bd93507ff41 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -4,13 +4,21 @@ import type { PreflightStatus } from '../../../../preload/api-types' import type { Repo, Worktree } from '../../../../shared/types' import type { AppState } from '../types' import { createPreflightSlice } from './preflight' +import { createRuntimeStatusSlice } from './runtime-status' const preflightCheck = vi.fn() const callRuntimeRpc = vi.fn() const platformGet = vi.fn(() => ({ platform: 'linux' })) +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), dismiss: vi.fn() } +})) + vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: (...args: unknown[]) => callRuntimeRpc(...args), + clearRecentRuntimeCompatibilityFailure: vi.fn(), + clearRuntimeCompatibilityCache: vi.fn(), + unwrapRuntimeRpcResult: (response: { result?: T }) => response.result as T, getActiveRuntimeTarget: ( settings?: { activeRuntimeEnvironmentId?: string | null } | null ): { kind: 'local' } | { kind: 'environment'; environmentId: string } => { @@ -43,7 +51,8 @@ function createTestStore() { return create()( (...a) => ({ - ...createPreflightSlice(...a) + ...createPreflightSlice(...a), + ...createRuntimeStatusSlice(...a) }) as AppState ) } @@ -124,6 +133,22 @@ describe('createPreflightSlice', () => { expect(store.getState().preflightStatusLoading).toBe(false) }) + it('invalidates an in-flight result when its runtime session ends', async () => { + resetPreflightMocks() + const stale = deferred() + preflightCheck.mockReturnValueOnce(stale.promise) + const store = createTestStore() + + const request = store.getState().refreshPreflightStatus() + store.getState().invalidatePreflightStatus() + stale.resolve(makeStatus(true)) + await request + + expect(store.getState().preflightStatus).toBeNull() + expect(store.getState().preflightStatusChecked).toBe(false) + expect(store.getState().preflightStatusContextKey).toBeNull() + }) + it('lets forced checks bypass non-forced dedupe and win stale races', async () => { resetPreflightMocks() const stale = deferred() @@ -334,6 +359,69 @@ describe('createPreflightSlice', () => { expect(store.getState().preflightStatus?.glab?.installed).toBe(true) }) + it('keeps a paired preflight result bound to the active runtime session', async () => { + resetPreflightMocks() + const runtimeA = deferred() + const runtimeB = deferred() + const reconnectedB = deferred() + callRuntimeRpc + .mockReturnValueOnce(runtimeA.promise) + .mockReturnValueOnce(runtimeB.promise) + .mockReturnValueOnce(reconnectedB.promise) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-a' } } as Partial) + store.getState().setRuntimeEnvironmentStatus('runtime-a', { + status: { runtimeId: 'server-a' } as never, + checkedAt: 1 + }) + + const requestA = store.getState().refreshPreflightStatus() + store.setState({ settings: { activeRuntimeEnvironmentId: 'runtime-b' } } as Partial) + store.getState().setRuntimeEnvironmentStatus('runtime-b', { + status: { runtimeId: 'server-b' } as never, + checkedAt: 2 + }) + const requestB = store.getState().refreshPreflightStatus() + + expect(callRuntimeRpc).toHaveBeenCalledTimes(2) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 1, + { kind: 'environment', environmentId: 'runtime-a' }, + 'preflight.check', + {} + ) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 2, + { kind: 'environment', environmentId: 'runtime-b' }, + 'preflight.check', + {} + ) + + runtimeA.resolve(makeStatus(false)) + await requestA + expect(store.getState().preflightStatus).toBeNull() + expect(store.getState().preflightStatusChecked).toBe(false) + + runtimeB.resolve(makeStatus(true)) + await requestB + expect(store.getState().preflightStatus?.glab?.installed).toBe(true) + + store.getState().setRuntimeEnvironmentStatus('runtime-b', { status: null, checkedAt: 3 }) + store.getState().invalidatePreflightStatus() + expect(store.getState().preflightStatus).toBeNull() + expect(store.getState().preflightStatusChecked).toBe(false) + + store.getState().setRuntimeEnvironmentStatus('runtime-b', { + status: { runtimeId: 'server-b-reconnected' } as never, + checkedAt: 4 + }) + const reconnect = store.getState().refreshPreflightStatus() + expect(callRuntimeRpc).toHaveBeenCalledTimes(3) + reconnectedB.resolve(makeStatus(true)) + await reconnect + expect(store.getState().preflightStatus?.glab?.installed).toBe(true) + }) + it('clears checked status immediately when refreshing a different local context', async () => { resetPreflightMocks() const host = deferred() diff --git a/src/renderer/src/store/slices/preflight.ts b/src/renderer/src/store/slices/preflight.ts index 05e60383749..9b4abd21a11 100644 --- a/src/renderer/src/store/slices/preflight.ts +++ b/src/renderer/src/store/slices/preflight.ts @@ -16,6 +16,7 @@ export type PreflightSlice = { preflightStatusError: string | null refreshPreflightStatus: (options?: { force?: boolean }) => Promise + invalidatePreflightStatus: () => void } let nonForcedPreflightRequest: { key: string; promise: Promise } | null = null @@ -51,6 +52,19 @@ export const createPreflightSlice: StateCreator { + latestPreflightRequestId += 1 + nonForcedPreflightRequest = null + forcedPreflightRequest = null + set({ + preflightStatus: null, + preflightStatusChecked: false, + preflightStatusContextKey: null, + preflightStatusLoading: false, + preflightStatusError: null + }) + }, + refreshPreflightStatus: async (options) => { const force = options?.force === true const context = getLocalPreflightContext(get()) diff --git a/src/renderer/src/store/slices/runtime-status.test.ts b/src/renderer/src/store/slices/runtime-status.test.ts index a82350f06e0..e68bf50e375 100644 --- a/src/renderer/src/store/slices/runtime-status.test.ts +++ b/src/renderer/src/store/slices/runtime-status.test.ts @@ -1,6 +1,8 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' +import { toast } from 'sonner' import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import { createCompatibleRuntimeStatusResponse } from '../../runtime/runtime-compatibility-test-fixture' import { callRuntimeRpc, @@ -11,6 +13,11 @@ import { type RuntimeStatusSlice, getRuntimeEnvironmentConnectionGeneration } from './runtime-status' +import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' + +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), dismiss: vi.fn() } +})) function createSliceStore() { return create()((...a) => ({ @@ -32,6 +39,22 @@ function makeStatus(overrides: Partial = {}): RuntimeStatus { } as RuntimeStatus } +function makeEnvironment( + overrides: Partial = {} +): PublicKnownRuntimeEnvironment { + return { + id: 'env-a', + name: 'Dev Box', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [{ id: 'ws-a', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://x' }], + preferredEndpointId: 'ws-a', + ...overrides + } +} + function stubRuntimeEnvironmentApi({ getStatus = vi.fn(), list = vi.fn() @@ -50,7 +73,13 @@ function stubRuntimeEnvironmentApi({ return { getStatus, list } } +beforeEach(() => { + vi.mocked(toast.warning).mockReset() + vi.mocked(toast.dismiss).mockReset() +}) + afterEach(() => { + vi.useRealTimers() vi.unstubAllGlobals() }) @@ -142,6 +171,141 @@ describe('runtime-status slice', () => { expect(map.get('env-a')).toEqual({ status: null, checkedAt: 5, connectionGeneration: 1 }) }) + it('does not toast when the first probe finds a saved server offline', () => { + const store = createSliceStore() + store.setState({ runtimeEnvironments: [makeEnvironment()] }) + + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 1 }) + + expect(toast.warning).not.toHaveBeenCalled() + }) + + it('toasts once when a connected server becomes unavailable', () => { + const store = createSliceStore() + store.setState({ runtimeEnvironments: [makeEnvironment()] }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) + + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 3 }) + + expect(toast.warning).toHaveBeenCalledTimes(1) + expect(toast.warning).toHaveBeenCalledWith( + "Can't reach Dev Box", + expect.objectContaining({ + id: 'runtime-environment-disconnected:env-a', + description: + 'Check that Orca is running on this server and that your network connection is working, then try again.', + action: expect.objectContaining({ label: 'Try again' }) + }) + ) + }) + + it('dismisses the disconnect toast when the server recovers', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 }) + + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 3 }) + + expect(toast.dismiss).toHaveBeenCalledWith('runtime-environment-disconnected:env-a') + }) + + it('keeps the keyed action toast visible when an offline retry settles', async () => { + vi.useFakeTimers() + const toastId = 'runtime-environment-disconnected:env-a' + const visibleToastIds = new Set() + vi.mocked(toast.warning).mockImplementation((_title, options) => { + if (options?.id !== undefined) { + visibleToastIds.add(options.id) + } + return options?.id ?? '' + }) + const getStatus = vi.fn().mockRejectedValue(new Error('closed')) + stubRuntimeEnvironmentApi({ getStatus }) + const store = createSliceStore() + store.setState({ runtimeEnvironments: [makeEnvironment()] }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 }) + const options = vi.mocked(toast.warning).mock.calls[0]?.[1] as unknown as { + action: { onClick: (event: { preventDefault: () => void }) => void } + } + const clickAction = (): { defaultPrevented: boolean } => { + const event = { + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true + } + } + options.action.onClick(event) + if (!event.defaultPrevented) { + setTimeout(() => visibleToastIds.delete(toastId), 200) + } + return event + } + + const firstClick = clickAction() + const secondClick = clickAction() + await vi.advanceTimersByTimeAsync(0) + + expect(firstClick.defaultPrevented).toBe(true) + expect(secondClick.defaultPrevented).toBe(true) + expect(getStatus).toHaveBeenCalledWith({ selector: 'env-a', timeoutMs: 10_000 }) + await vi.advanceTimersByTimeAsync(200) + expect(visibleToastIds.has(toastId)).toBe(true) + expect(getStatus).toHaveBeenCalledTimes(1) + expect(toast.warning).toHaveBeenCalledTimes(3) + expect(vi.mocked(toast.warning).mock.calls.map((call) => call[1]?.duration)).toEqual([ + 4_000, + Number.POSITIVE_INFINITY, + 4_000 + ]) + expect(vi.mocked(toast.warning).mock.calls.every((call) => call[1]?.id === toastId)).toBe(true) + }) + + it('does not report removal or explicit status clearing as a disconnect', () => { + const store = createSliceStore() + store.setState({ runtimeEnvironments: [makeEnvironment()] }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) + + store.getState().clearRuntimeEnvironmentStatus('env-a') + store.getState().setRuntimeEnvironments([]) + + expect(toast.warning).not.toHaveBeenCalled() + }) + + it('does not report an intentional disconnect as an outage', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) + + store + .getState() + .setRuntimeEnvironmentStatus( + 'env-a', + { status: null, checkedAt: 2 }, + { suppressDisconnectToast: true } + ) + + expect(toast.warning).not.toHaveBeenCalled() + expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBeNull() + }) + + it('dismisses an outage toast opened while an intentional disconnect was pending', () => { + const store = createSliceStore() + store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 2 }) + + store + .getState() + .setRuntimeEnvironmentStatus( + 'env-a', + { status: null, checkedAt: 3 }, + { suppressDisconnectToast: true } + ) + + expect(toast.warning).toHaveBeenCalledTimes(1) + expect(toast.dismiss).toHaveBeenCalledWith('runtime-environment-disconnected:env-a') + }) + it('clears a single environment entry', () => { const store = createSliceStore() store.getState().setRuntimeEnvironmentStatus('env-a', { status: makeStatus(), checkedAt: 1 }) @@ -215,6 +379,36 @@ describe('runtime-status slice', () => { expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe(2) }) + it('invalidates provider state only when the active runtime session changes', () => { + const store = createSliceStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-a' } } as never) + const initialKey = getProviderRuntimeContextKey({ activeRuntimeEnvironmentId: 'env-a' }) + + store.getState().setRuntimeEnvironmentStatus('env-b', { + status: makeStatus({ runtimeId: 'runtime-b' }), + checkedAt: 1 + }) + expect(getProviderRuntimeContextKey({ activeRuntimeEnvironmentId: 'env-a' })).toBe(initialKey) + + store.getState().setRuntimeEnvironmentStatus('env-a', { + status: makeStatus({ runtimeId: 'runtime-a' }), + checkedAt: 2 + }) + const connectedKey = getProviderRuntimeContextKey({ activeRuntimeEnvironmentId: 'env-a' }) + expect(connectedKey).not.toBe(initialKey) + + store.getState().setRuntimeEnvironmentStatus('env-a', { + status: makeStatus({ runtimeId: 'runtime-a' }), + checkedAt: 3 + }) + expect(getProviderRuntimeContextKey({ activeRuntimeEnvironmentId: 'env-a' })).toBe(connectedKey) + + store.getState().setRuntimeEnvironmentStatus('env-a', { status: null, checkedAt: 4 }) + expect(getProviderRuntimeContextKey({ activeRuntimeEnvironmentId: 'env-a' })).not.toBe( + connectedKey + ) + }) + it('drops a recent compatibility failure once a status refresh succeeds', async () => { clearRuntimeCompatibilityCacheForTests() let offline = true diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 03fdac1224f..60351672ec7 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -1,4 +1,5 @@ import type { StateCreator } from 'zustand' +import { toast } from 'sonner' import type { AppState } from '../types' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../../shared/runtime-types' @@ -8,6 +9,8 @@ import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { translate } from '@/i18n/i18n' +import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-context' /** Live status for one saved runtime environment, as last observed by the * renderer. `status === null` records a probe that failed or timed out so the @@ -44,7 +47,11 @@ export type RuntimeStatusSlice = { * retires state owned by any environment that just left the saved list. */ setRuntimeEnvironments: (environments: PublicKnownRuntimeEnvironment[]) => void /** Merges one environment's status. Replaces the prior entry for that id. */ - setRuntimeEnvironmentStatus: (environmentId: string, status: RuntimeEnvironmentStatus) => void + setRuntimeEnvironmentStatus: ( + environmentId: string, + status: RuntimeEnvironmentStatus, + options?: { suppressDisconnectToast?: boolean } + ) => void /** Drops a removed environment so stale hosts don't linger in the registry. */ clearRuntimeEnvironmentStatus: (environmentId: string) => void /** Drops every entry whose id is not in the saved-environments set. */ @@ -57,6 +64,85 @@ export type RuntimeStatusSlice = { } const connectionGenerationByEnvironment = new Map() +const activeRuntimeDisconnectedToasts = new Map() +const RUNTIME_DISCONNECTED_TOAST_DURATION_MS = 4_000 + +function getRuntimeDisconnectedToastId(environmentId: string): string { + return `runtime-environment-disconnected:${environmentId}` +} + +function showRuntimeDisconnectedToast(environmentId: string, getState: () => AppState): void { + const environment = getState().runtimeEnvironments.find((entry) => entry.id === environmentId) + const toastId = getRuntimeDisconnectedToastId(environmentId) + const activation = Symbol(toastId) + const title = environment?.name + ? translate( + 'auto.store.slices.runtime.status.runtimeHostUnreachableNamed', + "Can't reach {{hostName}}", + { hostName: environment.name } + ) + : translate( + 'auto.store.slices.runtime.status.runtimeHostUnreachable', + "Can't reach Orca server" + ) + activeRuntimeDisconnectedToasts.set(toastId, activation) + const clearActiveToast = (): void => { + if (activeRuntimeDisconnectedToasts.get(toastId) === activation) { + activeRuntimeDisconnectedToasts.delete(toastId) + } + } + let retrying = false + const showToast = (duration = RUNTIME_DISCONNECTED_TOAST_DURATION_MS): void => { + toast.warning(title, { + id: toastId, + description: translate( + 'auto.store.slices.runtime.status.runtimeHostDisconnectedDescription', + 'Check that Orca is running on this server and that your network connection is working, then try again.' + ), + duration, + action: { + label: translate('auto.store.slices.runtime.status.tryAgain', 'Try again'), + onClick: (event) => { + // Why: Sonner otherwise deletes the keyed toast after the action callback. + event.preventDefault() + if (retrying) { + return + } + retrying = true + showToast(Number.POSITIVE_INFINITY) + void getState() + .refreshRuntimeEnvironmentStatus(environmentId) + .then((reachable) => { + const stillSaved = getState().runtimeEnvironments.some( + (entry) => entry.id === environmentId + ) + if ( + !reachable && + stillSaved && + activeRuntimeDisconnectedToasts.get(toastId) === activation + ) { + showToast() + } + }) + .finally(() => { + retrying = false + }) + } + }, + onDismiss: clearActiveToast, + onAutoClose: clearActiveToast + }) + } + showToast() +} + +function dismissRuntimeDisconnectedToast(environmentId: string): void { + const toastId = getRuntimeDisconnectedToastId(environmentId) + if (!activeRuntimeDisconnectedToasts.delete(toastId)) { + return + } + toast.dismiss?.(toastId) +} export function getRuntimeEnvironmentConnectionGeneration(environmentId: string): number { return connectionGenerationByEnvironment.get(environmentId) ?? 0 @@ -158,10 +244,12 @@ export const createRuntimeStatusSlice: StateCreator 0) { get().purgeStaleRuntimeHostState?.(retiredEnvironmentIds) + retiredEnvironmentIds.forEach(dismissRuntimeDisconnectedToast) } }, - setRuntimeEnvironmentStatus: (environmentId, status) => { + setRuntimeEnvironmentStatus: (environmentId, status, options) => { + const previous = get().runtimeStatusByEnvironmentId.get(environmentId) // Why: a non-null status proves the runtime just answered, so drop any stale // "offline" compat failure before this online transition fires the // reuse-flagged background refetches — a recovered host must re-probe. @@ -170,13 +258,17 @@ export const createRuntimeStatusSlice: StateCreator { const next = new Map(s.runtimeStatusByEnvironmentId) - const previous = next.get(environmentId) + const sessionEnded = status.status === null && previous?.status != null const connectionChanged = status.status !== null && (previous?.status == null || previous.status.runtimeId !== status.status.runtimeId) + const activeEnvironmentId = s.settings?.activeRuntimeEnvironmentId?.trim() if (connectionChanged) { advanceRuntimeEnvironmentConnectionGeneration(environmentId) } + if (activeEnvironmentId === environmentId && (sessionEnded || connectionChanged)) { + bumpProviderRuntimeSessionGeneration() + } next.set(environmentId, { ...status, connectionGeneration: connectionChanged @@ -185,9 +277,17 @@ export const createRuntimeStatusSlice: StateCreator + clearRuntimeEnvironmentStatus: (environmentId) => { + dismissRuntimeDisconnectedToast(environmentId) set((s) => { advanceRuntimeEnvironmentConnectionGeneration(environmentId) if (!s.runtimeStatusByEnvironmentId.has(environmentId)) { @@ -196,11 +296,17 @@ export const createRuntimeStatusSlice: StateCreator + retainRuntimeEnvironmentStatuses: (environmentIds) => { + const keep = new Set(environmentIds) + for (const id of get().runtimeStatusByEnvironmentId.keys()) { + if (!keep.has(id)) { + dismissRuntimeDisconnectedToast(id) + } + } set((s) => { - const keep = new Set(environmentIds) let changed = false const next = new Map(s.runtimeStatusByEnvironmentId) for (const id of next.keys()) { @@ -210,7 +316,8 @@ export const createRuntimeStatusSlice: StateCreator { try { diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index f5a706ff0de..612caab0be4 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -210,6 +210,23 @@ describe('createSettingsSlice checked persistence', () => { consoleError.mockRestore() } }) + + it('normalizes malformed mobile pairing addresses before renderer IPC', async () => { + const store = createTestStore() + store.setState({ + settings: { notifications: {} } as unknown as AppState['settings'] + }) + + await store.getState().updateSettingsOrThrow({ + mobilePairingCustomAddress: 'host:99999' as never, + mobilePairingCustomAddresses: [' first.example:6768 ', 'host:99999', 'first.example:6768'] + }) + + expect(settingsSet).toHaveBeenCalledWith({ + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: ['first.example:6768'] + }) + }) }) describe('createSettingsSlice runtime switching', () => { diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 7e42263ac8f..670a2556e10 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -23,6 +23,10 @@ import { bumpProviderRuntimeSessionGeneration } from '@/lib/provider-runtime-con import { normalizeUiLanguage } from '../../../../shared/ui-language' import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy' import { translate } from '@/i18n/i18n' +import { + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../../../../shared/mobile-pairing-custom-address' export type SettingsSlice = SettingsSearchState & { settings: GlobalSettings | null @@ -105,6 +109,16 @@ function normalizeSettingsUpdates( updates.terminalScrollbackRows ) } + if ('mobilePairingCustomAddress' in updates) { + sanitizedUpdates.mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( + updates.mobilePairingCustomAddress + ) + } + if ('mobilePairingCustomAddresses' in updates) { + sanitizedUpdates.mobilePairingCustomAddresses = normalizeMobilePairingCustomAddresses( + updates.mobilePairingCustomAddresses + ) + } return sanitizedUpdates } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 210c2f35537..11fbb55c158 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -124,6 +124,7 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' +import { isWebClientLocation } from '@/lib/web-client-location' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' import { resolveAgentPaneAuthorityKey } from './agent-pane-authority' @@ -524,6 +525,8 @@ export type CodexRestartNotice = { * a re-mark actually points back at the account the pane launched under. */ previousAccountId?: string | null nextAccountId?: string | null + /** The launch account still matches, but its baked CODEX_HOME route is retired. */ + homeRouteChanged?: true /** Set once the user asks for the restart. The record outlives the prompt * because the previous-account fields are the only memory of the account this * pane actually launched under, which drives the A -> B -> A collapse. */ @@ -735,6 +738,8 @@ export type TerminalSlice = { markCodexRestartNotices: ( notices: (Pick & Partial> & { + /** Explicit false replaces a restored route notice after main rechecks it. */ + homeRouteChanged?: boolean ptyId: string })[] ) => string[] @@ -1527,6 +1532,9 @@ export const createTerminalSlice: StateCreator }) return } + if (isWebClientLocation() && worktreeId !== FLOATING_TERMINAL_WORKTREE_ID) { + return + } const terminal = get().createTab(worktreeId, groupId) get().setActiveTab(terminal.id) get().setActiveTabType('terminal') @@ -3459,9 +3467,14 @@ export const createTerminalSlice: StateCreator // describing two different accounts. const launch = existing ?? notice const target = { id: notice.nextAccountId, label: notice.nextAccountLabel } + const homeRouteChanged = + notice.homeRouteChanged === undefined + ? existing?.homeRouteChanged === true + : notice.homeRouteChanged // Why: a live Codex pane keeps its original launch account until it actually restarts, so A -> B -> A must not leave a stale restart notice. if ( + !homeRouteChanged && isSameCodexRestartNoticeAccount( { id: launch.previousAccountId, label: launch.previousAccountLabel }, target @@ -3479,6 +3492,7 @@ export const createTerminalSlice: StateCreator ? {} : { previousAccountId: launch.previousAccountId }), ...(notice.nextAccountId === undefined ? {} : { nextAccountId: notice.nextAccountId }), + ...(homeRouteChanged ? { homeRouteChanged: true as const } : {}), // Why: a queued restart relaunches under whatever account is selected // when it runs, so a later switch does not reopen an answered prompt. ...(existing?.restartRequested ? { restartRequested: true as const } : {}), diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index f6bcc0c383f..098ceeff9a2 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -3008,6 +3008,27 @@ describe('shared agent-hook-listener', () => { expect(stop?.payload.subagents).toBeUndefined() }) + it.each([ + { + label: 'a running shell task', + eventName: 'Stop', + payload: { background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }] } + }, + { + label: 'a pending session cron', + eventName: 'StopFailure', + payload: { session_crons: [{ id: 'cron-1' }] } + } + ])( + 'reports Stop as working for $label without adding a subagent row', + ({ eventName, payload }) => { + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'run in background' }) + const stop = claudeEvent({ hook_event_name: eventName, ...payload }) + expect(stop?.payload.state).toBe('working') + expect(stop?.payload.subagents).toBeUndefined() + } + ) + it('reports Stop as working while a background subagent is still running', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'review the PR' }) claudeEvent({ diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 2294639df2f..e64b1941e4e 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -31,6 +31,7 @@ import { claudeRosterToSnapshots, claudeTeammateIdMatchesName, foldClaudeBackgroundTasksIntoRoster, + hasActiveClaudeNonAgentBackgroundWork, idleClaudeTeammateByName, readClaudeBackgroundAgentTasks, reapRestoredClaudeSubagentsWithoutLiveAgent, @@ -2600,6 +2601,9 @@ function normalizeClaudeEvent( if (!stateName) { return null } + const hasActiveNonAgentBackgroundWork = + (eventName === 'Stop' || eventName === 'StopFailure') && + hasActiveClaudeNonAgentBackgroundWork(hookPayload) const eventAgentId = readString(hookPayload, 'agent_id') // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). @@ -2682,10 +2686,13 @@ function normalizeClaudeEvent( ...(stateBeforeWait ? { stateBeforeWait } : {}) }) - // Why: a lead Stop isn't "done" while subagents/teammates run (would show a finished row mid-flight); Claude re-wakes the lead, so a later empty-roster Stop resolves to done. + // Why: a lead Stop isn't done while children, shells, or crons remain live; a later drained Stop resolves it. const roster = state.claudeSubagentRosterByPaneKey.get(paneKey) const effectiveState = - stateName === 'done' && claudeRosterHasWorkingSubagent(roster) ? 'working' : stateName + stateName === 'done' && + (hasActiveNonAgentBackgroundWork || claudeRosterHasWorkingSubagent(roster)) + ? 'working' + : stateName return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { stateName: effectiveState, diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index be22ce6e68d..157fc95a050 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -60,6 +60,8 @@ export const AGENT_STATE_HISTORY_MAX = 20 export type AgentStatusOrchestrationContext = { taskId: string dispatchId: string + /** Runtime-authoritative lifecycle state. Hook-only contexts may omit it. */ + dispatchStatus?: 'pending' | 'dispatched' | 'completed' | 'failed' | 'circuit_broken' taskTitle?: string displayName?: string parentTerminalHandle?: string diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index 977d96508ed..58bf9c6d99b 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -5,6 +5,7 @@ import { claudeRosterToSnapshots, claudeTeammateIdMatchesName, foldClaudeBackgroundTasksIntoRoster, + hasActiveClaudeNonAgentBackgroundWork, idleClaudeTeammateByName, readClaudeBackgroundAgentTasks, reapRestoredClaudeSubagentsWithoutLiveAgent, @@ -202,6 +203,27 @@ describe('claude-subagent-roster', () => { ]) }) + it('detects live non-agent background work without treating agent tasks as shell work', () => { + expect( + hasActiveClaudeNonAgentBackgroundWork({ + background_tasks: [{ id: 'shell-1', type: 'shell', status: 'running' }] + }) + ).toBe(true) + expect(hasActiveClaudeNonAgentBackgroundWork({ session_crons: [{ id: 'cron-1' }] })).toBe(true) + expect( + hasActiveClaudeNonAgentBackgroundWork({ + background_tasks: [ + { id: 'agent-1', type: 'subagent', status: 'running' }, + { id: 'team-1', type: 'teammate', status: 'running' } + ] + }) + ).toBe(false) + expect(hasActiveClaudeNonAgentBackgroundWork({ background_tasks: [], session_crons: [] })).toBe( + false + ) + expect(hasActiveClaudeNonAgentBackgroundWork({})).toBe(false) + }) + it('reports background_tasks as absent when missing or malformed', () => { expect(readClaudeBackgroundAgentTasks({}).present).toBe(false) expect(readClaudeBackgroundAgentTasks({ background_tasks: 'nope' }).present).toBe(false) diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index 128b49e8010..50be0ab5ab9 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -142,6 +142,27 @@ export function stopClaudeSubagent(roster: ClaudeSubagentRoster, id: string): vo tracked.state = 'idle' } +/** Shell tasks and session crons outlive a lead Stop but do not belong in the agent roster. */ +export function hasActiveClaudeNonAgentBackgroundWork( + hookPayload: Record +): boolean { + const sessionCrons = hookPayload['session_crons'] + if (Array.isArray(sessionCrons) && sessionCrons.length > 0) { + return true + } + const backgroundTasks = hookPayload['background_tasks'] + return ( + Array.isArray(backgroundTasks) && + backgroundTasks.some((item) => { + if (typeof item !== 'object' || item === null) { + return false + } + const task = item as Record + return task.status === 'running' && task.type !== 'subagent' && task.type !== 'teammate' + }) + ) +} + /** Read the agent-typed entries of a hook payload's `background_tasks` field. * `present: false` means the field was absent/malformed (older Claude builds), * so callers must keep their tracked roster instead of clearing it. */ diff --git a/src/shared/codex-auth-errors.test.ts b/src/shared/codex-auth-errors.test.ts index 4d974239d42..de92e01c8f0 100644 --- a/src/shared/codex-auth-errors.test.ts +++ b/src/shared/codex-auth-errors.test.ts @@ -8,6 +8,7 @@ afterEach(() => { describe('isCodexAuthError', () => { it('matches Codex authentication refresh failures', () => { expect(isCodexAuthError('Access token could not be refreshed')).toBe(true) + expect(isCodexAuthError('Sign in with ChatGPT')).toBe(true) expect(isCodexAuthError('plain provider error')).toBe(false) expect(isCodexAuthError(null)).toBe(false) }) diff --git a/src/shared/codex-auth-errors.ts b/src/shared/codex-auth-errors.ts index 9f25a4b444c..366a129c3cb 100644 --- a/src/shared/codex-auth-errors.ts +++ b/src/shared/codex-auth-errors.ts @@ -6,6 +6,7 @@ const CODEX_AUTH_ERROR_PATTERNS = [ /please (?:log out and )?sign in again/i, /please reauthenticate/i, /not logged in/i, + /sign in with chatgpt/i, /token data is not available/i, /auth (?:is missing|tokens are missing|does not expose)/i, // Why: app-server rejects account/rateLimits/read with this when auth.json diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index f6bd48c8fde..e8c55be052d 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -27,6 +27,11 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').sourceControlGroupOrder).toBe('changes-first') }) + it('defaults mobile pairing to discovered network addresses', () => { + expect(getDefaultSettings('/tmp').mobilePairingCustomAddress).toBeNull() + expect(getDefaultSettings('/tmp').mobilePairingCustomAddresses).toEqual([]) + }) + it('keeps first-work branch auto-renaming on by default for new settings', () => { expect(getDefaultSettings('/tmp').autoRenameBranchFromWork).toBe(true) expect(getDefaultSettings('/tmp').autoRenameBranchFromWorkDefaultedOn).toBe(true) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 536663e1d6a..279296834e4 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -351,6 +351,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings { mobileAutoRestoreFitMs: null, // Why: Anywhere (Relay + local) is the default; local-only is written only on explicit same-network choice. mobilePairingConnectionMode: 'automatic', + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: [], // Why: off keeps the cosmetic overlay unmounted for users who never opt in. experimentalPet: false, experimentalActivity: false, @@ -436,6 +438,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState { sshTargets: [], deletedSshConfigAliases: [], sshRemotePtyLeases: [], + sshPtyConsumerRecoveries: [], claudeLivePtySessionIds: [], migrationUnsupportedPtyEntries: [], legacyPaneKeyAliasEntries: [], diff --git a/src/shared/feature-tips.test.ts b/src/shared/feature-tips.test.ts index 325bee2c174..4513f94744a 100644 --- a/src/shared/feature-tips.test.ts +++ b/src/shared/feature-tips.test.ts @@ -104,5 +104,7 @@ describe('feature tips', () => { expect(voiceTip?.eyebrow).toBe('Tip') expect(voiceTip?.priority).toBe('unseen') + expect(voiceTip?.title).toBe('Dictate into any pane') + expect(voiceTip?.ctaLabel).toBe('Set up voice dictation') }) }) diff --git a/src/shared/feature-tips.ts b/src/shared/feature-tips.ts index dd9b43861f1..a9712d6d432 100644 --- a/src/shared/feature-tips.ts +++ b/src/shared/feature-tips.ts @@ -56,11 +56,10 @@ export const FEATURE_TIPS = [ id: 'voice-dictation', priority: 'unseen', eyebrow: 'Tip', - title: 'Voice Dictation is here', - description: - 'Speak into any focused pane and Orca will transcribe it. Press the dictation shortcut to start and stop.', + title: 'Dictate into any pane', + description: 'Start voice dictation in any focused pane, then use the shortcut again to stop.', action: 'enable-voice', - ctaLabel: 'Set Up Voice', + ctaLabel: 'Set up voice dictation', completedByFeatureInteractions: ['voice-dictation'] } ] as const satisfies readonly FeatureTip[] diff --git a/src/shared/mobile-pairing-custom-address.test.ts b/src/shared/mobile-pairing-custom-address.test.ts new file mode 100644 index 00000000000..b63e88cfa34 --- /dev/null +++ b/src/shared/mobile-pairing-custom-address.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES, + addMobilePairingCustomAddress, + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses, + removeMobilePairingCustomAddress +} from './mobile-pairing-custom-address' + +describe('normalizeMobilePairingCustomAddress', () => { + it('keeps a valid custom address', () => { + expect(normalizeMobilePairingCustomAddress(' 100.126.117.25:6768 ')).toBe('100.126.117.25:6768') + }) + + it.each([null, undefined, 42, '', '0.0.0.0', 'host:99999'])( + 'clears an invalid persisted value: %s', + (value) => { + expect(normalizeMobilePairingCustomAddress(value)).toBeNull() + } + ) +}) + +describe('mobile pairing custom address collection', () => { + it('normalizes, deduplicates, and drops invalid entries', () => { + expect( + normalizeMobilePairingCustomAddresses([ + ' first.example:6768 ', + 'host:99999', + 'first.example:6768', + 'second.example:6768' + ]) + ).toEqual(['first.example:6768', 'second.example:6768']) + }) + + it('bounds persisted entries', () => { + const addresses = Array.from( + { length: MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES + 5 }, + (_, index) => `host-${index}.example:6768` + ) + expect(normalizeMobilePairingCustomAddresses(addresses)).toHaveLength( + MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES + ) + }) + + it('adds the newest address and removes a saved address', () => { + expect(addMobilePairingCustomAddress(['first.example'], ' second.example ')).toEqual([ + 'first.example', + 'second.example' + ]) + expect( + removeMobilePairingCustomAddress(['first.example', 'second.example'], 'first.example') + ).toEqual(['second.example']) + }) +}) diff --git a/src/shared/mobile-pairing-custom-address.ts b/src/shared/mobile-pairing-custom-address.ts new file mode 100644 index 00000000000..ecf8d1cefaa --- /dev/null +++ b/src/shared/mobile-pairing-custom-address.ts @@ -0,0 +1,53 @@ +import { parseManualNetworkAddress } from './network/manual-address' + +export const MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES = 50 + +export function normalizeMobilePairingCustomAddress(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const parsed = parseManualNetworkAddress(value) + return parsed.ok ? parsed.address : null +} + +export function normalizeMobilePairingCustomAddresses(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + const normalized: string[] = [] + for (const candidate of value) { + const address = normalizeMobilePairingCustomAddress(candidate) + if (address && !normalized.includes(address)) { + normalized.push(address) + } + if (normalized.length === MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES) { + break + } + } + return normalized +} + +export function addMobilePairingCustomAddress( + addresses: readonly string[], + value: string +): string[] { + const address = normalizeMobilePairingCustomAddress(value) + if (!address) { + return normalizeMobilePairingCustomAddresses(addresses) + } + const normalized = normalizeMobilePairingCustomAddresses(addresses) + if (normalized.includes(address)) { + return normalized + } + return [...normalized, address].slice(-MAX_MOBILE_PAIRING_CUSTOM_ADDRESSES) +} + +export function removeMobilePairingCustomAddress( + addresses: readonly string[], + value: string +): string[] { + const address = normalizeMobilePairingCustomAddress(value) + return normalizeMobilePairingCustomAddresses(addresses).filter( + (candidate) => candidate !== address + ) +} diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts index 089487f0ad9..dde3109bbd4 100644 --- a/src/shared/release-channel.test.ts +++ b/src/shared/release-channel.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { formatHourlyVersion, + getReleaseNotesUrlForVersion, getReleaseRepoForChannel, getVersionChannel, isChannelSupportedOnPlatform, @@ -30,6 +31,21 @@ describe('release channel', () => { expect(getReleaseRepoForChannel('rc')).toBe('stablyai/orca') }) + // Why: an hourly tag linked against the main repo 404s — the tag only exists + // in the hourly repo. + it('builds release-notes links against the repo that published the version', () => { + expect(getReleaseNotesUrlForVersion('1.4.160-hourly.202607281400')).toBe( + 'https://github.com/stablyai/orca-hourly/releases/tag/v1.4.160-hourly.202607281400' + ) + expect(getReleaseNotesUrlForVersion('1.4.160')).toBe( + 'https://github.com/stablyai/orca/releases/tag/v1.4.160' + ) + expect(getReleaseNotesUrlForVersion('v1.4.160-rc.3')).toBe( + 'https://github.com/stablyai/orca/releases/tag/v1.4.160-rc.3' + ) + expect(getReleaseNotesUrlForVersion(null)).toBe('https://github.com/stablyai/orca/releases') + }) + it('round-trips an hourly version stamp as UTC', () => { const version = formatHourlyVersion('1.4.160', '202607281405') expect(isHourlyVersion(version)).toBe(true) @@ -89,6 +105,7 @@ describe('release channel', () => { tag: `v${version}`, version, channel: 'hourly', + name: null, publishedAt: null, releaseUrl: `https://github.com/stablyai/orca-hourly/releases/tag/v${version}` }) diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts index fc0c09cefb9..a700bc7198d 100644 --- a/src/shared/release-channel.ts +++ b/src/shared/release-channel.ts @@ -82,10 +82,27 @@ export function getVersionChannel(version: string): ReleaseChannel | null { return normalized.includes('-') ? 'rc' : 'stable' } +/** + * Release-notes page for a version, in whichever repo published it. Hourly tags + * exist only in the hourly repo, so a main-repo tag URL for one 404s. + * A null version falls back to the plain releases listing (not /releases/latest + * — /latest also breaks when GitHub's API is degraded). + */ +export function getReleaseNotesUrlForVersion(version: string | null): string { + const repo = + version && getVersionChannel(version) === 'hourly' ? HOURLY_RELEASE_REPO : MAIN_RELEASE_REPO + return version + ? `https://github.com/${repo}/releases/tag/v${normalizeTagToVersion(version)}` + : `https://github.com/${repo}/releases` +} + export type ReleaseBuild = { tag: string version: string channel: ReleaseChannel + /** The release's GitHub title. Null when it is absent or just repeats the tag, + * so the picker can tell "the workflow named this" from "nobody did". */ + name: string | null publishedAt: string | null releaseUrl: string } diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 889a15256f1..230e53c6a5b 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -157,6 +157,20 @@ export type SshRemotePtyLease = { lastDetachedAt?: number } +/** Main-owned relay lease needed to reclaim PTY delivery after a desktop restart. */ +export type SshPtyConsumerRecovery = { + targetId: string + clientInstanceId: string + serverBuildId: string + clientGeneration: number + ownerGeneration: number + ownerLease: string + outputFlowControl?: { + version: 1 + windowSu: number + } +} + // ─── Port Forwarding Types ───────────────────────────────────────── export type PortForwardEntry = { diff --git a/src/shared/types.ts b/src/shared/types.ts index f713ada0645..475470dd4c4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,6 +1,11 @@ /* eslint-disable max-lines */ import type { ExecutionHostId } from './execution-host' -import type { RemovedSshTargetTombstone, SshRemotePtyLease, SshTarget } from './ssh-types' +import type { + RemovedSshTargetTombstone, + SshPtyConsumerRecovery, + SshRemotePtyLease, + SshTarget +} from './ssh-types' import type { Automation, AutomationExecutionTargetType, AutomationRun } from './automations-types' import type { WorkspaceSource } from './workspace-source' import type { ReleaseBuild, ReleaseChannel } from './release-channel' @@ -3002,6 +3007,10 @@ export type GlobalSettings = { /** Preferred mobile pairing path for new QR codes. Missing/'automatic' = Anywhere (Relay + local); * explicit 'local-only' = same-network only. */ mobilePairingConnectionMode?: 'automatic' | 'local-only' + /** Explicit custom address restored when generating future mobile pairing codes. */ + mobilePairingCustomAddress?: string | null + /** Saved custom addresses available in both mobile pairing pickers. */ + mobilePairingCustomAddresses?: string[] /** Experimental: floating animated pet in the bottom-right corner. Opt-in cosmetic; * off never mounts the overlay, and toggling takes effect instantly (renderer-side). */ experimentalPet: boolean @@ -3591,6 +3600,8 @@ export type PersistedState = { /** Identity records for removed SSH targets so a re-added host can re-adopt workspaces orphaned on the old target id. */ removedSshTargetTombstones?: RemovedSshTargetTombstone[] sshRemotePtyLeases: SshRemotePtyLease[] + /** Main-owned authenticated relay recovery records; never expose through renderer settings APIs. */ + sshPtyConsumerRecoveries?: SshPtyConsumerRecovery[] /** Live local Claude daemon session ids; seeds the live-PTY gate so early OAuth refresh can't rotate the single-use refresh token out from under a running daemon. */ claudeLivePtySessionIds?: string[] migrationUnsupportedPtyEntries: MigrationUnsupportedPtyEntry[] diff --git a/tests/e2e/helpers/docker-ssh-relay-connection.ts b/tests/e2e/helpers/docker-ssh-relay-connection.ts index 4c603365ac5..53fca0dbb0c 100644 --- a/tests/e2e/helpers/docker-ssh-relay-connection.ts +++ b/tests/e2e/helpers/docker-ssh-relay-connection.ts @@ -37,7 +37,7 @@ export async function connectDockerSshRelayTarget( target: { label: `${viaProxyJump ? 'Docker SSH ProxyJump' : 'Docker SSH Relay'} E2E ${Date.now()}`, ...(viaProxyJump ? { configHost: 'orca-e2e-destination' } : {}), - host: '127.0.0.1', + host: target.host, port: viaProxyJump ? 22 : target.port, username: 'root', identityFile: target.identityFile, diff --git a/tests/e2e/helpers/docker-ssh-relay-target.ts b/tests/e2e/helpers/docker-ssh-relay-target.ts index ed625be9dfa..6c6f75dfc75 100644 --- a/tests/e2e/helpers/docker-ssh-relay-target.ts +++ b/tests/e2e/helpers/docker-ssh-relay-target.ts @@ -14,6 +14,7 @@ export const DOCKER_SSH_SECOND_HUB_REMOTE_REPO_PATH = '/tmp/orca-docker-second-h export type DockerSshRelayTarget = { containerName: string containerIp: string + host: string identityFile: string port: number tempDir: string @@ -58,7 +59,7 @@ function sshArgs(target: DockerSshRelayTarget, command: string): string[] { 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', - 'root@127.0.0.1', + `root@${target.host}`, command ] } @@ -122,6 +123,13 @@ export function writeDockerSshRelayTargetFile( } export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTarget { + const host = process.env.ORCA_E2E_SSH_TARGET_HOST?.trim() || '127.0.0.1' + if (host === 'localhost' || host === '::1' || host.startsWith('127.')) { + if (process.env.ORCA_E2E_SSH_TARGET_HOST) { + throw new Error(`ORCA_E2E_SSH_TARGET_HOST must be non-loopback: ${host}`) + } + } + const bindHost = host === '127.0.0.1' ? host : '0.0.0.0' const tempDir = mkdtempSync(path.join(os.tmpdir(), 'orca-ssh-docker-')) const identityFile = path.join(tempDir, 'id_ed25519') run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) @@ -139,7 +147,7 @@ export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTar '--name', containerName, '-p', - '127.0.0.1::22', + `${bindHost}::22`, '-e', `AUTHORIZED_KEY=${publicKey}`, getDockerSshRelayImage(), @@ -169,7 +177,7 @@ export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTar if (!containerIp) { throw new Error(`Unable to read container IP for ${containerName}`) } - target = { containerName, containerIp, identityFile, port, tempDir } + target = { containerName, containerIp, host, identityFile, port, tempDir } waitForSsh(target) seedRemoteRepo(target, DOCKER_SSH_RELAY_REMOTE_REPO_PATH) seedRemoteRepo(target, DOCKER_SSH_PROXY_JUMP_REMOTE_REPO_PATH) @@ -177,7 +185,7 @@ export function startDockerSshRelayTarget(testInfo: TestInfo): DockerSshRelayTar return target } catch (error) { cleanupDockerSshRelayTarget( - target ?? { containerName, containerIp: '', identityFile, port: 0, tempDir } + target ?? { containerName, containerIp: '', host, identityFile, port: 0, tempDir } ) throw error } diff --git a/tests/e2e/landing-preflight-runtime-routing.spec.ts b/tests/e2e/landing-preflight-runtime-routing.spec.ts new file mode 100644 index 00000000000..47fac4646f7 --- /dev/null +++ b/tests/e2e/landing-preflight-runtime-routing.spec.ts @@ -0,0 +1,189 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { createRestartSession } from './helpers/orca-restart' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { addPairedRuntimeEnvironment } from './helpers/nested-runtime-ssh-client-route' + +const missingGitPath = mkdtempSync(path.join(os.tmpdir(), 'orca-preflight-path-')) + +test.use({ seedTestRepo: false }) +test.describe.configure({ mode: 'serial' }) + +test.afterAll(() => { + rmSync(missingGitPath, { recursive: true, force: true }) +}) + +async function setProcessPath(app: ElectronApplication, value: string): Promise { + await app.evaluate((_electron, nextPath) => { + process.env.PATH = nextPath + }, value) +} + +async function directGitPreflight(page: Page): Promise { + return page.evaluate( + async () => (await window.api.preflight.check({ force: true })).git.installed + ) +} + +async function expectLandingGitState( + client: PairedElectronClient, + installed: boolean +): Promise { + await expect + .poll( + () => client.page.evaluate(() => window.__store?.getState().preflightStatus?.git.installed), + { timeout: 30_000 } + ) + .toBe(installed) + const warning = client.page.getByText('Git is not installed', { exact: true }) + await (installed ? expect(warning).toBeHidden() : expect(warning).toBeVisible()) +} + +async function selectRuntime(client: PairedElectronClient, environmentId: string): Promise { + const selected = await client.page.evaluate(async (nextEnvironmentId) => { + const store = window.__store + if (!store) { + throw new Error('Paired desktop store is unavailable') + } + return store.getState().setActiveRuntimeEnvironmentPreference(nextEnvironmentId) + }, environmentId) + expect(selected).toBe(true) +} + +async function runRuntimePreflightJourney( + hubAApp: ElectronApplication, + hubAPage: Page, + testInfo: TestInfo, + headed: boolean +): Promise { + test.setTimeout(240_000) + const hubBSession = createRestartSession(testInfo) + let hubB: Awaited> | null = null + let client: PairedElectronClient | null = null + try { + await hubAPage.waitForFunction( + () => window.__store?.getState().workspaceSessionReady === true, + null, + { timeout: 30_000 } + ) + hubB = await hubBSession.launch() + await hubB.page.waitForFunction( + () => window.__store?.getState().workspaceSessionReady === true, + null, + { timeout: 30_000 } + ) + expect(await directGitPreflight(hubAPage)).toBe(true) + expect(await directGitPreflight(hubB.page)).toBe(true) + + const offerA = await createRuntimeDesktopPairingOffer(hubAPage) + client = await launchPairedElectronClient(offerA, testInfo, 'Preflight runtime A') + await setProcessPath(client.app, missingGitPath) + expect(await client.app.evaluate(() => process.env.PATH)).toBe(missingGitPath) + if (headed) { + await client.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.show()) + expect( + await hubAApp.evaluate( + ({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isVisible() ?? false + ) + ).toBe(true) + expect( + await hubB.app.evaluate( + ({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isVisible() ?? false + ) + ).toBe(true) + } + expect(await directGitPreflight(client.page)).toBe(false) + await client.page.evaluate(() => window.dispatchEvent(new Event('focus'))) + + const environmentA = client.environmentId + await expectLandingGitState(client, true) + const contextA = await client.page.evaluate( + () => window.__store?.getState().preflightStatusContextKey + ) + expect(contextA).toContain(`runtime:${environmentA}#`) + + const offerB = await createRuntimeDesktopPairingOffer(hubB.page) + const environmentB = await addPairedRuntimeEnvironment(client, offerB, 'Preflight runtime B') + await expectLandingGitState(client, true) + expect( + await client.page.evaluate(async (selector) => { + const response = await window.api.runtimeEnvironments.call({ + selector, + method: 'preflight.check', + params: { force: true } + }) + return response.ok && (response.result as { git: { installed: boolean } }).git.installed + }, environmentB) + ).toBe(true) + + await selectRuntime(client, environmentA) + await expectLandingGitState(client, true) + const beforeDisconnectContext = await client.page.evaluate( + () => window.__store?.getState().preflightStatusContextKey + ) + await client.page.evaluate(async (environmentId) => { + const store = window.__store + if (!store) { + throw new Error('Paired desktop store is unavailable') + } + await window.api.runtimeEnvironments.disconnect({ selector: environmentId }) + store.getState().setRuntimeEnvironmentStatus(environmentId, { + status: null, + checkedAt: Date.now() + }) + }, environmentA) + await expect + .poll(() => client!.page.evaluate(() => window.__store?.getState().preflightStatus)) + .toBeNull() + await expect(client.page.getByText('Git is not installed', { exact: true })).toBeHidden() + + await client.page.evaluate(async (environmentId) => { + const store = window.__store + if (!store) { + throw new Error('Paired desktop store is unavailable') + } + const response = await window.api.runtimeEnvironments.connect({ + selector: environmentId, + timeoutMs: 15_000 + }) + if (!response.ok) { + throw new Error(response.error.message) + } + store.getState().setRuntimeEnvironmentStatus(environmentId, { + status: response.result, + checkedAt: Date.now() + }) + }, environmentA) + await expectLandingGitState(client, true) + const reconnectedContext = await client.page.evaluate( + () => window.__store?.getState().preflightStatusContextKey + ) + expect(reconnectedContext).not.toBe(beforeDisconnectContext) + + await selectRuntime(client, environmentB) + await expectLandingGitState(client, true) + } finally { + await client?.dispose() + if (hubB) { + await hubBSession.close(hubB.app) + } + await hubBSession.dispose() + } +} + +test('routes landing preflight across runtime switch and reconnect', async ({ + electronApp, + orcaPage +}, testInfo) => runRuntimePreflightJourney(electronApp, orcaPage, testInfo, false)) + +test('routes landing preflight across runtime switch and reconnect @headful', async ({ + electronApp, + orcaPage +}, testInfo) => runRuntimePreflightJourney(electronApp, orcaPage, testInfo, true)) diff --git a/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts b/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts index 2f6026f0b2e..4406ebd2f18 100644 --- a/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts +++ b/tests/e2e/paired-remote-terminal-stall-recovery.spec.ts @@ -1,7 +1,8 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import type { Page } from '@stablyai/playwright-test' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { PNG } from 'pngjs' import type { RuntimeTerminalRead } from '../../src/shared/runtime-types' import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' import { expect, test } from './helpers/orca-app' @@ -12,6 +13,7 @@ import { import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' const MIN_EXHAUSTED_ACK_BYTES = 400 * 1024 +const PUBLICATION_DEADLINE_MS = 10_000 const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-stalled-stream-')) const fixturePath = path.join(scratch, 'stalled-stream-terminal.mjs') writeFileSync( @@ -30,7 +32,7 @@ writeFileSync( " process.stdout.write('HOST_FLOOD_COMPLETE\\r\\n')", ' continue', ' }', - ' process.stdout.write(`LIVE:${input}\\r\\n`)', + " process.stdout.write(`\\x1b[48;2;24;96;144mLIVE:${input}${' '.repeat(48)}\\x1b[0m\\r\\n`)", ' }', '})', 'process.stdin.resume()' @@ -65,11 +67,169 @@ async function callRuntime(page: Page, method: string, params: unknown) ) as Promise } +type AppResourceProxies = { + gpuCpuPercent: number + gpuIdleWakeupsPerSecond: number + processCount: number + totalCpuPercent: number + totalIdleWakeupsPerSecond: number +} + +async function getAppResourceProxies( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(({ app }) => { + const metrics = app.getAppMetrics() + const gpu = metrics.find((metric) => metric.type === 'GPU') + return { + gpuCpuPercent: gpu?.cpu.percentCPUUsage ?? 0, + gpuIdleWakeupsPerSecond: gpu?.cpu.idleWakeupsPerSecond ?? 0, + processCount: metrics.length, + totalCpuPercent: metrics.reduce((total, metric) => total + metric.cpu.percentCPUUsage, 0), + totalIdleWakeupsPerSecond: metrics.reduce( + (total, metric) => total + metric.cpu.idleWakeupsPerSecond, + 0 + ) + } + }) +} + +async function minimizeHeadedHost(electronApp: ElectronApplication, page: Page): Promise { + const host = await electronApp.browserWindow(page) + await host.evaluate((window) => window.minimize()) + await expect + .poll(() => + host.evaluate((window) => ({ + backgroundThrottling: window.webContents.getBackgroundThrottling(), + minimized: window.isMinimized(), + visible: window.isVisible() + })) + ) + .toEqual({ backgroundThrottling: true, minimized: true, visible: false }) +} + +async function restoreHeadedHost(electronApp: ElectronApplication, page: Page): Promise { + const host = await electronApp.browserWindow(page) + await host.evaluate((window) => { + window.restore() + window.show() + window.focus() + }) + await expect + .poll(() => + host.evaluate((window) => ({ + backgroundThrottling: window.webContents.getBackgroundThrottling(), + minimized: window.isMinimized(), + visible: window.isVisible() + })) + ) + .toEqual({ backgroundThrottling: true, minimized: false, visible: true }) + await expect.poll(() => page.evaluate(() => document.visibilityState)).toBe('visible') + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + ) +} + +async function showHeadedClient(electronApp: ElectronApplication, page: Page): Promise { + const clientWindow = await electronApp.browserWindow(page) + await clientWindow.evaluate((window) => { + window.show() + window.focus() + }) + await expect.poll(() => clientWindow.evaluate((window) => window.isVisible())).toBe(true) +} + +function countForegroundPixels(buffer: Buffer): number { + const image = PNG.sync.read(buffer) + const buckets = new Map() + for (let offset = 0; offset < image.data.length; offset += 4) { + if ((image.data[offset + 3] ?? 0) < 128) { + continue + } + const red = image.data[offset] ?? 0 + const green = image.data[offset + 1] ?? 0 + const blue = image.data[offset + 2] ?? 0 + const key = `${red >> 3},${green >> 3},${blue >> 3}` + const bucket = buckets.get(key) ?? { count: 0, red, green, blue } + bucket.count += 1 + buckets.set(key, bucket) + } + const background = [...buckets.values()].sort((a, b) => b.count - a.count)[0] + if (!background) { + return 0 + } + let foregroundPixels = 0 + for (let offset = 0; offset < image.data.length; offset += 4) { + if ((image.data[offset + 3] ?? 0) < 128) { + continue + } + const distance = + Math.abs((image.data[offset] ?? 0) - background.red) + + Math.abs((image.data[offset + 1] ?? 0) - background.green) + + Math.abs((image.data[offset + 2] ?? 0) - background.blue) + if (distance > 48) { + foregroundPixels += 1 + } + } + return foregroundPixels +} + +function countVisualMarkerPixels(buffer: Buffer): number { + const image = PNG.sync.read(buffer) + let count = 0 + for (let offset = 0; offset < image.data.length; offset += 4) { + const red = image.data[offset] ?? 0 + const green = image.data[offset + 1] ?? 0 + const blue = image.data[offset + 2] ?? 0 + if ( + (image.data[offset + 3] ?? 0) >= 245 && + blue >= 100 && + blue - red >= 50 && + blue - green >= 20 && + green - red >= 10 + ) { + count += 1 + } + } + return count +} + +async function findHostPaneWithMarker( + page: Page, + marker: string +): Promise<{ paneId: number; ptyId: string; tabId: string }> { + let target: { paneId: number; ptyId: string; tabId: string } | null = null + await expect + .poll( + async () => { + target = await page.evaluate((expectedMarker) => { + for (const [tabId, manager] of window.__paneManagers?.entries() ?? []) { + for (const pane of manager.getPanes?.() ?? []) { + const content = pane.serializeAddon?.serialize?.() ?? '' + const ptyId = pane.container?.dataset?.ptyId + if (content.includes(expectedMarker) && ptyId) { + return { paneId: pane.id, ptyId, tabId } + } + } + } + return null + }, marker) + return target !== null + }, + { timeout: 30_000, message: 'host renderer never mirrored the paired terminal marker' } + ) + .toBe(true) + if (!target) { + throw new Error('Host terminal marker target disappeared') + } + return target +} + test('restarts one ACK-starved paired terminal stream without replacing its PTY @headful', async ({ electronApp, orcaPage -}) => { - test.setTimeout(120_000) +}, testInfo) => { + test.setTimeout(150_000) const liveMarker = `PAIRED_STALL_RECOVERED_${Date.now()}` const worktree = await orcaPage.evaluate(() => { const state = window.__store?.getState() @@ -80,11 +240,13 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY } return { id: active.id } }) + const noClientResources = await getAppResourceProxies(electronApp) const offer = await createRuntimeDesktopPairingOffer(orcaPage) const client = await launchPairedWebClient(electronApp, offer, { disableRemoteTerminalStallRecovery: process.env.ORCA_E2E_DISABLE_REMOTE_TERMINAL_STALL_RECOVERY === '1' }) + let observer: Awaited> | null = null let terminal: string | null = null try { await expect @@ -101,8 +263,29 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY { timeout: 30_000 } ) .toBe(true) + const observerOffer = await createRuntimeDesktopPairingOffer(orcaPage) + observer = await launchPairedWebClient(electronApp, observerOffer) + await showHeadedClient(electronApp, client.page) + await showHeadedClient(electronApp, observer.page) + await expect + .poll( + () => + observer?.page.evaluate( + (worktreeId) => + window.__store + ?.getState() + .allWorktrees() + .some((candidate) => candidate.id === worktreeId), + worktree.id + ), + { timeout: 30_000 } + ) + .toBe(true) + const connectedIdleResources = await getAppResourceProxies(electronApp) + await minimizeHeadedHost(electronApp, orcaPage) + const createStartedAt = performance.now() const created = await callRuntime<{ - tab: { parentTabId: string; terminal: string | null } + tab: { id: string; parentTabId: string; terminal: string | null } }>(client.page, 'session.tabs.createTerminal', { worktree: `id:${worktree.id}`, command: fixtureCommand(), @@ -110,6 +293,8 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY select: false, navigation: 'caller' }) + const createLatencyMs = performance.now() - createStartedAt + expect(createLatencyMs).toBeLessThan(PUBLICATION_DEADLINE_MS) terminal = created.tab.terminal if (!terminal) { throw new Error('Paired runtime did not publish the stalled-stream fixture') @@ -128,6 +313,42 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY { timeout: 30_000 } ) .toBe(true) + await expect + .poll( + () => + observer?.page.evaluate( + ({ tabId, worktreeId }) => + (window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.id === tabId + ), + { tabId: webTabId, worktreeId: worktree.id } + ), + { timeout: 30_000 } + ) + .toBe(true) + await expect + .poll( + () => + orcaPage.evaluate( + ({ tabId, worktreeId }) => + (window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.id === tabId + ), + { tabId: created.tab.parentTabId, worktreeId: worktree.id } + ), + { timeout: 30_000 } + ) + .toBe(true) + expect( + ( + await callRuntime<{ tabs: { terminal?: string | null }[] }>( + client.page, + 'session.tabs.list', + { worktree: `id:${worktree.id}` } + ) + ).tabs.some((candidate) => candidate.terminal === terminal), + 'authoritative inventory dropped the terminal before ACK starvation' + ).toBe(true) await client.page.evaluate( (worktreeId) => window.__store?.getState().setActiveWorktree(worktreeId), worktree.id @@ -139,6 +360,20 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY await expect .poll(() => getTerminalContent(client.page), { timeout: 30_000 }) .toContain('PAIRED_STALL_READY') + await observer.page.evaluate( + (worktreeId) => window.__store?.getState().setActiveWorktree(worktreeId), + worktree.id + ) + const observerTab = observer.page.locator( + `[data-testid="sortable-tab"][data-tab-id="${webTabId}"]` + ) + await expect(observerTab).toBeVisible({ timeout: 30_000 }) + await observerTab.click() + const observerOriginalPtyId = await waitForActivePanePtyId(observer.page, 30_000) + expect(observerOriginalPtyId.split('@@').at(-1)).toBe(originalPtyId.split('@@').at(-1)) + await expect + .poll(() => getTerminalContent(observer.page), { timeout: 30_000 }) + .toContain('PAIRED_STALL_READY') await client.page.evaluate((target) => { const gate = ( @@ -233,6 +468,157 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY .toContain(`LIVE:${liveMarker}`) expect(await waitForActivePanePtyId(client.page, 30_000)).toBe(originalPtyId) await expect(tab).toHaveAttribute('data-active', 'true') + await expect + .poll(() => getTerminalContent(observer.page), { timeout: 30_000 }) + .toContain(`LIVE:${liveMarker}`) + expect(await waitForActivePanePtyId(observer.page, 30_000)).toBe(observerOriginalPtyId) + expect( + ( + await callRuntime<{ tabs: { terminal?: string | null }[] }>( + client.page, + 'session.tabs.list', + { worktree: `id:${worktree.id}` } + ) + ).tabs.some((candidate) => candidate.terminal === terminal), + 'authoritative inventory dropped the terminal during ACK recovery' + ).toBe(true) + + await restoreHeadedHost(electronApp, orcaPage) + await orcaPage.evaluate( + (worktreeId) => window.__store?.getState().setActiveWorktree(worktreeId), + worktree.id + ) + const hostTab = orcaPage.locator( + `[data-testid="sortable-tab"][data-tab-id="${created.tab.parentTabId}"]` + ) + await expect(hostTab).toBeVisible({ timeout: 30_000 }) + await hostTab.click() + const hostPane = await findHostPaneWithMarker(orcaPage, `LIVE:${liveMarker}`) + expect(hostPane.tabId).toBe(created.tab.parentTabId) + await orcaPage.evaluate(({ paneId, tabId }) => { + const manager = window.__paneManagers?.get(tabId) + manager?.setActivePane?.(paneId, { focus: true }) + }, hostPane) + await expect + .poll(() => getTerminalContent(orcaPage), { timeout: 30_000 }) + .toContain(`LIVE:${liveMarker}`) + const restoredTerminalScreenshot = await orcaPage + .locator( + `[data-terminal-tab-id="${hostPane.tabId}"] .pane[data-pane-id="${hostPane.paneId}"] .xterm-screen` + ) + .screenshot({ animations: 'disabled' }) + await testInfo.attach('host-terminal-after-create-restore', { + body: restoredTerminalScreenshot, + contentType: 'image/png' + }) + expect( + countVisualMarkerPixels(restoredTerminalScreenshot), + 'host terminal marker did not repaint after the background publication toggle' + ).toBeGreaterThan(500) + expect( + ( + await callRuntime<{ tabs: { terminal?: string | null }[] }>( + client.page, + 'session.tabs.list', + { worktree: `id:${worktree.id}` } + ) + ).tabs.some((candidate) => candidate.terminal === terminal), + 'authoritative inventory dropped the terminal while restoring the host' + ).toBe(true) + + await minimizeHeadedHost(electronApp, orcaPage) + await showHeadedClient(electronApp, observer.page) + + const authoritativeInventory = await callRuntime<{ + tabs: { id: string; parentTabId?: string; terminal?: string | null }[] + }>(client.page, 'session.tabs.list', { worktree: `id:${worktree.id}` }) + const authoritativeTab = authoritativeInventory.tabs.find( + (candidate) => candidate.terminal === terminal + ) + if (!authoritativeTab) { + throw new Error( + `Paired terminal was absent from authoritative inventory before close: ${JSON.stringify( + authoritativeInventory.tabs.map((candidate) => ({ + id: candidate.id, + parentTabId: candidate.parentTabId, + terminal: candidate.terminal + })) + )}` + ) + } + const closeStartedAt = performance.now() + await callRuntime(client.page, 'session.tabs.close', { + worktree: `id:${worktree.id}`, + tabId: authoritativeTab.id, + reason: 'user', + navigation: 'caller' + }) + const closeLatencyMs = performance.now() - closeStartedAt + expect(closeLatencyMs).toBeLessThan(PUBLICATION_DEADLINE_MS) + await expect + .poll( + () => + Promise.all([ + orcaPage.evaluate( + ({ tabId, worktreeId }) => + (window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some( + (candidate) => candidate.id === tabId + ), + { tabId: created.tab.parentTabId, worktreeId: worktree.id } + ), + client.page.evaluate( + ({ tabId, worktreeId }) => + (window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some( + (candidate) => candidate.id === tabId + ), + { tabId: webTabId, worktreeId: worktree.id } + ), + observer.page.evaluate( + ({ tabId, worktreeId }) => + (window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some( + (candidate) => candidate.id === tabId + ), + { tabId: webTabId, worktreeId: worktree.id } + ) + ]), + { timeout: 30_000 } + ) + .toEqual([false, false, false]) + terminal = null + + await restoreHeadedHost(electronApp, orcaPage) + const restoredHostScreenshot = await orcaPage.screenshot({ fullPage: true }) + expect( + countForegroundPixels(restoredHostScreenshot), + 'host compositor remained blank after the background close toggle' + ).toBeGreaterThan(1_000) + await testInfo.attach('host-window-after-close-restore', { + body: restoredHostScreenshot, + contentType: 'image/png' + }) + + const afterPublicationResources = await getAppResourceProxies(electronApp) + const evidencePath = test.info().outputPath('publication-evidence.json') + writeFileSync( + evidencePath, + `${JSON.stringify( + { + afterPublicationResources, + closeLatencyMs, + connectedIdleResources, + createLatencyMs, + hostMinimizedDuringCreateAndClose: true, + hostRestoredAfterEachPublication: true, + noClientResources + }, + null, + 2 + )}\n` + ) + await test.info().attach('publication-evidence', { + contentType: 'application/json', + path: evidencePath + }) } finally { await client.page .evaluate(() => { @@ -243,6 +629,7 @@ test('restarts one ACK-starved paired terminal stream without replacing its PTY ).__remoteTerminalMultiplexAckGate?.release() }) .catch(() => undefined) + await observer?.dispose() if (terminal) { await callRuntime(client.page, 'terminal.closeTab', { terminal }).catch(() => undefined) } diff --git a/tests/e2e/ssh-cold-activation-restore.spec.ts b/tests/e2e/ssh-cold-activation-restore.spec.ts index 56abd91539d..25827482a92 100644 --- a/tests/e2e/ssh-cold-activation-restore.spec.ts +++ b/tests/e2e/ssh-cold-activation-restore.spec.ts @@ -1,4 +1,4 @@ -import type { Page } from '@stablyai/playwright-test' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { @@ -14,6 +14,7 @@ import { type DockerSshRelayTarget } from './helpers/docker-ssh-relay-target' import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { createRestartSession } from './helpers/orca-restart' const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' const TAB_COUNT = 6 @@ -54,6 +55,14 @@ async function readRemoteTerminalTabs( ) } +function readRemoteProof(target: DockerSshRelayTarget, path: string): string | null { + try { + return execDockerSshRelayTargetCommand(target, `cat ${path}`) + } catch { + return null + } +} + test.describe('SSH cold activation restore', () => { test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') test.skip(process.platform === 'win32', 'Docker SSH restore uses POSIX SSH tooling.') @@ -200,4 +209,103 @@ test.describe('SSH cold activation restore', () => { cleanupDockerSshRelayTarget(target) } }) + + test('reclaims the authenticated PTY owner immediately after a full app restart', async (// oxlint-disable-next-line no-empty-pattern -- This restart test owns both Electron launches. + {}, testInfo) => { + test.setTimeout(300_000) + const restart = createRestartSession(testInfo) + let target: DockerSshRelayTarget | null = null + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + target = startDockerSshRelayTarget(testInfo) + const firstLaunch = await restart.launch() + firstApp = firstLaunch.app + await waitForSessionReady(firstLaunch.page) + const remote = await connectDockerSshRelayTarget(firstLaunch.page, target) + await expect + .poll(() => waitForActiveWorktree(firstLaunch.page), { timeout: 30_000 }) + .toBe(remote.worktreeId) + await waitForActiveTerminalManager(firstLaunch.page, 60_000) + const firstPtyId = await waitForActivePanePtyId(firstLaunch.page, 60_000) + const token = `SSH_PROCESS_RESTART_${Date.now()}` + const beforeProofPath = `/tmp/orca-ssh-restart-before-${Date.now()}` + const afterProofPath = `/tmp/orca-ssh-restart-after-${Date.now()}` + + await focusActiveTerminalInput(firstLaunch.page) + await firstLaunch.page.keyboard.type( + `export ORCA_RESTART_TOKEN=${token}; cd /tmp; (while :; do sleep 60; done) & export ORCA_BG_PID=$!; printf '%s|%s|%s|%s\\n' "$$" "$ORCA_BG_PID" "$ORCA_RESTART_TOKEN" "$PWD" > ${beforeProofPath}` + ) + await firstLaunch.page.keyboard.press('Enter') + await expect.poll(() => readRemoteProof(target!, beforeProofPath)).not.toBeNull() + const beforeProof = readRemoteProof(target, beforeProofPath) + expect(beforeProof).toMatch(/^\d+\|\d+\|SSH_PROCESS_RESTART_\d+\|\/tmp$/) + + const beforeTabs = await readRemoteTerminalTabs(firstLaunch.page, remote.worktreeId) + const restoredTabId = beforeTabs.find((tab) => tab.ptyId === firstPtyId)?.id + if (!restoredTabId) { + throw new Error('Active SSH terminal was not persisted in its worktree') + } + await firstLaunch.page.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await expect + .poll( + () => + firstLaunch.page.evaluate( + async ({ targetId, worktreeId, tabId }) => { + const persisted = await window.api.session.get() + return ( + persisted.activeConnectionIdsAtShutdown?.includes(targetId) === true && + persisted.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) === true + ) + }, + { targetId: remote.targetId, worktreeId: remote.worktreeId, tabId: restoredTabId } + ), + { timeout: 10_000, message: 'SSH restart state was not persisted before quit' } + ) + .toBe(true) + + await restart.close(firstApp) + firstApp = null + + const secondLaunch = await restart.launch() + secondApp = secondLaunch.app + await waitForSessionReady(secondLaunch.page, 60_000) + await expect + .poll(() => waitForActiveWorktree(secondLaunch.page), { timeout: 60_000 }) + .toBe(remote.worktreeId) + await waitForActiveTerminalManager(secondLaunch.page, 60_000) + expect(await waitForActivePanePtyId(secondLaunch.page, 60_000)).toBe(firstPtyId) + await secondLaunch.page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error('Restored SSH pane unavailable') + } + pane.terminal.options.screenReaderMode = true + pane.terminal.refresh(0, pane.terminal.rows - 1) + }, restoredTabId) + + const restoredMarker = `SSH_OWNER_RESTORED_${Date.now()}` + await focusActiveTerminalInput(secondLaunch.page) + await secondLaunch.page.keyboard.type( + `printf '%s|%s|%s|%s\\n' "$$" "$ORCA_BG_PID" "$ORCA_RESTART_TOKEN" "$PWD" > ${afterProofPath}; printf '${restoredMarker}\\n'` + ) + await secondLaunch.page.keyboard.press('Enter') + await expect( + secondLaunch.page.locator( + `[data-terminal-tab-id=${JSON.stringify(restoredTabId)}] .xterm-accessibility-tree` + ) + ).toContainText(restoredMarker, { timeout: 30_000 }) + await expect.poll(() => readRemoteProof(target!, afterProofPath)).toBe(beforeProof) + } finally { + if (secondApp) { + await restart.close(secondApp) + } + if (firstApp) { + await restart.close(firstApp) + } + await restart.dispose() + cleanupDockerSshRelayTarget(target) + } + }) }) diff --git a/tests/e2e/workspace-board-lane-virtualization.spec.ts b/tests/e2e/workspace-board-lane-virtualization.spec.ts index 58db14bcdd1..44042ce16f0 100644 --- a/tests/e2e/workspace-board-lane-virtualization.spec.ts +++ b/tests/e2e/workspace-board-lane-virtualization.spec.ts @@ -366,14 +366,21 @@ test.describe('Workspace board lane virtualization', () => { const laneCards = lane.locator('[data-workspace-board-card-id]') await expect.poll(() => laneCards.count(), { timeout: 15_000 }).toBeGreaterThan(3) const laneScroll = lane.locator('[data-workspace-board-lane-scroll]') + const selectionSurface = orcaPage.locator('[data-workspace-board-selection-surface]') const box = await laneScroll.boundingBox() - if (!box) { - throw new Error('Expected the marquee lane to have a bounding box') + const selectionBox = await selectionSurface.boundingBox() + if (!box || !selectionBox) { + throw new Error('Expected the marquee lane and selection surface to have bounding boxes') } - await orcaPage.mouse.move(box.x + 2, box.y + 12) + await orcaPage.mouse.move(selectionBox.x + 4, box.y + 12) await orcaPage.mouse.down() - await orcaPage.mouse.move(box.x + box.width - 18, box.y + 80) + await orcaPage.mouse.move(box.x + box.width - 18, box.y + 80, { steps: 4 }) + await expect + .poll(() => lane.locator('[data-workspace-board-card-area-selected="true"]').count(), { + timeout: 15_000 + }) + .toBeGreaterThan(0) await laneScroll.evaluate(async (element) => { for (let pass = 0; pass < 4; pass++) { element.scrollTop = element.scrollHeight diff --git a/tests/e2e/worktree-lineage.spec.ts b/tests/e2e/worktree-lineage.spec.ts index 65bcda63f7b..0f9ddb33496 100644 --- a/tests/e2e/worktree-lineage.spec.ts +++ b/tests/e2e/worktree-lineage.spec.ts @@ -1,4 +1,4 @@ -import type { Page } from '@stablyai/playwright-test' +import type { Locator, Page } from '@stablyai/playwright-test' import { mkdirSync } from 'node:fs' import { resolve } from 'node:path' import { test, expect } from './helpers/orca-app' @@ -15,18 +15,22 @@ function worktreeOption(page: Page, worktreeId: string) { return worktreeRow(page, worktreeId) } -async function captureSidebarEvidence(page: Page, name: string): Promise { +async function captureEvidence(page: Page, name: string, locator?: Locator): Promise { if (process.env.ORCA_CAPTURE_EVIDENCE !== '1') { return } const outputDir = resolve(process.cwd(), 'pr-evidence') mkdirSync(outputDir, { recursive: true }) - await page - .locator('[data-worktree-sidebar]') - .first() - .screenshot({ - path: resolve(outputDir, name) - }) + const path = resolve(outputDir, name) + if (locator) { + await locator.screenshot({ path }) + return + } + await page.screenshot({ path }) +} + +async function captureSidebarEvidence(page: Page, name: string): Promise { + await captureEvidence(page, name, page.locator('[data-worktree-sidebar]').first()) } test.describe('Worktree Lineage', () => { @@ -205,6 +209,79 @@ test.describe('Worktree Lineage', () => { await expect(childRow).toContainText('Inactive') }) + test('sleeps a workspace and every descendant from the parent context menu', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage) + await orcaPage.evaluate((parentId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.setState((current) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(current.worktreesByRepo).map(([repoId, worktrees]) => [ + repoId, + worktrees.map((worktree) => + worktree.id === parentId ? { ...worktree, isMainWorktree: false } : worktree + ) + ]) + ) + })) + }, parentId) + const parentTabId = await seedWorkspaceLiveTerminal(orcaPage, parentId) + const childTabId = await seedWorkspaceLiveTerminal(orcaPage, childId) + + await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.setState({ + shutdownWorktreeBrowsers: async (worktreeId: string) => { + store.setState((current) => ({ + browserTabsByWorktree: { ...current.browserTabsByWorktree, [worktreeId]: [] } + })) + }, + shutdownWorktreeTerminals: async (worktreeId: string) => { + const tabIds = (store.getState().tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + store.setState((current) => ({ + ptyIdsByTabId: { + ...current.ptyIdsByTabId, + ...Object.fromEntries(tabIds.map((tabId) => [tabId, []])) + } + })) + } + }) + window.api.ephemeralVm.suspendWorkspace = async () => null + }) + + await worktreeOption(orcaPage, parentId).click({ button: 'right' }) + const sleepSubtree = orcaPage.getByRole('menuitem', { + name: 'Sleep with Descendants (1)' + }) + await expect(sleepSubtree).toBeVisible() + await expect(sleepSubtree).toBeEnabled() + await expect(orcaPage.getByRole('menuitem', { name: 'Delete with Descendants…' })).toBeVisible() + await captureEvidence(orcaPage, 'workspace-descendant-actions.png') + await sleepSubtree.click() + + await expect + .poll(() => + orcaPage.evaluate( + ({ parentTabId, childTabId }) => { + const state = window.__store?.getState() + return { + parentPtys: state?.ptyIdsByTabId[parentTabId], + childPtys: state?.ptyIdsByTabId[childTabId] + } + }, + { parentTabId, childTabId } + ) + ) + .toEqual({ parentPtys: [], childPtys: [] }) + }) + test('shows parent and child agent rows while the parent workspace is active', async ({ orcaPage }) => { diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index ffb602686da..6c2969f53ad 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -190,7 +190,7 @@ test.describe('Create Workspace', () => { } }) - test('enters emoji with Slack-style shortcode suggestions', async ({ orcaPage }) => { + test('enters the Korean flag with the kr shortcode suggestion', async ({ orcaPage }) => { try { await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() @@ -198,11 +198,11 @@ test.describe('Create Workspace', () => { const nameInput = dialog.getByPlaceholder(/Type a name/i) await expect(nameInput).toBeVisible() - await nameInput.pressSequentially('Launch :wink', { delay: 100 }) + await nameInput.pressSequentially('Launch :kr', { delay: 100 }) const emojiSuggestions = orcaPage.locator('[data-workspace-emoji-suggestions="true"]') const sourceSuggestions = orcaPage.locator('[data-workspace-source-suggestions="true"]') await expect(emojiSuggestions).toBeVisible() - await expect(emojiSuggestions.getByRole('option', { name: ':wink:' })).toBeVisible() + await expect(emojiSuggestions.getByRole('option', { name: ':kr:' })).toBeVisible() await expect(emojiSuggestions).toHaveAttribute('data-side', 'top') await expect(sourceSuggestions).toBeVisible() await expect(sourceSuggestions).toHaveAttribute('data-side', 'bottom') @@ -210,10 +210,10 @@ test.describe('Create Workspace', () => { await orcaPage.waitForTimeout(750) await nameInput.pressSequentially(':') - await expect(nameInput).toHaveValue('Launch 😉') - await expect(orcaPage.getByRole('option', { name: /:wink:/i })).toHaveCount(0) + await expect(nameInput).toHaveValue('Launch 🇰🇷') + await expect(orcaPage.getByRole('option', { name: /:kr:/i })).toHaveCount(0) await nameInput.pressSequentially(' experiment') - await expect(nameInput).toHaveValue('Launch 😉 experiment') + await expect(nameInput).toHaveValue('Launch 🇰🇷 experiment') // Keep the asserted result visible in retained proof recordings. await orcaPage.waitForTimeout(750) } finally {