diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 8024d498918..218ff6a6c82 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -10,7 +10,14 @@ on: - 'config/scripts/computer-use-skill-guidance.test.mjs' - 'config/scripts/computer-use-smoke.mjs' - 'config/scripts/computer-use-smoke.test.mjs' + - 'config/scripts/daemon-boot-smoke.mjs' - 'config/scripts/verify-computer-native.mjs' + # Why: the native-smoke job boots the built terminal daemon under plain + # Node, so any change to the daemon bundle graph or the main build must + # re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI). + - 'electron.vite.config.ts' + - 'build-plugins/**' + - 'src/main/daemon/**' - 'native/computer-use-macos/**' - 'native/computer-use-linux/**' - 'native/computer-use-windows/**' @@ -98,6 +105,12 @@ jobs: - run: pnpm verify:computer-native - run: pnpm build:cli - run: pnpm build:electron-vite + # Why: boot the BUILT daemon-entry under plain Node the way production + # forks it. v1.4.129-rc.1 shipped a daemon that exited at module load + # (leaked electron require) while every other check passed; this fails + # the PR when the built daemon cannot start on ubuntu-22.04 / windows. + - name: Daemon boot smoke + run: node config/scripts/daemon-boot-smoke.mjs - if: runner.os == 'Linux' env: ORCA_COMPUTER_E2E: '1' diff --git a/.github/workflows/daemon-relocation-spike.yml b/.github/workflows/daemon-relocation-spike.yml new file mode 100644 index 00000000000..319580dc264 --- /dev/null +++ b/.github/workflows/daemon-relocation-spike.yml @@ -0,0 +1,97 @@ +name: Daemon Relocation Spike + +# Why: Phase 1 of the Windows update-survival work relocates the terminal +# daemon's file closure out of the install dir so it survives an update. Before +# implementing it in the app, this spike finds the MINIMAL set of packaged files +# that a copied Orca.exe (run as node) needs to start the daemon and drive a +# ConPTY session from a relocated directory, holding no install-dir file locks. +# Runs from the feature branch via push (workflow_dispatch only works on the +# default branch); main is never touched. + +on: + push: + branches: + - Jinwoo-H/windows-update-survival + paths: + - 'tools/daemon-relocation-spike/**' + - '.github/workflows/daemon-relocation-spike.yml' + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: daemon-relocation-spike-${{ github.ref }} + cancel-in-progress: true + +jobs: + spike: + name: relocation file-set spike + runs-on: windows-2022 + timeout-minutes: 40 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # This job only reads the repo to build/run the spike; it never pushes. + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Why: cache the unpacked build keyed on the inputs that affect it, so + # spike-script-only edits skip the ~15 min electron-builder build. + - name: Cache unpacked build + id: cache-unpacked + uses: actions/cache@v4 + with: + path: dist/win-unpacked + key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }} + + - name: Build unpacked app + if: steps.cache-unpacked.outputs.cache-hit != 'true' + run: pnpm run build:unpack + + - name: Run relocation spike (all tiers) + shell: pwsh + run: | + New-Item -ItemType Directory -Force artifacts | Out-Null + $log = "artifacts/spike-output.log" + $tiers = @('full', 'no-gpu', 'minimal') + $any = $false + foreach ($tier in $tiers) { + "===== TIER: $tier =====" | Tee-Object -FilePath $log -Append + $work = Join-Path $env:RUNNER_TEMP "spike-$tier" + # --keep-work-dir so the per-tier daemon stdout/stderr logs survive + # for the artifact upload (the spike otherwise removes work-dir), + # which is what diagnoses a tier that fails to reach ready. + node tools/daemon-relocation-spike/spike.mjs ` + --app-dir dist/win-unpacked ` + --work-dir "$work" ` + --tier $tier ` + --keep-work-dir 2>&1 | Tee-Object -FilePath $log -Append + if ($LASTEXITCODE -eq 0) { $any = $true } + } + if (-not $any) { throw "No tier passed the relocation spike" } + + - name: Upload spike output + if: always() + uses: actions/upload-artifact@v7 + with: + name: daemon-relocation-spike-output + path: | + artifacts/spike-output.log + ${{ runner.temp }}/spike-*/**/*.log + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 96af9bd0cd1..77e1dbcb7f8 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -719,6 +719,10 @@ jobs: ~/.cache/electron-builder runs-on: ${{ matrix.os }} + # Why: hosted runners hard-cap jobs at 6h; the Windows SignPath waits + # (1h inner + 4h installer) are budgeted to fit under this with the + # build itself, so a slow approval can't kill the job mid-flow. + timeout-minutes: 360 permissions: actions: read @@ -958,6 +962,244 @@ jobs: } } + # ── Windows inner-binary signing (issue #7785) ───────────────────── + # Why: SignPath cannot deep-sign inside NSIS installers, so inner PE + # files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip + # request, then the installer is rebuilt from the signed tree before the + # existing installer signing request below. Every step in this chain is + # fail-open (continue-on-error + outcome gating): any failure ships the + # original installer with unsigned inner binaries, exactly like releases + # did before this chain existed. Rehearsed end to end in run 28988432001 + # (.github/workflows/windows-signing-rehearsal.yml). + + # Why: only unsigned PE files go to SignPath. Files that already carry a + # valid signature (Microsoft's OpenConsole.exe) must keep their signer. + - name: Stage unsigned inner PE files for signing + id: stage-inner + if: matrix.platform == 'win' + continue-on-error: true + shell: pwsh + run: | + $root = Resolve-Path 'dist/win-unpacked' + $stage = New-Item -ItemType Directory -Force -Path 'signing-stage' + $list = New-Object System.Collections.Generic.List[string] + $skipped = New-Object System.Collections.Generic.List[string] + + Get-ChildItem -Path $root -Recurse -File | + Where-Object { $_.Extension -in '.exe', '.dll', '.node' } | + ForEach-Object { + $relative = [System.IO.Path]::GetRelativePath($root, $_.FullName) + $signature = Get-AuthenticodeSignature -FilePath $_.FullName + if ($signature.Status -eq 'Valid') { + $skipped.Add("$relative ") + return + } + $destination = Join-Path $stage.FullName $relative + New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null + Copy-Item -Path $_.FullName -Destination $destination -Force + $list.Add($relative) + } + + if (-not ($list -contains 'Orca.exe')) { + throw 'Orca.exe was not staged for signing; unpacked layout changed?' + } + if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) { + throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.' + } + + Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n") + Write-Host "Staged $($list.Count) unsigned PE files for signing:" + $list | ForEach-Object { Write-Host " $_" } + Write-Host "Skipped $($skipped.Count) already-signed files:" + $skipped | ForEach-Object { Write-Host " $_" } + + - name: Upload unsigned inner binaries for SignPath + id: upload-unsigned-inner + if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success' + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }} + path: signing-stage/** + if-no-files-found: error + + - name: Submit inner binaries signing request + id: submit-inner-signing + if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success' + continue-on-error: true + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0 + project-slug: orca + signing-policy-slug: release-signing + artifact-configuration-slug: windows-inner-binaries-zip + github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }} + wait-for-completion: false + + - name: Notify Slack that inner-binary signing is waiting for approval + id: notify-inner-signing + if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' + continue-on-error: true + shell: pwsh + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0 + SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }} + SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }} + TAG: ${{ needs.cut.outputs.tag }} + GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) { + throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.' + } + + $requestUrl = $env:SIGNPATH_REQUEST_URL + if ([string]::IsNullOrWhiteSpace($requestUrl)) { + $requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID" + } + + $message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>" + $payload = @{ + text = $message + blocks = @( + @{ + type = 'section' + text = @{ + type = 'mrkdwn' + text = $message + } + } + ) + } | ConvertTo-Json -Depth 5 + + Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload + + # Why gate on the notify outcome too: if nobody was told to approve, + # don't hold the release for the approval window — fall through and + # ship like today instead. The 1h wait (vs the installer's 4h) keeps + # both waits plus the build inside the 360-minute job cap; missing it + # falls through to today's unsigned-inner flow rather than blocking. + - name: Download signed inner binaries from SignPath + id: download-signed-inner + if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success' + continue-on-error: true + shell: pwsh + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }} + run: | + Get-SignedArtifact ` + -OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 ` + -ApiToken $env:SIGNPATH_API_TOKEN ` + -SigningRequestId $env:SIGNPATH_REQUEST_ID ` + -OutputArtifactPath signed-inner.zip ` + -Force ` + -WaitForCompletionTimeoutInSeconds 3600 + + New-Item -ItemType Directory -Path signed-inner -Force + Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force + + # Why: copy back strictly by the staged list so a layout mismatch in the + # returned artifact fails loudly (into fail-open) instead of silently + # shipping a mix of signed and unsigned binaries. + - name: Restore signed inner binaries into unpacked app + id: restore-signed-inner + if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success' + continue-on-error: true + shell: pwsh + run: | + $root = Resolve-Path 'dist/win-unpacked' + $failures = New-Object System.Collections.Generic.List[string] + foreach ($relative in Get-Content 'inner-signing-list.txt') { + $signed = Get-ChildItem -Path signed-inner -Recurse -File | + Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } | + Select-Object -First 1 + if ($null -eq $signed) { + $failures.Add("missing from signed artifact: $relative") + continue + } + $signature = Get-AuthenticodeSignature -FilePath $signed.FullName + if ($null -eq $signature.SignerCertificate) { + $failures.Add("returned without a signature: $relative") + continue + } + Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force + Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject) + } + if ($failures.Count -gt 0) { + $failures | ForEach-Object { Write-Host "::error::$_" } + throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)." + } + + # Why this step exists: electron-builder's CopyElevateHelper re-copies a + # pristine elevate.exe from its download cache over resources\elevate.exe + # on EVERY nsis pack — including the --prepackaged rebuild below — which + # clobbered the SignPath signature in v1.4.129-rc.4. There is no supported + # way to disable just the copy, so we overwrite the cache's copy with our + # signed one (identical bytes plus signature) so the clobber becomes a + # no-op. Known quirk: the cache persists across releases via actions/cache, + # so later runs may see elevate.exe as already signed and skip staging it — + # that is fine (the signature is timestamped) and the evidence gate checks + # elevate.exe in the shipped installer unconditionally. If this ever causes + # trouble, delete this step; the only effect is elevate.exe shipping + # unsigned again, which the evidence gate will flag. + - name: Replace cached elevate.exe with the signed copy + id: sign-elevate-cache + if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success' + continue-on-error: true + shell: pwsh + run: | + $signed = 'dist/win-unpacked/resources/elevate.exe' + if (-not (Test-Path $signed)) { + Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.' + exit 0 + } + $signature = Get-AuthenticodeSignature -FilePath $signed + $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } + if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') { + Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap." + exit 0 + } + $cached = @(Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter elevate.exe -ErrorAction SilentlyContinue) + if ($cached.Count -eq 0) { + Write-Host '::warning::No cached elevate.exe found (electron-builder cache layout changed?); the rebuild will pack the unsigned copy and the evidence gate will flag it.' + exit 0 + } + foreach ($file in $cached) { + Copy-Item -Path $signed -Destination $file.FullName -Force + Write-Host "Replaced $($file.FullName) with the SignPath-signed copy." + } + + - name: Rebuild NSIS installer from signed unpacked app + id: rebuild-nsis-signed + if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success' + continue-on-error: true + shell: pwsh + run: | + # Why: keep the pre-rebuild artifacts so a failed rebuild can fall + # back to shipping them unchanged (fail-open). + New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null + Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force + Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force + + pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if (-not (Test-Path 'dist/orca-windows-setup.exe')) { + throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe' + } + + - name: Roll back to original installer after failed rebuild + if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure' + shell: pwsh + run: | + if (Test-Path 'prepack-backup/orca-windows-setup.exe') { + Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force + Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force + Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.' + } + # ── End Windows inner-binary signing ─────────────────────────────── + - name: Upload unsigned Windows installer for SignPath if: matrix.platform == 'win' id: upload-unsigned-windows-installer @@ -992,6 +1234,7 @@ jobs: SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }} TAG: ${{ needs.cut.outputs.tag }} GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }} run: | if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) { throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.' @@ -1002,7 +1245,9 @@ jobs: $requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID" } - $message = "Orca Windows release $env:TAG is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>" + # Why: releases where inner signing fell through have only this one request. + $stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' } + $message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>" $payload = @{ text = $message blocks = @( @@ -1085,6 +1330,90 @@ jobs: } $signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint + # Why: evidence gate for inner-binary signing (issue #7785, supersedes + # PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node + # by extracting the shipped installer). Warn-only until the flow has been + # proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED + # to 'true' so unsigned inner binaries block the release. + - name: Verify Windows inner binary signatures + if: matrix.platform == 'win' + shell: pwsh + env: + ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false' + INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }} + run: | + $required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true' + if ($env:INNER_SIGNING_COMPLETED -ne 'true') { + $message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).' + if ($required) { throw $message } + Write-Host "::warning::$message" + exit 0 + } + + # Why try/catch: while the gate is warn-only, even an unexpected + # script error (extraction hiccup, missing file) must not block + # the release — only the flip to required makes failures fatal. + try { + $report = New-Object System.Collections.Generic.List[string] + $failures = New-Object System.Collections.Generic.List[string] + + # Why: verify the files a user actually gets on disk, not the build + # tree — 7z parses the NSIS exe directly as its embedded payload. + $7za = 'node_modules/7zip-bin/win/x64/7za.exe' + New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null + & $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null + + $root = Resolve-Path 'inner-evidence-extract' + # Why elevate.exe is always appended: staging skips already-signed + # files, and the persisted electron-builder cache can carry a + # previously signed elevate.exe — so it may be absent from the list + # in some runs, yet it is the file most at risk of losing its + # signature in the NSIS rebuild. Verify it in every release. + $targets = @(Get-Content 'inner-signing-list.txt') + if ($targets -notcontains 'resources\elevate.exe') { + $targets += 'resources\elevate.exe' + } + foreach ($relative in $targets) { + $path = Join-Path $root $relative + if (-not (Test-Path $path)) { + $failures.Add("missing from installer payload: $relative") + continue + } + $signature = Get-AuthenticodeSignature -FilePath $path + $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } + $line = "{0,-14} {1} <{2}>" -f $signature.Status, $relative, $subject + $report.Add($line) + Write-Host $line + if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') { + $failures.Add("not signed by SignPath Foundation: $relative ($($signature.Status), $subject)") + } + } + + Set-Content -Path 'inner-signing-evidence.txt' -Value ($report -join "`n") + if ($failures.Count -gt 0) { + $failures | ForEach-Object { Write-Host "::warning::$_" } + $message = "Windows inner-binary evidence gate found $($failures.Count) problems." + if ($required) { throw $message } + Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'." + } else { + Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation." + } + } catch { + if ($required) { throw } + Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)." + } + + - name: Upload Windows inner signing evidence + if: always() && matrix.platform == 'win' + uses: actions/upload-artifact@v7 + with: + name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }} + path: | + inner-signing-evidence.txt + inner-signing-list.txt + if-no-files-found: ignore + retention-days: 30 + - name: Publish signed Windows release artifacts if: matrix.platform == 'win' uses: nick-fields/retry@v4 diff --git a/.github/workflows/win-update-e2e.yml b/.github/workflows/win-update-e2e.yml new file mode 100644 index 00000000000..21c788256b2 --- /dev/null +++ b/.github/workflows/win-update-e2e.yml @@ -0,0 +1,145 @@ +name: Windows Update-Survival E2E + +# Why: proves what happens to terminal sessions across a real Windows app update +# by installing one released version, updating to another, and asserting on the +# packaged artifacts (daemon survival, terminal interactivity, zero console +# flashes). This MUST run on a disposable CI Windows: electron-builder's oneClick +# installer uninstalls the registry-registered copy of the app before every +# install, so running the harness on a machine with a real Orca install would +# delete it. A fresh runner has no Orca registered, so it is the only safe home +# for install-based testing. Manual dispatch only. + +# Why: workflow_dispatch only works once a workflow is on the default branch. +# To exercise this from the unmerged feature branch WITHOUT touching main, a +# push trigger scoped to that exact branch runs the workflow from the branch's +# own tree. The paths filter keeps it to harness/workflow edits so ordinary +# commits don't spend 30 min of Windows runner time. Push runs have no inputs, +# so every parameter falls back to a default below. +on: + push: + branches: + - Jinwoo-H/windows-update-survival + paths: + - 'tools/win-update-e2e/**' + - '.github/workflows/win-update-e2e.yml' + workflow_dispatch: + inputs: + from_tag: + description: Release tag to install first (e.g. v1.4.124-rc.8) + required: true + type: string + to_tag: + description: Release tag to update to (e.g. v1.4.124-rc.9) + required: true + type: string + expect: + description: Expected outcome profile + required: true + type: choice + default: cold-restore + options: + - cold-restore + - survival + asset_pattern: + description: Installer asset glob + required: false + type: string + default: '*windows-setup.exe' + soak_seconds: + description: Post-relaunch console-window watch duration + required: false + type: string + default: '60' + +permissions: + contents: read + +# Why: cancel a superseded run when iterating — a new push to the branch makes +# the in-flight Windows job obsolete, so free the runner immediately. +concurrency: + group: win-update-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + update-e2e: + name: update ${{ inputs.from_tag || 'v1.4.124-rc.8' }} -> ${{ inputs.to_tag || 'v1.4.124-rc.9' }} (${{ inputs.expect || 'cold-restore' }}) + runs-on: windows-2022 + timeout-minutes: 30 + env: + FROM_TAG: ${{ inputs.from_tag || 'v1.4.124-rc.8' }} + TO_TAG: ${{ inputs.to_tag || 'v1.4.124-rc.9' }} + EXPECT: ${{ inputs.expect || 'cold-restore' }} + ASSET_PATTERN: ${{ inputs.asset_pattern || '*windows-setup.exe' }} + SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # This job only reads the repo and downloads a release; it never pushes. + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + # Why: the harness only needs its runtime deps (the Playwright Electron + # driver); it drives already-built installer artifacts, so no app build. + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Why: fail fast with a readable message if a tag has no matching Windows + # installer, rather than deep inside the harness. + - name: Download installers + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + New-Item -ItemType Directory -Force artifacts/from | Out-Null + New-Item -ItemType Directory -Force artifacts/to | Out-Null + gh release download "$env:FROM_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/from + gh release download "$env:TO_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/to + $from = Get-ChildItem artifacts/from -Filter *.exe | Select-Object -First 1 + $to = Get-ChildItem artifacts/to -Filter *.exe | Select-Object -First 1 + if (-not $from) { throw "No installer matching '$env:ASSET_PATTERN' in $env:FROM_TAG" } + if (-not $to) { throw "No installer matching '$env:ASSET_PATTERN' in $env:TO_TAG" } + "FROM_EXE=$($from.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "TO_EXE=$($to.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + # Why: no --install-dir here. A CI runner has no real Orca registered, so + # the harness installs to the default per-user location and owns it — the + # isolated-install machinery exists only to (imperfectly) protect a real + # developer install, which does not apply on a throwaway runner. + - name: Run update-survival harness + id: harness + shell: pwsh + env: + # Why: on a driving failure the harness dumps a screenshot + DOM state + # here so the actual post-onboarding UI is visible in the artifacts. + ORCA_E2E_DIAG_DIR: artifacts/diag + run: | + $log = "artifacts/harness-output.log" + node tools/win-update-e2e/run.mjs ` + --from "$env:FROM_EXE" ` + --to "$env:TO_EXE" ` + --expect "$env:EXPECT" ` + --soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log + exit $LASTEXITCODE + + - name: Upload harness output + if: always() + uses: actions/upload-artifact@v7 + with: + name: win-update-e2e-output + # Why: log + diagnostics only, never the multi-hundred-MB installers. + path: | + artifacts/harness-output.log + artifacts/diag/** + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/win-update-survival-e2e.yml b/.github/workflows/win-update-survival-e2e.yml new file mode 100644 index 00000000000..f5e7cb09309 --- /dev/null +++ b/.github/workflows/win-update-survival-e2e.yml @@ -0,0 +1,116 @@ +name: Windows Update-Survival E2E (branch build) + +# Why: proves the Phase 1 daemon-host relocation actually makes terminal +# sessions SURVIVE a Windows update. Builds an (unsigned) installer FROM THIS +# BRANCH, then runs the win-update-e2e harness with --expect survival: install, +# open a terminal, silently update over it, relaunch, and assert the daemon PID +# is unchanged and the pre-update terminal is still interactive with no console +# flashing. Runs from the feature branch via push (workflow_dispatch only works +# on the default branch); main is never touched. A CI runner is the only safe +# place to install/update — see the relocation post-mortem. + +on: + push: + branches: + - Jinwoo-H/windows-update-survival + paths: + - 'src/main/daemon/**' + - 'src/main/pty/**' + - 'tools/win-update-e2e/**' + - 'config/electron-builder.config.cjs' + - '.github/workflows/win-update-survival-e2e.yml' + workflow_dispatch: + inputs: + expect: + description: Expected outcome profile + required: true + type: choice + default: survival + options: + - survival + - cold-restore + +permissions: + contents: read + +concurrency: + group: win-update-survival-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + survival: + name: survival (branch build over itself) + runs-on: windows-2022 + timeout-minutes: 50 + env: + EXPECT: ${{ inputs.expect || 'survival' }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # This job only builds and runs the harness locally; it never pushes. + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Why: cache the built installer keyed on the inputs that affect it, so a + # harness-only edit skips the ~20 min electron-builder build. The daemon + # relocation code lives under src/, so changing it correctly rebuilds. + - name: Cache branch installer + id: cache-installer + uses: actions/cache@v4 + with: + path: dist/orca-windows-setup.exe + key: branch-installer-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }} + + - name: Build Windows installer (unsigned) + if: steps.cache-installer.outputs.cache-hit != 'true' + run: | + node config/scripts/ensure-native-runtime.mjs --runtime=electron + pnpm run build:desktop + pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never + + # Why: install the branch build, open a terminal, update the SAME build + # over it, and assert the relocated daemon survives. Same build on both + # sides isolates the survival mechanism (NSIS kill sweep + userData daemon + # + adoption) from cross-version staleness policy. No --install-dir: a CI + # runner has no real Orca to protect. + - name: Run survival harness + id: harness + shell: pwsh + env: + ORCA_E2E_DIAG_DIR: artifacts/diag + run: | + New-Item -ItemType Directory -Force artifacts | Out-Null + $exe = "dist/orca-windows-setup.exe" + if (-not (Test-Path $exe)) { throw "Installer not found at $exe" } + $log = "artifacts/survival-output.log" + node tools/win-update-e2e/run.mjs ` + --from "$exe" ` + --to "$exe" ` + --expect "$env:EXPECT" ` + --soak-seconds 60 2>&1 | Tee-Object -FilePath $log + exit $LASTEXITCODE + + - name: Upload survival output + if: always() + uses: actions/upload-artifact@v7 + with: + name: win-update-survival-output + path: | + artifacts/survival-output.log + artifacts/diag/** + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml new file mode 100644 index 00000000000..24b4307a83b --- /dev/null +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -0,0 +1,360 @@ +# Windows inner-binary signing rehearsal. +# +# Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed +# inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires +# a two-request flow: sign the unpacked PE files first, then build the NSIS +# installer from the signed tree, then sign the installer. This workflow +# rehearses that entire flow from a branch, end to end, without publishing +# anything — so the release pipeline on main is never at risk while we verify. +# +# Runs only via manual dispatch. Use the test-signing policy for iteration +# (auto-approved test certificate) and release-signing to rehearse the +# production flow (requires manual SignPath approvals). Rehearsed successfully +# with test-signing (run 28987534795) and release-signing (run 28988432001). +name: Windows signing rehearsal + +on: + workflow_dispatch: + inputs: + signing-policy-slug: + description: SignPath signing policy + type: choice + default: test-signing + options: + - test-signing + - release-signing + inner-artifact-configuration-slug: + description: SignPath artifact configuration for the inner-binaries zip + type: string + default: windows-inner-binaries-zip + +jobs: + rehearse: + # Why: SignPath origin verification requires GitHub-hosted runners, and + # windows-2022 matches the real release job's build environment. + runs-on: windows-2022 + timeout-minutes: 360 + permissions: + actions: read + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # Why: nothing here pushes; keep the token out of .git/config. + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + + - name: Cache electron-builder downloads + uses: actions/cache@v5 + with: + path: | + ~\AppData\Local\electron\Cache + ~\AppData\Local\electron-builder\Cache + key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-win- + + - name: Install dependencies + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile + + # Why: rehearsal builds are never published, so the official-build + # secrets (telemetry key, diagnostics URL) are intentionally omitted. + - name: Build app + run: pnpm build:release + env: + NODE_OPTIONS: --max-old-space-size=4096 + + - name: Package unpacked Windows app + shell: pwsh + run: | + node config/scripts/ensure-native-runtime.mjs --runtime=electron + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + pnpm exec electron-builder --config config/electron-builder.config.cjs --win --dir --publish never + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if (-not (Test-Path 'dist/win-unpacked/Orca.exe')) { + throw 'electron-builder --dir did not produce dist/win-unpacked/Orca.exe' + } + + # Why: only unsigned PE files go to SignPath. Files that already carry a + # valid signature (Microsoft's OpenConsole.exe, signed vendor DLLs) must + # keep their original signer, so they are excluded from the request. + - name: Stage unsigned PE files for inner signing + id: stage-inner + shell: pwsh + run: | + $root = Resolve-Path 'dist/win-unpacked' + $stage = New-Item -ItemType Directory -Force -Path 'signing-stage' + $list = New-Object System.Collections.Generic.List[string] + $skipped = New-Object System.Collections.Generic.List[string] + + Get-ChildItem -Path $root -Recurse -File | + Where-Object { $_.Extension -in '.exe', '.dll', '.node' } | + ForEach-Object { + $relative = [System.IO.Path]::GetRelativePath($root, $_.FullName) + $signature = Get-AuthenticodeSignature -FilePath $_.FullName + if ($signature.Status -eq 'Valid') { + $skipped.Add("$relative ") + return + } + $destination = Join-Path $stage.FullName $relative + New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null + Copy-Item -Path $_.FullName -Destination $destination -Force + $list.Add($relative) + } + + if (-not ($list -contains 'Orca.exe')) { + throw 'Orca.exe was not staged for signing; unpacked layout changed?' + } + if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) { + throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.' + } + + Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n") + Write-Host "Staged $($list.Count) unsigned PE files for signing:" + $list | ForEach-Object { Write-Host " $_" } + Write-Host "Skipped $($skipped.Count) already-signed files:" + $skipped | ForEach-Object { Write-Host " $_" } + + - name: Upload unsigned inner binaries for SignPath + id: upload-unsigned-inner + uses: actions/upload-artifact@v7 + with: + name: orca-windows-inner-unsigned-${{ github.run_id }} + path: signing-stage/** + if-no-files-found: error + + - name: Install SignPath PowerShell module + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + $useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue) + if ($useResourceGet) { + if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSResourceRepository -PSGallery -Trusted + } else { + Set-PSResourceRepository -Name PSGallery -Trusted + } + Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop + } else { + Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + } + Import-Module SignPath -ErrorAction Stop + Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop + + - name: Submit inner binaries signing request + id: submit-inner-signing + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0 + project-slug: orca + signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }} + artifact-configuration-slug: ${{ inputs.inner-artifact-configuration-slug || 'windows-inner-binaries-zip' }} + github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }} + wait-for-completion: false + + - name: Download signed inner binaries from SignPath + shell: pwsh + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }} + run: | + Get-SignedArtifact ` + -ApiToken $env:SIGNPATH_API_TOKEN ` + -OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' ` + -SigningRequestId $env:SIGNPATH_REQUEST_ID ` + -OutputArtifactPath signed-inner.zip ` + -Force ` + -WaitForCompletionTimeoutInSeconds 3600 + New-Item -ItemType Directory -Path signed-inner -Force + Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force + + # Why: copy back strictly by the staged list so a layout mismatch in the + # returned artifact fails loudly instead of silently shipping a mix of + # signed and unsigned binaries. + - name: Restore signed inner binaries into unpacked app + shell: pwsh + run: | + $root = Resolve-Path 'dist/win-unpacked' + $failures = New-Object System.Collections.Generic.List[string] + foreach ($relative in Get-Content 'inner-signing-list.txt') { + $signed = Get-ChildItem -Path signed-inner -Recurse -File | + Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } | + Select-Object -First 1 + if ($null -eq $signed) { + $failures.Add("missing from signed artifact: $relative") + continue + } + $signature = Get-AuthenticodeSignature -FilePath $signed.FullName + if ($null -eq $signature.SignerCertificate) { + $failures.Add("returned without a signature: $relative") + continue + } + Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force + Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject) + } + if ($failures.Count -gt 0) { + $failures | ForEach-Object { Write-Host "::error::$_" } + throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)." + } + + - name: Build NSIS installer from signed unpacked app + shell: pwsh + run: | + pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if (-not (Test-Path 'dist/orca-windows-setup.exe')) { + throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe' + } + + - name: Upload unsigned Windows installer for SignPath + id: upload-unsigned-installer + uses: actions/upload-artifact@v7 + with: + name: orca-windows-installer-unsigned-${{ github.run_id }} + path: dist/orca-windows-setup.exe + if-no-files-found: error + + - name: Submit Windows installer signing request + id: submit-installer-signing + uses: signpath/github-action-submit-signing-request@v2 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0 + project-slug: orca + signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }} + artifact-configuration-slug: github-actions-windows-installer + github-artifact-id: ${{ steps.upload-unsigned-installer.outputs.artifact-id }} + wait-for-completion: false + + - name: Download signed Windows installer from SignPath + shell: pwsh + env: + SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} + SIGNPATH_REQUEST_ID: ${{ steps.submit-installer-signing.outputs.signing-request-id }} + run: | + Get-SignedArtifact ` + -ApiToken $env:SIGNPATH_API_TOKEN ` + -OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' ` + -SigningRequestId $env:SIGNPATH_REQUEST_ID ` + -OutputArtifactPath signed-windows.zip ` + -Force ` + -WaitForCompletionTimeoutInSeconds 14400 + New-Item -ItemType Directory -Path signed-windows -Force + Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force + + # Why: signing changes installer bytes, so the updater metadata must be + # regenerated exactly like the release job's staging step does. + - name: Stage signed Windows installer and regenerate updater metadata + shell: pwsh + run: | + $signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1 + if ($null -eq $signedInstaller) { + throw 'Signed Windows installer was not returned by SignPath.' + } + Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force + & 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $installer = Get-Item 'dist/orca-windows-setup.exe' + $blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap' + $stream = [System.IO.File]::OpenRead($installer.FullName) + try { + $sha512 = [System.Security.Cryptography.SHA512]::Create() + $hash = [Convert]::ToBase64String($sha512.ComputeHash($stream)) + } finally { + if ($null -ne $sha512) { $sha512.Dispose() } + $stream.Dispose() + } + $latestYml = Get-Content -Path 'dist/latest.yml' -Raw + $latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', { + param($match) + "$($match.Groups[1].Value)sha512: $hash" + }) + $latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)" + $latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)" + Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline + + # Why: this is the pass/fail heart of the rehearsal — the same evidence + # check that will later gate releases (see PR #7170). Test certificates + # do not chain to a trusted root, so Status=Valid is only required for + # release-signing runs; test-signing runs require a present signature. + - name: Verify signatures end to end + shell: pwsh + env: + SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }} + run: | + $report = New-Object System.Collections.Generic.List[string] + $failures = New-Object System.Collections.Generic.List[string] + $requireValid = $env:SIGNING_POLICY -eq 'release-signing' + + function Test-Signature([string]$label, [string]$path) { + $signature = Get-AuthenticodeSignature -FilePath $path + $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } + $line = "{0,-14} {1} <{2}>" -f $signature.Status, $label, $subject + $script:report.Add($line) + Write-Host $line + if ($null -eq $signature.SignerCertificate -or $signature.Status -eq 'NotSigned') { + $script:failures.Add("unsigned: $label") + } elseif ($script:requireValid -and $signature.Status -ne 'Valid') { + $script:failures.Add("not Valid under release-signing: $label ($($signature.Status))") + } elseif ($script:requireValid -and $subject -notlike '*CN=SignPath Foundation*') { + $script:failures.Add("unexpected signer: $label ($subject)") + } + } + + Test-Signature 'installer: orca-windows-setup.exe' 'dist/orca-windows-setup.exe' + + # Extract the signed installer and verify the files a user actually + # gets on disk — including the exact file from issue #7785. + $7za = 'node_modules/7zip-bin/win/x64/7za.exe' + New-Item -ItemType Directory -Path extracted-app -Force | Out-Null + & $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null + + $root = Resolve-Path 'extracted-app' + foreach ($relative in Get-Content 'inner-signing-list.txt') { + $path = Join-Path $root $relative + if (-not (Test-Path $path)) { + $failures.Add("missing from installer payload: $relative") + continue + } + Test-Signature "installed: $relative" $path + } + + Set-Content -Path 'signing-evidence.txt' -Value ($report -join "`n") + if ($failures.Count -gt 0) { + $failures | ForEach-Object { Write-Host "::error::$_" } + throw "Signing rehearsal failed with $($failures.Count) problems." + } + Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries plus the installer are signed." + + - name: Upload rehearsal evidence and installer + if: always() + uses: actions/upload-artifact@v7 + with: + name: windows-signing-rehearsal-${{ inputs.signing-policy-slug || 'test-signing' }}-${{ github.run_id }} + path: | + signing-evidence.txt + inner-signing-list.txt + dist/orca-windows-setup.exe + dist/orca-windows-setup.exe.blockmap + dist/latest.yml + retention-days: 7 diff --git a/README.md b/README.md index 8e9bc05d3f3..ee5cf6a0a78 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) +- **Android:** [Download APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- @@ -238,9 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone. - **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements. -- **WeChat:** Scan the QR code to join the community. +- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group. WeChat QR code for the Orca community + Backup WeChat QR code for the Orca community - **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues). - **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out. diff --git a/build-plugins/plain-node-entry-guard.ts b/build-plugins/plain-node-entry-guard.ts new file mode 100644 index 00000000000..1fddb5f6a12 --- /dev/null +++ b/build-plugins/plain-node-entry-guard.ts @@ -0,0 +1,130 @@ +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' +import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup' + +// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main +// chunk gained `require("electron")` (an import edge added in #7642), and the +// daemon is forked as a plain-Node process where electron cannot be required. +// Nothing in CI executes the built daemon-entry under plain Node, so the leak +// stayed invisible until an adopted old daemon died. This guard fails the +// build when any chunk reachable from a plain-Node fork entry requires +// electron, and smoke-loads daemon-entry under plain Node to prove its module +// graph still resolves. + +// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime): +// forked daemon, parcel-watcher and computer sidecars, and the CLI-run +// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them. +const PLAIN_NODE_ENTRY_NAMES = [ + 'daemon-entry', + 'parcel-watcher-process-entry', + 'computer-sidecar', + 'agent-hooks/managed-agent-hook-controls' +] as const + +const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/ + +function collectReachableChunks( + entry: OutputChunk, + byFileName: Map +): OutputChunk[] { + const seen = new Set() + const reachable: OutputChunk[] = [] + const stack = [entry.fileName] + while (stack.length > 0) { + const fileName = stack.pop() as string + if (seen.has(fileName)) { + continue + } + seen.add(fileName) + const chunk = byFileName.get(fileName) + if (!chunk) { + continue + } + reachable.push(chunk) + for (const imported of [...chunk.imports, ...chunk.dynamicImports]) { + stack.push(imported) + } + } + return reachable +} + +function assertNoElectronRequire( + entryName: string, + entry: OutputChunk, + byFileName: Map +): void { + for (const chunk of collectReachableChunks(entry, byFileName)) { + if (ELECTRON_REQUIRE_RE.test(chunk.code)) { + throw new Error( + `[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` + + `requires electron. "${entryName}" runs as a plain-Node process, where ` + + `require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` + + `v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.` + ) + } + } +} + +// Why: proves the whole daemon-entry graph resolves under plain Node (no +// unresolved requires). require("electron") does not throw in a dev tree with +// node_modules present, so the static scan above — not this smoke — is the +// electron regression guard; this only catches gross load failures. +function smokeLoadDaemonEntry(outputDir: string): void { + const entryPath = join(outputDir, 'daemon-entry.js') + const result = spawnSync(process.execPath, [entryPath], { + encoding: 'utf8', + timeout: 15_000 + }) + if (result.error) { + throw new Error( + `[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` + + `${result.error.message}` + ) + } + const stderr = result.stderr ?? '' + if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) { + throw new Error( + `[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}` + ) + } + if (!stderr.includes('Usage: daemon-entry')) { + throw new Error( + `[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` + + `(expected the "Usage: daemon-entry" error). stderr:\n${stderr}` + ) + } +} + +export function createPlainNodeEntryGuardPlugin(): Plugin { + return { + name: 'orca-plain-node-entry-guard', + writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) { + // Why: skip in `electron-vite dev` watch mode — the smoke would respawn on + // every rebuild, and the guard only needs to gate produced builds. + if (this.meta.watchMode) { + return + } + const chunks = Object.values(bundle).filter( + (item): item is OutputChunk => item.type === 'chunk' + ) + const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk])) + const entryByName = new Map() + for (const chunk of chunks) { + if (chunk.isEntry && chunk.name) { + entryByName.set(chunk.name, chunk) + } + } + + for (const entryName of PLAIN_NODE_ENTRY_NAMES) { + const entry = entryByName.get(entryName) + if (entry) { + assertNoElectronRequire(entryName, entry, byFileName) + } + } + + if (entryByName.has('daemon-entry') && options.dir) { + smokeLoadDaemonEntry(options.dir) + } + } + } +} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 9e482825a73..61f38c081c4 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -2,6 +2,10 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs') const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') +const { + assertPackagedDaemonEntryExists, + verifyPackagedDaemonEntryBoots +} = require('./scripts/verify-packaged-daemon-entry.cjs') const { createPackagedRuntimeNodeModuleResources, prunePackagedRuntimeNodeModules, @@ -106,6 +110,7 @@ module.exports = { 'out/main/win32-utils.js', 'out/main/daemon-entry.js', 'out/main/computer-sidecar.js', + 'out/main/parcel-watcher-process-entry.js', 'out/main/chunks/**', 'resources/**', 'node_modules/ws/**', @@ -129,6 +134,24 @@ module.exports = { } prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch) verifyPackagedMainRuntimeDeps(resourcesDir) + // Why: boot the packaged daemon-entry under plain Node, but only for the + // slice matching the packaging host's arch — daemon-entry.js is JS, yet it + // require()s the native (N-API) node-pty for the TARGET arch, which the host + // Node cannot load cross-arch. `Arch` enum: ia32=0, x64=1, armv7l=2, + // arm64=3, universal=4 (universal contains the host slice, so run it). + const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 } + const hostArchEnum = archEnumByNodeArch[process.arch] + if (context.arch === hostArchEnum || context.arch === 4) { + verifyPackagedDaemonEntryBoots(resourcesDir) + } else { + // Why: a cross-arch slice can't be booted by the host Node, but the + // unpacked entry must still exist — its absence is a layout regression + // regardless of arch, so only the boot is skipped, not the check. + assertPackagedDaemonEntryExists(resourcesDir) + console.log( + `[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})` + ) + } chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { @@ -142,6 +165,10 @@ module.exports = { } if (context.electronPlatformName === 'darwin') { await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager) + await signMacNotificationStatusHelper( + join(resourcesDir, '..', 'MacOS', 'orca-notification-status'), + context.packager + ) } }, win: { @@ -173,7 +200,11 @@ module.exports = { artifactName: 'orca-windows-setup.${ext}', shortcutName: '${productName}', uninstallDisplayName: '${productName}', - createDesktopShortcut: 'always' + createDesktopShortcut: 'always', + // Why: on a real uninstall, stop and remove the relocated terminal daemon + // (which lives outside the install dir under LOCALAPPDATA by design). Guarded + // by ${isUpdated} inside so it never runs during an update's uninstallOldVersion. + include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh') }, mac: { icon: 'resources/build/icon.icns', @@ -229,6 +260,15 @@ module.exports = { }, featureWallResources ], + // Why: the notification-status helper must execute from Contents/MacOS — + // on macOS 26 UNUserNotificationCenter aborts (bundleProxyForCurrentProcess + // is nil) for executables launched out of Contents/Resources (#7929). + extraFiles: [ + { + from: 'native/notification-status-macos/.build/release/orca-notification-status', + to: 'MacOS/orca-notification-status' + } + ], target: [ { target: 'dmg', @@ -398,6 +438,37 @@ async function signMacComputerUseHelper(helperAppPath, packager) { }) } +async function signMacNotificationStatusHelper(helperPath, packager) { + if (!existsSync(helperPath)) { + if (isMacRelease) { + throw new Error(`Missing orca-notification-status helper at ${helperPath}`) + } + return + } + const codeSigningInfo = + isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value + ? await packager.codeSigningInfo.value + : null + const identity = + process.env.CSC_NAME ?? + findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ?? + (isMacRelease ? null : '-') + if (!identity) { + throw new Error('Missing signing identity for orca-notification-status helper') + } + // Why: macOS keys notification records to the code-signing identifier; the + // binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this + // (and any later) `codesign --force` derives the correct identifier. Sign + // before the outer Orca.app is sealed, like the computer-use helper. + const args = ['--force', '--sign', identity] + if (isMacRelease) { + args.push('--options', 'runtime', '--timestamp') + } + args.push(helperPath) + execFileSync('codesign', args, { stdio: 'inherit' }) + execFileSync('codesign', ['--verify', '--strict', helperPath], { stdio: 'inherit' }) +} + function codesignArgs(identity, targetPath) { const args = ['--force', '--deep', '--sign', identity] if (isMacRelease) { diff --git a/config/nsis/daemon-host-uninstall.nsh b/config/nsis/daemon-host-uninstall.nsh new file mode 100644 index 00000000000..dc3a497ce67 --- /dev/null +++ b/config/nsis/daemon-host-uninstall.nsh @@ -0,0 +1,23 @@ +; Clean up the relocated terminal daemon on a REAL uninstall. +; +; Why: the daemon host is deliberately copied to a distinct image name +; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app +; UPDATES cannot kill it — that relocation is what keeps terminals alive across +; updates. The same design means a normal uninstall's process sweep and file +; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. +; +; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as +; part of uninstallOldVersion on EVERY update, and killing the daemon there would +; defeat the whole feature. Only clean up on a genuine uninstall. +; +; The image name and the LOCALAPPDATA folder name must stay in sync with +; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in +; src/main/daemon/daemon-host-relocation.ts. +!macro customUnInstall + ${ifNot} ${isUpdated} + nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' + ; Give the OS a moment to release the image lock before removing the tree. + Sleep 500 + RMDir /r "$LOCALAPPDATA\Orca\daemon-host" + ${endIf} +!macroend diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index bdabd2a7b79..36291362d1e 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-03", + "updatedAt": "2026-07-08", "policy": { "maturityLevels": [ "experimental", @@ -105,6 +105,163 @@ ], "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." }, + { + "id": "terminal-session.startup-cwd-missing-dir-recovery", + "title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "shared-main-renderer-contract", + "surfaces": [ + "terminal lifecycle", + "tab creation", + "PTY spawn", + "startup cwd persistence" + ], + "platforms": [ + "macos", + "linux", + "windows", + "mobile" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "ssh", + "remote-runtime" + ], + "coverageNotes": "Local macOS evidence covers the shared missing-dir fallback policy, main pty:spawn recovery and metadata, no-flag and reattach strictness, renderer IPC flag routing, SSH-tagged and remote-runtime omission, and the visibility-gated terminal notice. Daemon shares the same pre-provider main cwd decision but lacks a live daemon-provider run; WSL UNC paths are exempt from the probe by design and lack a live run; Linux/Windows and mobile/API strictness are gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/7239", + "https://github.com/stablyai/orca/pull/7750", + "https://github.com/stablyai/orca/pull/7678" + ], + "invariant": "A fresh local renderer terminal spawn may recover from a saved startup cwd whose directory no longer exists only by spawning at the selected workspace root and printing a generic in-terminal notice; existing directories — including ones outside the worktree (#7685) — spawn as requested, and reattach, SSH, remote-runtime, runtime/API, and mobile callers keep exact cwd semantics.", + "oracle": "The shared resolver falls back to the workspace root only when the injected existence probe reports the resolved cwd missing and the workspace root present, and never probes floating terminals or a cwd equal to the root. The renderer sends cwdFallback only for fresh local IPC spawns, main honors it only when connectionId and sessionId are absent, WSL UNC paths never engage the probe-based fallback, main returns fallback metadata only after an actual fallback, the IPC transport preserves that metadata, and the connection layer writes a generic notice that omits the missing path.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "testFiles": [ + "src/shared/terminal-startup-cwd.test.ts", + "src/main/ipc/pty.test.ts", + "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "assertionRefs": [ + { + "file": "src/shared/terminal-startup-cwd.test.ts", + "assertions": [ + "a missing requested cwd falls back to the workspace root and reports the missing path to the callback", + "existing cwds — nested or outside the worktree (#7685) — are never remapped", + "no fallback happens when the workspace root is missing too", + "floating terminal cwds and root-equal requests are never probed", + "non-ASCII worktree roots and folder workspace roots are recovered verbatim" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "local pty:spawn with cwdFallback worktree spawns at the worktree root when the saved cwd is missing and returns fallback metadata", + "a missing cwd without the flag still surfaces the provider's missing-directory error", + "an existing outside-worktree cwd spawns as requested without fallback metadata", + "session reattach spawns ignore the fallback flag and keep exact cwd semantics" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "assertions": [ + "IPC transport sends cwdFallback only for local fresh spawns", + "SSH-tagged and session reattach spawns omit cwdFallback", + "IPC transport returns startup cwd fallback metadata to the connection layer" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "fresh local IPC worktree spawns are marked with cwdFallback worktree", + "startup cwd fallback metadata prints a generic in-terminal notice", + "remote-runtime worktree spawns are not marked with cwdFallback" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-08", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts", + "result": "passed", + "durationSeconds": 0.2, + "summary": "1 test file passed, 21 tests passed; covers the missing-dir fallback policy, #7685 outside-worktree preservation, and root-missing/floating exemptions." + }, + { + "date": "2026-07-08", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts", + "result": "passed", + "durationSeconds": 0.9, + "summary": "1 test file passed, 225 tests passed; covers main pty:spawn recovery, fallback metadata, and no-flag/reattach provider-error strictness." + }, + { + "date": "2026-07-08", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", + "result": "passed", + "durationSeconds": 0.4, + "summary": "1 test file passed, 58 tests passed; covers cwdFallback forwarding only for local fresh spawns and metadata handoff." + }, + { + "date": "2026-07-08", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "result": "passed", + "durationSeconds": 6.5, + "summary": "1 test file passed, 341 tests passed; covers local IPC marking, the generic terminal fallback notice, and remote-runtime omission." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "focused unit and IPC contract tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New experimental gate added with local deterministic evidence only; needs CI soak before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The main IPC missing-cwd tests fail with the provider's 'Working directory ... does not exist.' error when the fallback is removed and pass with it. Full live Electron reproduction from a production persisted session is not captured." + }, + "performanceBudget": { + "required": true, + "evidence": "The runtime change adds at most two statSync probes on the fresh-local spawn path (the provider already stats the same paths during validation) and one bounded terminal write only when fallback actually occurs; no polling, provider listing, hidden-pane work, startup awaits, subprocesses, or render-loop work was added." + }, + "promotionCriteria": [ + "Attach CI evidence for all declared test files.", + "Add a live Electron regression that opens a local terminal whose persisted startupCwd was deleted and proves visible shell input/output at the workspace root.", + "Add WSL/mobile/API provider-contract coverage or explicitly narrow their risk scope." + ], + "knownGaps": [ + "No live Electron fixture seeds a persisted tab whose startupCwd directory was deleted.", + "Daemon coverage is via the shared pre-provider main cwd decision, not a live daemon provider spawn.", + "WSL UNC paths bypass the probe by design and have no live existence-recovery run; Linux, Windows, and mobile/API strictness are not directly exercised." + ], + "demotionRule": "Demote or quarantine if the gate flakes without a product bug, if an existing directory is ever remapped away from the requested cwd, or if a reattach/remote/API caller can engage the fallback." + }, { "id": "agent-session.provider-ownership", "title": "Provider sessions are resumed once per workspace ownership claim", @@ -1273,7 +1430,7 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence on main@1282f5c2d; identical files to the pending stack. Real OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", + "coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6699", "https://github.com/stablyai/orca/pull/6682", @@ -1281,14 +1438,20 @@ "https://github.com/stablyai/orca/pull/6999" ], "invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.", - "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, and paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", + "oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts", "src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts", - "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts" + "src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", + "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts", + "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", + "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "tests/e2e/chinese-ime-chat-input-repro.spec.ts" ], "assertionRefs": [ { @@ -1309,34 +1472,80 @@ "assertions": [ "paste/runtime forwarding avoids duplicate terminal payloads" ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts", + "assertions": [ + "empty Sogou-style compositionupdate keeps composition active", + "stale composition state expires so editing keys cannot stay suppressed indefinitely", + "post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts", + "assertions": [ + "suppressed candidate keydown arms only the matching keypress/keyup release guard", + "held-key repeat keydowns stay guarded until their keyup, even past expiry", + "fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts", + "assertions": [ + "standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed", + "candidate Space/digit selectors are suppressed only while candidate guards are active" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", + "assertions": [ + "macOS standalone Process key behavior and composition-owned key suppression stay intact" + ] + }, + { + "file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts", + "assertions": [ + "Sogou-style Space candidate selection submits only the committed Chinese character", + "Sogou-style digit candidate selection submits only the committed Chinese phrase", + "Post-composition Sogou-style digit selection stays out of the PTY after compositionend" + ] } ], "evidenceRuns": [ { - "date": "2026-07-03", + "date": "2026-07-07", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts", "result": "passed", - "durationSeconds": 0.9, - "summary": "3 test file(s) passed, 63 tests passed on main@1282f5c2d in a clean checkout." + "durationSeconds": 0.7, + "summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)." + }, + { + "date": "2026-07-07", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts", + "result": "passed", + "durationSeconds": 72.0, + "summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME." } ], "runtimeBudget": { - "p95Seconds": 30, - "scope": "renderer unit plus platform soak" + "p95Seconds": 90, + "scope": "renderer unit plus focused Electron/CDP IME repro" }, "flakeHistory": { "status": "unknown", - "evidence": "Focused renderer input tests are now registered; needs soak history and platform IME evidence before promotion." + "evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion." }, "redGreenEvidence": { "status": "partial", - "evidence": "Focused tests cover existing native-text, input-source, and paste/runtime forwarding contracts. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." + "evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix." }, "performanceBudget": { - "required": false, - "evidence": "Required if input forwarding adds polling, repeated event listeners, or renderer hot-path work." + "required": true, + "evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added." }, "promotionCriteria": [ "Cover deterministic byte/cell oracles first.", @@ -1344,7 +1553,7 @@ "Pair with Windows ConPTY keyboard reset for Windows standard-key behavior." ], "knownGaps": [ - "Current command is a renderer-unit slice; real OS IME automation may need manual or soak evidence.", + "Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.", "Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage." ], "demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence." @@ -1411,6 +1620,144 @@ ], "demotionRule": "Cannot promote if standard-key behavior is inferred without PTY byte evidence." }, + { + "id": "terminal-input.agent-prompt-injection", + "title": "Orchestration agent prompts arrive as bracketed paste before submit", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-input", + "layer": "runtime-contract-and-cli-repro", + "surfaces": [ + "terminal input", + "agent prompt injection", + "orchestration dispatch", + "PTY writes", + "bracketed paste" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local" + ], + "coverageNotes": "Local macOS evidence covers the runtime PTY write contract and a live dev-runtime CLI repro. SSH, daemon, remote-runtime, Linux, and Windows remain provider/platform gaps; the product path stays provider-owned and does not add local filesystem or process assumptions.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/7226" + ], + "invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and send Enter only after the paste frame completes.", + "oracle": "Runtime tests assert the exact PTY write sequence and failure cleanup; orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness dispatches a 32KB task to a fake Codex-like TUI and requires marker present, bracketed paste present, zero unframed line breaks, and submit observed.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts", + "node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000" + ], + "testFiles": [ + "src/shared/agent-prompt-injection.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/rpc/methods/orchestration.test.ts", + "src/main/runtime/orchestration/coordinator.test.ts", + "tools/repro-orchestration-long-prompt.mjs" + ], + "assertionRefs": [ + { + "file": "src/shared/agent-prompt-injection.test.ts", + "assertions": [ + "agent prompts are always framed as bracketed paste", + "submit stays separate from the paste frame", + "embedded ESC bytes are made inert before framing", + "chunk reconstruction preserves the paste frame" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "runtime writes bracketed paste before a delayed submit", + "large prompt frames are chunked and reconstructed before submit", + "partial prompt write failure closes the paste frame and does not submit" + ] + }, + { + "file": "src/main/runtime/rpc/methods/orchestration.test.ts", + "assertions": [ + "orchestration.dispatch uses the agent prompt path for injected preambles", + "raw terminal.send is not called for injected task prompts", + "failed prompt injection rolls back the active dispatch" + ] + }, + { + "file": "src/main/runtime/orchestration/coordinator.test.ts", + "assertions": [ + "coordinator dispatch failures from prompt injection circuit-break through the DB", + "silent-skip paths do not attempt prompt injection" + ] + }, + { + "file": "tools/repro-orchestration-long-prompt.mjs", + "assertions": [ + "fake Codex-like worker observes submit after long orchestration dispatch", + "32KB task marker survives before submit", + "prompt bytes include a bracketed-paste frame with zero unframed line breaks" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-07", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts", + "result": "passed", + "durationSeconds": 7.4, + "summary": "4 test files passed, 697 tests passed; covers framing, runtime PTY writes, orchestration RPC dispatch, and coordinator dispatch behavior." + }, + { + "date": "2026-07-07", + "runner": "local", + "platform": "macos", + "command": "node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000", + "result": "passed", + "durationSeconds": 2.2, + "summary": "Live dev-runtime repro passed: expectedSpecBytes=32830, hasSubmit=true, rawContainsMarker=true, hasBracketedPasteFrame=true, unframedLineBreaks=0, contractOk=true." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "runtime contract tests plus optional local dev-runtime CLI repro" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "First local macOS evidence only; needs repeated dev-runtime harness runs and provider matrix evidence before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The live harness reproduced the unsafe raw multiline contract before the fix and passes after the fix; intentional-break evidence is local only and not yet in CI." + }, + "performanceBudget": { + "required": true, + "evidence": "Agent prompt dispatch remains O(prompt bytes), uses the existing 16KB terminal input chunking and one existing 500ms submit delay, and adds no polling, provider listing, subprocess churn, hidden-pane wakeups, or renderer work." + }, + "promotionCriteria": [ + "Run the live harness in soak with a self-starting dev runtime or provider-contract fixture.", + "Add daemon, SSH, remote-runtime, Linux, and Windows evidence or mark narrower provider scope.", + "Capture stable red/green intentional-break evidence in CI before blocking promotion." + ], + "knownGaps": [ + "Live harness command currently expects an already-running dev runtime and generated out/bin/orca-dev wrapper.", + "No Windows ConPTY, Linux PTY, SSH, daemon, or remote-runtime live evidence yet.", + "Push-on-idle orchestration message banners remain outside this dispatch-prompt gate." + ], + "demotionRule": "Demote or quarantine if the live harness flakes without a product bug or harness bug filed to the terminal-input owner." + }, { "id": "terminal-render.windows-cjk-repaint", "title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells", diff --git a/config/scripts/build-native-for-platform.mjs b/config/scripts/build-native-for-platform.mjs index a16cb34322a..11ad15bc5f4 100755 --- a/config/scripts/build-native-for-platform.mjs +++ b/config/scripts/build-native-for-platform.mjs @@ -8,6 +8,8 @@ if (process.platform !== 'darwin') { } runPnpmScript('build:computer-macos') +runPnpmScript('build:notification-status-macos') +process.exit(0) function runPnpmScript(scriptName) { const npmExecPath = process.env.npm_execpath @@ -22,5 +24,7 @@ function runPnpmScript(scriptName) { if (result.signal) { process.kill(process.pid, result.signal) } - process.exit(result.status ?? (result.error ? 1 : 0)) + if (result.status !== 0 || result.error) { + process.exit(result.status ?? 1) + } } diff --git a/config/scripts/build-notification-status-macos.mjs b/config/scripts/build-notification-status-macos.mjs new file mode 100644 index 00000000000..e8150e32b6f --- /dev/null +++ b/config/scripts/build-notification-status-macos.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// Builds the orca-notification-status helper binary. +// +// The helper reads UNUserNotificationCenter settings for the app it ships +// inside (see native/notification-status-macos/main.swift). The target +// CFBundleIdentifier is embedded as a __TEXT,__info_plist section so every +// later `codesign --force` pass (electron-builder's signing, the dev runner's +// ad-hoc deep sign) derives the correct code identifier automatically — +// macOS keys notification records to that identifier. +import { execFileSync } from 'node:child_process' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' + +const repoRoot = path.resolve(import.meta.dirname, '../..') +const sourcePath = path.join(repoRoot, 'native', 'notification-status-macos', 'main.swift') +const defaultOutputPath = path.join( + repoRoot, + 'native', + 'notification-status-macos', + '.build', + 'release', + 'orca-notification-status' +) + +if (process.platform !== 'darwin') { + process.exit(0) +} + +const args = process.argv.slice(2) +const bundleId = readArg('--bundle-id') ?? 'com.stablyai.orca' +const outputPath = readArg('--output') ?? defaultOutputPath +// Why: dev launches only need the host architecture; release builds ship a +// universal binary matching the app's x64 + arm64 targets. +const singleArch = args.includes('--single-arch') + +const workDir = path.join(tmpdir(), `orca-notification-status-${process.pid}`) +mkdirSync(workDir, { recursive: true }) +try { + const plistPath = path.join(workDir, 'Info.plist') + writeFileSync(plistPath, embeddedInfoPlist(bundleId), 'utf8') + const triples = singleArch + ? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx'] + : ['arm64-apple-macosx', 'x86_64-apple-macosx'] + const builtBinaries = triples.map((triple) => { + const output = path.join(workDir, `orca-notification-status-${triple}`) + execFileSync( + 'swiftc', + [ + '-O', + sourcePath, + '-target', + triple.replace('-apple-macosx', '-apple-macosx11.0'), + '-o', + output, + '-Xlinker', + '-sectcreate', + '-Xlinker', + '__TEXT', + '-Xlinker', + '__info_plist', + '-Xlinker', + plistPath + ], + { stdio: 'inherit' } + ) + return output + }) + mkdirSync(path.dirname(outputPath), { recursive: true }) + if (builtBinaries.length === 1) { + execFileSync('cp', [builtBinaries[0], outputPath]) + } else { + execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath]) + } + execFileSync('chmod', ['755', outputPath]) +} finally { + rmSync(workDir, { recursive: true, force: true }) +} + +function readArg(name) { + const index = args.indexOf(name) + return index >= 0 ? args[index + 1] : undefined +} + +function embeddedInfoPlist(identifier) { + return ` + + + + CFBundleIdentifier + ${identifier} + CFBundleName + orca-notification-status + + +` +} diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index 92480315b18..3d4fbaecddd 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -110,6 +110,39 @@ describe('computer-use e2e workflow', () => { } }) + it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => { + const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') + ) + const steps = workflow.jobs['native-smoke'].steps + const runs = steps.map((step) => step.run).filter((run) => typeof run === 'string') + const buildIndex = runs.indexOf('pnpm build:electron-vite') + const daemonSmokeIndex = runs.indexOf('node config/scripts/daemon-boot-smoke.mjs') + + expect(daemonSmokeIndex, 'native-smoke must boot the built daemon').toBeGreaterThanOrEqual(0) + expect( + buildIndex, + 'daemon boot smoke must run after the main bundle is built' + ).toBeGreaterThanOrEqual(0) + expect(daemonSmokeIndex).toBeGreaterThan(buildIndex) + }) + + it('re-runs the native-smoke job when the daemon bundle graph changes', () => { + const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') + ) + const triggerPaths = workflow.on.pull_request.paths + + expect(triggerPaths).toEqual( + expect.arrayContaining([ + 'config/scripts/daemon-boot-smoke.mjs', + 'electron.vite.config.ts', + 'build-plugins/**', + 'src/main/daemon/**' + ]) + ) + }) + it('runs Linux computer-use e2e in the PR native-smoke job under Xvfb', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') diff --git a/config/scripts/daemon-boot-smoke.mjs b/config/scripts/daemon-boot-smoke.mjs new file mode 100644 index 00000000000..dc593b650b2 --- /dev/null +++ b/config/scripts/daemon-boot-smoke.mjs @@ -0,0 +1,217 @@ +/** + * Boots the BUILT terminal daemon (out/main/daemon-entry.js) under plain Node — + * the exact way production forks it (ELECTRON_RUN_AS_NODE = a plain-Node + * process) — and asserts it starts, serves a real PTY, and stops. + * + * Why this exists: native-smoke CI (and packaging) went green while + * v1.4.129-rc.1 shipped a daemon that exited code 1 at module load because an + * electron `require` leaked into its bundle graph. Nothing executed the built + * entry under plain Node, so the outage was invisible until an adopted old + * daemon died in the field. This runs on every PR that touches the daemon. + * + * Hard assertions (fail the job): + * - the daemon signals `{ type: 'ready' }` over IPC within the timeout, and + * - it terminates when asked (no hang / zombie). + * Best-effort (logged skip, never fails): an end-to-end `ptySpawnHealth` RPC, + * because node-pty spawn can be flaky on constrained CI runners. + */ +import { fork } from 'node:child_process' +import { connect } from 'node:net' +import { randomUUID } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const projectDir = resolve(import.meta.dirname, '../..') +const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js') + +const READY_TIMEOUT_MS = 30_000 +const PTY_HEALTH_TIMEOUT_MS = 10_000 +const SHUTDOWN_TIMEOUT_MS = 10_000 + +function log(message) { + process.stdout.write(`[daemon-boot-smoke] ${message}\n`) +} + +// Why: the daemon rejects a hello whose protocol version differs, so read the +// current version from source rather than hardcoding a number that can drift. +function readProtocolVersion() { + const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8') + const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/) + if (!match) { + throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts') + } + return Number(match[1]) +} + +function makeSocketPath(userDataDir) { + // Why: Windows AF_UNIX-style IPC uses named pipes; POSIX uses a filesystem + // socket kept under the scratch userData dir so cleanup removes it. + if (process.platform === 'win32') { + return `\\\\.\\pipe\\orca-daemon-smoke-${process.pid}-${randomUUID()}` + } + return join(userDataDir, 'daemon.sock') +} + +// Best-effort end-to-end PTY check over the daemon's own control-socket RPC. +// Connects a single control socket, completes the hello handshake, and calls +// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves +// true on success, false on any failure — never throws. +function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { + return new Promise((resolveCheck) => { + let settled = false + let buffer = '' + const socket = connect(socketPath) + const finish = (ok, reason) => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + socket.destroy() + if (!ok && reason) { + log(`PTY spawn health check skipped (best-effort): ${reason}`) + } + resolveCheck(ok) + } + const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS) + + socket.on('error', (err) => finish(false, err.message)) + socket.on('connect', () => { + const token = readFileSync(tokenPath, 'utf8').trim() + socket.write( + `${JSON.stringify({ + type: 'hello', + version: protocolVersion, + token, + clientId: randomUUID(), + role: 'control' + })}\n` + ) + }) + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8') + let newlineIdx = buffer.indexOf('\n') + while (newlineIdx !== -1) { + const line = buffer.slice(0, newlineIdx) + buffer = buffer.slice(newlineIdx + 1) + let msg + try { + msg = JSON.parse(line) + } catch { + finish(false, 'invalid response line') + return + } + if (msg.type === 'hello') { + if (!msg.ok) { + finish(false, `hello rejected: ${msg.error ?? 'unknown'}`) + return + } + socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`) + } else if (msg.id === 'health-1') { + finish( + msg.ok === true, + msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed') + ) + return + } + newlineIdx = buffer.indexOf('\n') + } + }) + }) +} + +async function main() { + const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-')) + const socketPath = makeSocketPath(userDataDir) + const tokenPath = join(userDataDir, 'daemon.token') + const protocolVersion = readProtocolVersion() + + log(`forking ${entryPath} under plain Node (${process.execPath})`) + const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { + // Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in + // CI, and this is exactly the runtime where a leaked `require("electron")` + // throws MODULE_NOT_FOUND — the failure this smoke exists to catch. + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir } + }) + + let stderr = '' + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString('utf8') + }) + child.stdout?.on('data', (chunk) => { + process.stdout.write(chunk) + }) + + const cleanup = () => { + if (child.exitCode === null && child.signalCode === null && child.pid) { + try { + child.kill('SIGKILL') + } catch { + // already gone + } + } + rmSync(userDataDir, { recursive: true, force: true }) + } + + try { + await new Promise((resolveReady, rejectReady) => { + const timer = setTimeout(() => { + rejectReady( + new Error( + `daemon did not signal 'ready' within ${READY_TIMEOUT_MS}ms.\nstderr:\n${stderr}` + ) + ) + }, READY_TIMEOUT_MS) + child.on('message', (msg) => { + if (msg && typeof msg === 'object' && msg.type === 'ready') { + clearTimeout(timer) + resolveReady() + } + }) + child.on('error', (err) => { + clearTimeout(timer) + rejectReady(new Error(`daemon fork errored: ${err.message}\nstderr:\n${stderr}`)) + }) + child.on('exit', (code, signal) => { + clearTimeout(timer) + rejectReady( + new Error( + `daemon exited before 'ready' (code=${code}, signal=${signal}).\nstderr:\n${stderr}` + ) + ) + }) + }) + log('daemon signaled ready') + + const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) + if (ptyHealthy) { + log('ptySpawnHealth OK — daemon spawned a real PTY end-to-end') + } + + await new Promise((resolveExit, rejectExit) => { + const timer = setTimeout(() => { + rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)) + }, SHUTDOWN_TIMEOUT_MS) + child.on('exit', (code, signal) => { + clearTimeout(timer) + log(`daemon exited after signal (code=${code}, signal=${signal})`) + resolveExit() + }) + // Why: SIGTERM is the graceful stop on POSIX (the daemon handles it); + // Windows has no POSIX signal delivery, so Node maps this to process + // termination. Either way the hard assertion is "it stops, no hang". + child.kill('SIGTERM') + }) + + log('PASS: daemon booted, served, and shut down under plain Node') + } finally { + cleanup() + } +} + +main().catch((error) => { + process.stderr.write(`[daemon-boot-smoke] FAIL: ${error.message}\n`) + process.exitCode = 1 +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 3f318219eeb..cd46c8003fa 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -63,12 +63,36 @@ describe('electron-builder config', () => { ) }) + // Why: on macOS 26 UNUserNotificationCenter aborts for executables launched + // from Contents/Resources, so the helper must ship in Contents/MacOS (#7929). + it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => { + expect(electronBuilderConfig.mac.extraFiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: 'native/notification-status-macos/.build/release/orca-notification-status', + to: 'MacOS/orca-notification-status' + }) + ]) + ) + expect(electronBuilderConfig.mac.extraResources).not.toEqual( + expect.arrayContaining([expect.objectContaining({ to: 'orca-notification-status' })]) + ) + }) + it('unpacks the compiled CommonJS boundary with CLI runtime files', () => { expect(electronBuilderConfig.asarUnpack).toEqual( expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**']) ) }) + // Why: without the unpacked entry the watcher client silently falls back to + // in-process @parcel/watcher, reintroducing the #7547 main-process crash. + it('unpacks the forked parcel-watcher process entry', () => { + expect(electronBuilderConfig.asarUnpack).toEqual( + expect.arrayContaining(['out/main/parcel-watcher-process-entry.js']) + ) + }) + it('uses the multi-size icon source for Linux packages', () => { expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns') }) @@ -329,6 +353,15 @@ describe('electron-builder config', () => { const launcherPath = join(resourcesDir, 'bin', 'orca-ide') await mkdir(join(resourcesDir, 'bin'), { recursive: true }) await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) + // Why: afterPack now fails hard when the unpacked daemon entry is + // missing, so the fixture must carry one like a real package layout. + const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + await mkdir(unpackedMainDir, { recursive: true }) + await writeFile( + join(unpackedMainDir, 'daemon-entry.js'), + 'console.error("Usage: daemon-entry "); process.exit(1)\n', + 'utf8' + ) await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) await electronBuilderConfig.afterPack({ diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index c106b166feb..5e70cf68f42 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -221,7 +221,7 @@ describe('Electron runtime package contract', () => { expect(installRun).not.toMatch(/throw\s+\$_/) }) - it('temporarily allows publishing Windows after verifying the signed installer only', () => { + it('verifies Windows inner binary signatures fail-open before publishing', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), 'utf8' @@ -230,12 +230,38 @@ describe('Electron runtime package contract', () => { const steps = parsedWorkflow.jobs.build.steps const stepNames = steps.map((step) => step.name) const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer') - const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable') + const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures') + const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence') const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts') expect(outerVerifyIndex).toBeGreaterThan(-1) - expect(innerVerifyIndex).toBe(-1) - expect(publishIndex).toBe(outerVerifyIndex + 1) + expect(innerVerifyIndex).toBe(outerVerifyIndex + 1) + expect(evidenceIndex).toBe(innerVerifyIndex + 1) + expect(publishIndex).toBe(evidenceIndex + 1) + + // Why fail-open: unsigned inner binaries must warn, not block, until the + // flow is proven on a real release (issue #7785). Flip this to 'true' + // together with the workflow env to make the gate required. + expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false') + + // Why: every step in the inner-signing chain must be unable to fail the + // release — a SignPath outage or timeout falls through to today's + // unsigned-inner flow instead of blocking the cut. + const innerChainStepNames = [ + 'Stage unsigned inner PE files for signing', + 'Upload unsigned inner binaries for SignPath', + 'Submit inner binaries signing request', + 'Notify Slack that inner-binary signing is waiting for approval', + 'Download signed inner binaries from SignPath', + 'Restore signed inner binaries into unpacked app', + 'Replace cached elevate.exe with the signed copy', + 'Rebuild NSIS installer from signed unpacked app' + ] + for (const stepName of innerChainStepNames) { + const step = steps[stepNames.indexOf(stepName)] + expect(step, stepName).toBeDefined() + expect(step['continue-on-error'], stepName).toBe(true) + } }) it('publishes both Linux release matrix entries', () => { diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index 97df46c513b..9b8aad89557 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -97,16 +97,6 @@ function setPlistValue(plistPath, key, value) { execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, plistPath]) } -function sanitizeBundleIdPart(value) { - return ( - value - .toLowerCase() - .replace(/[^a-z0-9.-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 80) || 'dev' - ) -} - function sanitizeMacAppBundleName(value) { return ( Array.from(value, (char) => { @@ -138,7 +128,10 @@ function prepareMacDevElectronApp() { const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev' const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot - const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v4' + // v6: bundle the notification-status helper (real permission readout) and + // ad-hoc re-sign after plist edits so Notification Center accepts the + // bundle; bumping forces stale cached copies to be recreated. + const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6' const hash = createHash('sha1') .update( `${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}` @@ -151,7 +144,17 @@ function prepareMacDevElectronApp() { const appBundleName = `${sanitizeMacAppBundleName(title)}.app` const appPath = path.join(distDir, appBundleName) const markerPath = path.join(distDir, 'orca-dev-electron-app.json') - const bundleId = `com.stablyai.orca.dev.${sanitizeBundleIdPart(hash)}` + // Why: one stable id for every dev instance. Per-instance ids registered a + // new macOS Notification Settings entry for each branch × Electron version, + // piling up "Orca: " rows forever and breaking the notification + // settings deep-link (System Settings can't resolve an id it has no entry + // for and falls back to the root list). macOS keys notification permission + // by bundle id, so a single id also means granting notifications to one dev + // instance covers all of them. Trade-off: when two dev instances run at + // once, macOS may route a notification click to the other instance — + // Electron drops clicks for notification ids it didn't create, so the + // click is lost, not misdirected. + const bundleId = 'com.stablyai.orca.dev' process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId const expectedMarker = JSON.stringify( { title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion }, @@ -206,9 +209,45 @@ function prepareMacDevElectronApp() { setPlistValue(plistPath, 'CFBundleDisplayName', title) setPlistValue(plistPath, 'CFBundleIdentifier', bundleId) - // Why no re-sign: dev launches execute the copied Electron binary directly, - // and Electron's framework bundle is ambiguous to codesign when deep-signing - // an already-built distribution. Avoid blocking `pn dev` on local signing. + // Why: the notification-status helper reads the app's real macOS + // notification authorization (UNUserNotificationCenter has no Electron + // API). It must live inside the bundle and carry the dev bundle id as its + // embedded/code-sign identifier — macOS keys notification records to the + // signing identifier. Non-fatal: without swiftc the permission card falls + // back to delivery-probe heuristics. + try { + execFileSync( + process.execPath, + [ + path.join(repoRoot, 'config', 'scripts', 'build-notification-status-macos.mjs'), + '--bundle-id', + bundleId, + '--single-arch', + '--output', + path.join(appPath, 'Contents', 'MacOS', 'orca-notification-status') + ], + { stdio: 'inherit' } + ) + } catch (error) { + console.warn( + `[orca-dev] notification-status helper build failed (permission card falls back to probes): ${error?.message ?? error}` + ) + } + + // Why: the plist edits above (and the copy itself) break the bundle's + // ad-hoc seal, and macOS refuses Notification Center registration for + // invalidly-signed apps — every dev notification fails with UNErrorDomain + // error 1 and the app never appears in System Settings > Notifications. + // An ad-hoc re-sign restores delivery, the permission prompt, and the + // notification-settings deep link for dev builds. Non-fatal: a signing + // failure should not block `pnpm dev`. + try { + execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath]) + } catch (error) { + console.warn( + `[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}` + ) + } writeFileSync(markerPath, expectedMarker, 'utf8') process.env.ELECTRON_EXEC_PATH = executablePath } diff --git a/config/scripts/serve-headless-fresh-profile-pairing.mjs b/config/scripts/serve-headless-fresh-profile-pairing.mjs new file mode 100755 index 00000000000..6d4f7ac8656 --- /dev/null +++ b/config/scripts/serve-headless-fresh-profile-pairing.mjs @@ -0,0 +1,326 @@ +#!/usr/bin/env node + +import { spawn, spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { hostname, tmpdir } from 'node:os' +import path from 'node:path' +import { createInterface } from 'node:readline' + +const scriptDir = import.meta.dirname +const repoRoot = path.resolve(scriptDir, '..', '..') +const orcaDevScript = path.join(scriptDir, 'orca-dev.mjs') +const ensureNativeRuntimeScript = path.join(scriptDir, 'ensure-native-runtime.mjs') +const fixedProfileDir = process.env.ORCA_HEADLESS_PAIRING_PROFILE_DIR +const parsed = parseArgs(process.argv.slice(2)) + +if (parsed.help) { + printHelp() + process.exit(0) +} + +if (hasForwardedServeFlag(parsed.serveArgs, 'no-pairing')) { + console.error('serve-headless-fresh-profile-pairing: --no-pairing cannot print a pairing code.') + process.exit(2) +} +if (hasForwardedServeFlag(parsed.serveArgs, 'recipe-json')) { + console.error( + 'serve-headless-fresh-profile-pairing: --recipe-json detaches the server and cannot use an ephemeral profile safely.' + ) + process.exit(2) +} + +const serveArgs = withDefaultPairingAddress(parsed.serveArgs) + +ensureElectronRuntime() + +const profileDir = + fixedProfileDir ?? mkdtempSync(path.join(tmpdir(), 'orca-headless-pairing-profile-')) +const ownsProfileDir = !fixedProfileDir +mkdirSync(profileDir, { recursive: true }) + +let cleanedUp = false +let child = null +let sawPairingUrl = false +let stopAttempts = 0 +// Why: temp dev worktrees do not have a root-owned chrome-sandbox; this script +// is only for local headless testing, not packaged production. +const childEnv = { + ...process.env, + ORCA_DEV_USER_DATA_PATH: profileDir, + ...(process.platform === 'linux' + ? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' } + : {}) +} + +console.error(`[headless-pairing] userData=${profileDir}`) +console.error(`[headless-pairing] starting: orca-dev serve --json${formatForwardedArgs(serveArgs)}`) + +child = spawn(process.execPath, [orcaDevScript, 'serve', '--json', ...serveArgs], { + cwd: repoRoot, + detached: process.platform !== 'win32', + env: childEnv, + stdio: ['inherit', 'pipe', 'inherit'] +}) + +const stdoutLines = createInterface({ input: child.stdout }) +stdoutLines.on('line', (line) => { + if (!printReadyLine(line)) { + console.log(line) + } +}) + +child.once('error', (error) => { + console.error(`[headless-pairing] failed to start server: ${error.message}`) + cleanupProfile() + process.exitCode = 1 +}) + +child.once('exit', (code, signal) => { + stdoutLines.close() + if (!sawPairingUrl && code !== 0) { + console.error('[headless-pairing] server exited before printing a pairing URL.') + } + cleanupProfile() + if (typeof code === 'number') { + process.exitCode = code + return + } + process.exitCode = signal ? 1 : 0 +}) + +process.on('SIGINT', () => stopChild('SIGINT')) +process.on('SIGTERM', () => stopChild('SIGTERM')) + +/** + * Parses wrapper flags and forwards everything else to `orca serve`. + */ +function parseArgs(args) { + const serveArgs = [] + let keepProfile = false + let help = false + for (const arg of args) { + if (arg === '--keep') { + keepProfile = true + continue + } + if (arg === '-h' || arg === '--help') { + help = true + continue + } + serveArgs.push(arg) + } + return { help, keepProfile, serveArgs } +} + +/** + * Prints script usage without touching the dev profile or starting the server. + */ +function printHelp() { + console.log(`Usage: node config/scripts/serve-headless-fresh-profile-pairing.mjs [--keep] [orca serve flags] + +Starts orca-dev serve --json with a fresh isolated userData profile, ensures Electron's dev runtime is usable, and prints the pairing URL. + +Wrapper flags: + --keep Keep the fresh profile after the server exits. + -h, --help Show this help. + +Forwarded examples: + node config/scripts/serve-headless-fresh-profile-pairing.mjs --port 6768 + node config/scripts/serve-headless-fresh-profile-pairing.mjs --pairing-address 100.64.1.20 + node config/scripts/serve-headless-fresh-profile-pairing.mjs --mobile-pairing + +Environment: + ORCA_HEADLESS_PAIRING_ADDRESS= Override the auto pairing address. + ORCA_HEADLESS_PAIRING_PROFILE_DIR=/path/to/profile Use a fixed profile directory. +`) +} + +/** + * Adds a reachable pairing address unless the caller provided one explicitly. + */ +function withDefaultPairingAddress(args) { + if (hasPairingAddress(args)) { + return args + } + const address = resolveDefaultPairingAddress() + if (!address) { + return args + } + console.error(`[headless-pairing] pairingAddress=${address} (auto)`) + return [...args, '--pairing-address', address] +} + +/** + * Checks both supported CLI forms for an explicit pairing address. + */ +function hasPairingAddress(args) { + return hasForwardedServeFlag(args, 'pairing-address') +} + +/** + * Checks both exact and `--flag=value` forms for a forwarded serve flag. + */ +function hasForwardedServeFlag(args, name) { + const flag = `--${name}` + return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`)) +} + +/** + * Prefers an override, then Tailscale, then the OS hostname over loopback. + */ +function resolveDefaultPairingAddress() { + const configured = process.env.ORCA_HEADLESS_PAIRING_ADDRESS?.trim() + if (configured) { + return configured + } + const tailscaleAddress = readTailscaleAddress() + if (tailscaleAddress) { + return tailscaleAddress + } + const host = hostname().trim() + return host && host !== 'localhost' ? host : null +} + +/** + * Reads the first Tailscale IPv4 address when Tailscale is installed. + */ +function readTailscaleAddress() { + const result = spawnSync('tailscale', ['ip', '-4'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) + if (result.error || result.status !== 0) { + return null + } + return ( + result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? null + ) +} + +/** + * Formats forwarded arguments for the startup log. + */ +function formatForwardedArgs(args) { + if (args.length === 0) { + return '' + } + return ` ${args.map(formatShellArg).join(' ')}` +} + +/** + * Quotes a shell-ish argument for display only. + */ +function formatShellArg(value) { + if (/^[\w./:=@+-]+$/.test(value)) { + return value + } + return JSON.stringify(value) +} + +/** + * Runs the same Electron runtime preflight used by dev/start package scripts. + */ +function ensureElectronRuntime() { + console.error('[headless-pairing] ensuring Electron runtime...') + const result = spawnSync(process.execPath, [ensureNativeRuntimeScript, '--runtime=electron'], { + cwd: repoRoot, + stdio: 'inherit' + }) + if (result.error) { + console.error(`[headless-pairing] Electron runtime preflight failed: ${result.error.message}`) + process.exit(1) + } + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} + +/** + * Rewrites the CLI JSON readiness line into the pairing URL testers need. + */ +function printReadyLine(line) { + let payload + try { + payload = JSON.parse(line) + } catch { + return false + } + if (!payload || payload.type !== 'orca_server_ready') { + return false + } + console.log(`Orca server ready: ${payload.endpoint ?? 'websocket unavailable'}`) + if (payload.pairing?.endpoint) { + console.log(`Pairing endpoint: ${payload.pairing.endpoint}`) + } + if (payload.pairing?.webClientUrl) { + console.log(`Web client URL: ${payload.pairing.webClientUrl}`) + } + if (payload.pairing?.url) { + sawPairingUrl = true + console.log(`Pairing URL: ${payload.pairing.url}`) + console.error('[headless-pairing] server is running; press Ctrl+C to stop.') + return true + } + console.log('Pairing URL: unavailable') + return true +} + +/** + * Stops the foreground server; repeated signals force its process tree down. + */ +function stopChild(signal) { + if (!child || child.killed) { + return + } + stopAttempts += 1 + const targetSignal = stopAttempts > 1 ? 'SIGKILL' : signal + if (process.platform === 'win32' && child.pid) { + // Why: child.kill() only targets orca-dev on Windows; taskkill walks the + // CLI/Electron descendants so the fresh profile is not left locked. + const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + killer.on('error', () => child?.kill(targetSignal)) + return + } + if (process.platform !== 'win32' && child.pid) { + // Why: orca-dev synchronously owns the CLI child, which owns Electron; kill + // the spawned process group so programmatic shutdown does not orphan serve. + try { + process.kill(-child.pid, targetSignal) + return + } catch { + // Fall back to the direct child below if the process group already exited. + } + } + child.kill(targetSignal) +} + +/** + * Removes only the ephemeral profile directory this script created. + */ +function cleanupProfile() { + if (cleanedUp) { + return + } + cleanedUp = true + if (parsed.keepProfile || !ownsProfileDir) { + console.error(`[headless-pairing] kept ${profileDir}`) + return + } + if (!existsSync(profileDir) || !profileDir.includes('orca-headless-pairing-profile-')) { + console.error(`[headless-pairing] skipped cleanup for unexpected profile path: ${profileDir}`) + return + } + try { + rmSync(profileDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + console.error(`[headless-pairing] removed ${profileDir}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[headless-pairing] skipped cleanup for ${profileDir}: ${message}`) + } +} diff --git a/config/scripts/verify-packaged-daemon-entry.cjs b/config/scripts/verify-packaged-daemon-entry.cjs new file mode 100644 index 00000000000..8f5e802825c --- /dev/null +++ b/config/scripts/verify-packaged-daemon-entry.cjs @@ -0,0 +1,58 @@ +const { existsSync } = require('node:fs') +const { spawnSync } = require('node:child_process') +const { join } = require('node:path') + +// Why: `asarUnpack` in config/electron-builder.config.cjs lists +// out/main/daemon-entry.js on every platform, and the packaged daemon fork +// (src/main/daemon/daemon-init.ts) resolves exactly this unpacked path. A +// missing entry means the package layout regressed, so the check throws +// instead of skipping — a silent skip false-passed exactly the layout bug +// this gate exists to catch. +function assertPackagedDaemonEntryExists(resourcesDir) { + const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') + if (!existsSync(entryPath)) { + throw new Error( + `[verify-packaged-daemon-entry] missing unpacked daemon entry at ${entryPath} — ` + + `asarUnpack expects out/main/daemon-entry.js on every platform, so the packaged ` + + `daemon cannot be forked from this layout` + ) + } + return entryPath +} + +// Why: v1.4.129-rc.1 shipped a terminal daemon that could not load (an electron +// `require` leaked into its bundle) while every build check passed. This boots +// the PACKAGED daemon-entry under plain Node against the asar-unpacked layout, +// so a bundling / asar-unpack regression fails packaging instead of reaching +// users. Module-load proof only: with no args the entry must reach argv parsing +// and print its "Usage: daemon-entry" error — a MODULE_NOT_FOUND or a missing +// usage line means the packaged graph does not load and the build must fail. +// +// resourcesDir is the packaged Resources dir (Contents/Resources on macOS, +// /resources elsewhere). execPath defaults to the packaging Node. +function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) { + const execPath = options.execPath || process.execPath + const entryPath = assertPackagedDaemonEntryExists(resourcesDir) + + const result = spawnSync(execPath, [entryPath], { encoding: 'utf8', timeout: 10_000 }) + if (result.error) { + throw new Error( + `[verify-packaged-daemon-entry] could not launch daemon-entry.js: ${result.error.message}` + ) + } + const stderr = result.stderr || '' + if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) { + throw new Error( + `[verify-packaged-daemon-entry] packaged daemon-entry.js failed to load under plain Node:\n${stderr}` + ) + } + if (!stderr.includes('Usage: daemon-entry')) { + throw new Error( + `[verify-packaged-daemon-entry] packaged daemon-entry.js did not reach argv parsing ` + + `(expected the "Usage: daemon-entry" error). stderr:\n${stderr}` + ) + } + console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node') +} + +module.exports = { assertPackagedDaemonEntryExists, verifyPackagedDaemonEntryBoots } diff --git a/config/scripts/verify-packaged-daemon-entry.test.mjs b/config/scripts/verify-packaged-daemon-entry.test.mjs new file mode 100644 index 00000000000..8705953ba61 --- /dev/null +++ b/config/scripts/verify-packaged-daemon-entry.test.mjs @@ -0,0 +1,57 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + assertPackagedDaemonEntryExists, + verifyPackagedDaemonEntryBoots +} = require('./verify-packaged-daemon-entry.cjs') + +describe('verify-packaged-daemon-entry', () => { + let resourcesDir + + beforeEach(() => { + resourcesDir = mkdtempSync(join(tmpdir(), 'orca-daemon-entry-verify-')) + }) + + afterEach(() => { + rmSync(resourcesDir, { recursive: true, force: true }) + }) + + function writePackagedEntry(source) { + const entryDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + mkdirSync(entryDir, { recursive: true }) + writeFileSync(join(entryDir, 'daemon-entry.js'), source) + } + + // Why: a silent skip on a missing entry false-passed exactly the packaged + // layout regression this gate exists to catch (rc.1 daemon-load incident). + it('throws when the unpacked daemon entry is missing', () => { + expect(() => assertPackagedDaemonEntryExists(resourcesDir)).toThrow( + /missing unpacked daemon entry/ + ) + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow( + /missing unpacked daemon entry/ + ) + }) + + it('passes when the packaged entry loads and reaches argv parsing', () => { + writePackagedEntry('console.error("Usage: daemon-entry "); process.exit(1)\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).not.toThrow() + }) + + it('fails when the packaged entry cannot resolve its module graph', () => { + writePackagedEntry('require("orca-module-that-does-not-exist")\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow( + /failed to load under plain Node/ + ) + }) + + it('fails when the packaged entry never reaches argv parsing', () => { + writePackagedEntry('process.exit(0)\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(/did not reach argv parsing/) + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index a7c4761a0ec..8d92fa208ca 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -13,9 +13,13 @@ "../src/main/codex/codex-config-mirror.ts", "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-home-paths.ts", + "../src/main/codex/codex-hook-identity.ts", + "../src/main/codex/codex-wsl-hook-install-plan.ts", + "../src/main/codex/config-settings-promotion.ts", "../src/main/codex/config-toml-line-scan.ts", "../src/main/codex/config-toml-trust.ts", "../src/main/codex/hook-service.ts", + "../src/main/codex/hook-trust-promotion.ts", "../src/main/codex-accounts/fs-utils.ts", "../src/main/command-code/command-code-managed-script.ts", "../src/main/command-code/hook-service.ts", diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index a8145eb76c3..817b00c9cbe 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -2,6 +2,7 @@ "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "include": [ "../electron.vite.config.*", + "../build-plugins/**/*", "../src/main/**/*", "../src/preload/**/*", "../src/shared/**/*", diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index 538b7805d91..d795f74039a 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -225,7 +225,7 @@ Three scrollbar classes are defined globally in `main.css`: - **`.scrollbar-sleek`** — the default thin, neutral scrollbar for sidebars, lists, popovers. Pair with `.scrollbar-sleek-parent` on a hover-target ancestor if you want the thumb to fade in only on parent hover. - **`.scrollbar-editor`** — slightly heavier, used inside Monaco-adjacent surfaces. -- **`.worktree-sidebar-scrollbar`** — reserves the gutter but keeps the thumb invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar so the chrome stays still. +- **`.worktree-sidebar-scrollbar`** — no reserved gutter: paired with `overflow-y-auto`, the scrollbar (and its width) exists only while content actually overflows, so a short list stays flush with the fixed header controls and classic-scrollbar Windows shows no arrow buttons on empty lists. The thumb stays invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar. Apply one of these to overflow containers; don't write a fourth style. diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 3f124029e89..1333939252a 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.7m + + downloads: 4.2m @@ -15,7 +15,7 @@ downloads downloads - 3.7m - 3.7m + 4.2m + 4.2m diff --git a/docs/assets/wechat-qr-backup.jpg b/docs/assets/wechat-qr-backup.jpg new file mode 100644 index 00000000000..a6392babbce Binary files /dev/null and b/docs/assets/wechat-qr-backup.jpg differ diff --git a/docs/issue-7649-vscode-wsl-launch.md b/docs/issue-7649-vscode-wsl-launch.md new file mode 100644 index 00000000000..4643672e41e --- /dev/null +++ b/docs/issue-7649-vscode-wsl-launch.md @@ -0,0 +1,95 @@ +# VS Code WSL Workspace Launch + +## Problem + +On Windows, choosing **Open in VS Code** for a workspace stored under a WSL UNC path opens the folder in a Windows VS Code environment instead of a Remote - WSL window ([issue #7649](https://github.com/stablyai/orca/issues/7649)). The renderer passes the workspace path and configured editor command unchanged (`src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx:103-121`), and the main process delegates launch argument construction to `resolveExternalEditorLaunchSpec` (`src/main/ipc/shell.ts:101-110`). The builder currently gives every non-Cursor executable only the original path (`src/main/external-editor-launch.ts:109-116`). + +The deterministic reproduction for `\\wsl.localhost\Ubuntu\home\aliuq\project` produces `code ` with no remote authority. VS Code's supported Windows CLI form is `code --remote wsl+ `. + +## Root cause + +`resolveExternalEditorLaunchSpec` does not distinguish a VS Code launch targeting a WSL UNC workspace. Orca already has a shared parser for both modern `\\wsl.localhost\...` and legacy `\\wsl$\...` paths (`src/shared/wsl-paths.ts:1-20`), but the editor launcher never uses it. VS Code therefore receives the Windows-visible UNC folder and correctly opens it as a local Windows workspace. + +## Non-goals + +- Change the Open in menu, settings UI, IPC payload, file-manager behavior, or SSH/remote-runtime behavior. +- Infer WSL identity for ordinary drive-letter paths from project runtime settings. +- Rewrite user-defined compound shell commands or add remote flags to Cursor, VSCodium, or arbitrary editors. +- Install or configure the VS Code WSL extension. + +## Design + +1. In the external-editor launch-spec builder, parse the target path with the shared WSL UNC parser when the host platform is Windows. +2. For direct/executable VS Code Stable or Insiders launchers only, reuse the existing normalized launcher-basename check and translate a recognized WSL target into `['--remote', 'wsl+', '']`. The exact allowlist recognizes Stable's `code`, Insiders' `code-insiders`, and the direct `Code - Insiders.exe` basename without matching unrelated `code-*` editors. Matching is case-insensitive and strips every Windows launcher suffix already supported by Orca (`.cmd`, `.exe`, and `.bat`). +3. Keep local paths, non-Windows hosts, non-VS-Code applications, and compound commands on their existing argument paths. The existing main-process spawn and Windows shim handling remain unchanged. +4. Replace the temporary reproduction harness with focused regression cases in `src/main/external-editor-launch.test.ts` covering modern and legacy WSL UNC forms plus unaffected local/custom-editor behavior. + +## Data flow + +- Worktree menu selects VS Code and sends `(workspacePath, 'code')` over existing IPC. +- Main process validates the absolute existing host path. +- Launch-spec builder resolves the VS Code executable. +- On Windows + WSL UNC + VS Code, the builder emits the Remote - WSL authority and Linux-native folder path. +- Existing Windows spawn wrapping launches VS Code with those arguments. + +## Edge cases + +- Modern `\\wsl.localhost\\...` and legacy `\\wsl$\\...` paths both preserve the distro spelling and convert separators to a POSIX path. +- Distro names and Linux folder paths containing spaces remain single arguments because launch-spec construction and Windows shim wrapping preserve argument-array boundaries. +- A WSL distro root maps to `/`. +- Windows drive-letter and ordinary UNC paths stay local. +- macOS/Linux behavior stays unchanged even for strings that resemble WSL UNC paths. +- Cursor retains `--new-window`; other custom editors retain their existing single path argument. +- Compound commands remain user-owned and are not rewritten because inserting flags safely would require parsing arbitrary shell syntax. +- SSH and remote-runtime workspaces remain blocked from local path opening by the existing renderer guard; this change does not alter that boundary. +- If the VS Code WSL extension is unavailable, launch behavior is left to VS Code and Orca retains its existing spawn-success contract. + +## Test plan + +- Unit: demonstrate the pre-fix launch spec lacks `--remote` for a modern WSL UNC path. +- Unit: assert the fixed modern UNC launch is `--remote`, `wsl+Ubuntu`, `/home/...`. +- Unit: assert legacy `\\wsl$` and distro-root paths produce the same Remote - WSL form. +- Unit: assert direct and resolved Windows VS Code Stable and Insiders launchers are matched case-insensitively across `.exe`, `.cmd`, and `.bat` suffixes. +- Unit: assert distro names and Linux folder paths containing spaces remain intact arguments through launch-spec construction and Windows shim forwarding. +- Unit: assert a Windows local path remains unchanged and explicit `darwin` and `linux` hosts do not acquire WSL remote arguments. +- Unit: assert Cursor and another custom editor are not given VS Code remote arguments. +- Integration/Electron: create a throwaway repo in the installed Ubuntu WSL distro, add/open it in Orca, choose Open in VS Code, and verify the VS Code remote indicator and an integrated-terminal Linux probe. +- Adjacent smoke: open a local Windows workspace in VS Code and verify it remains a local Windows window. +- Repository gates: focused Vitest files, `pnpm typecheck`, `pnpm lint`, and `pnpm check:max-lines-ratchet`. + +## UI quality bar + +Not UI-visible in Orca. The existing menu, labels, loading behavior, and errors do not change. The user-visible acceptance criterion is external: the launched VS Code window must identify the selected WSL distro and its terminal must run Linux. + +## Review screenshots + +1. Golden path: VS Code opened from a throwaway WSL workspace, showing the Remote - WSL indicator and a terminal Linux probe. +2. Adjacent local path: VS Code opened from a local Windows workspace, showing a local Windows terminal/environment. + +## Rollout + +1. Add launch-spec regression tests and observe the WSL case fail against the desired arguments. +2. Add the scoped VS Code + Windows + WSL argument translation. +3. Run focused and repository-wide static/test gates. +4. Validate WSL and local launches end to end, retaining local screenshots outside the PR. + +## Lightweight Eng Review + +- Scope: Kept to the launch-spec seam; no renderer, IPC, persistence, runtime-routing, or settings changes are needed because WSL filesystem identity is encoded in the UNC path. +- Architecture/data flow: Reuse `parseWslUncPath` in the main-process pure argument builder, then leave executable resolution, Windows shim wrapping, spawn lifecycle, and renderer guards unchanged. +- Failure modes covered: + - Modern and legacy WSL UNC aliases route to the correct distro and Linux path. + - Local/ordinary UNC paths and non-Windows platforms do not acquire remote flags. + - Case variants and supported Windows VS Code shim suffixes are recognized without classifying custom editors or compound shell commands as VS Code. + - Distro and folder names containing spaces survive the Windows shim as distinct arguments. + - Missing VS Code WSL support still follows the existing launch contract rather than adding a new partial-failure protocol. +- Test coverage required: + - Pure launch-spec regression cases in `src/main/external-editor-launch.test.ts` for WSL aliases/root, launcher case/suffix variants, and unaffected branches. + - `src/main/ipc/shell.test.ts` plus the existing Windows shim contract to prove remote arguments, including spaces, are forwarded as distinct values. + - Live Windows + Ubuntu WSL + VS Code smoke for the actual environment boundary. +- Performance/blast radius: One regex parse per external-editor click only; no startup, polling, watcher, terminal, or renderer cost. Blast radius is limited to direct VS Code launches of WSL UNC paths on Windows. +- UI quality bar: Not UI-visible in Orca; VS Code must visibly attach to the requested WSL distro and run a Linux terminal. +- Required review screenshots: + 1. WSL VS Code window with distro indicator and Linux terminal probe. + 2. Local Windows VS Code window demonstrating unchanged local launch behavior. +- Residual risks: VS Code without the WSL extension may reject or prompt on the valid remote launch; this is external dependency behavior and should not cause Orca to fall back silently to the wrong Windows environment. diff --git a/docs/new-worktree-sidebar-reveal.md b/docs/new-worktree-sidebar-reveal.md deleted file mode 100644 index 75abb7bd647..00000000000 --- a/docs/new-worktree-sidebar-reveal.md +++ /dev/null @@ -1,79 +0,0 @@ -# New Worktree Sidebar Reveal - -## Problem - -Issue https://github.com/stablyai/orca-internal/issues/350 asks that newly created worktrees jump into view in the left sidebar list with no scroll animation. - -Current behavior: - -- `activateAndRevealWorktree(...)` always calls `state.revealWorktreeInSidebar(worktreeId)` with no options. -- `revealWorktreeInSidebar` defaults `behavior` to `'smooth'` (`ui.ts`). -- `WorktreeList` forwards that behavior to `virtualizer.scrollToIndex(..., { behavior })`. - -Result: off-screen targets animate by default, including freshly created worktrees. - -## Root Cause - -`activateAndRevealWorktree` conflates two intents: - -1. activate existing worktree navigation (smooth reveal is fine); -2. activate a just-created worktree (must jump immediately). - -Created-worktree callers cannot currently express reveal intent, so they inherit `'smooth'`. - -## Scope and Non-goals - -- Add an opt-in reveal behavior at activation call sites. -- Apply `'auto'` only where the worktree is newly created/added in that flow. -- Preserve existing behavior for normal worktree navigation (clicks, keyboard, history nav, palette selection of existing worktrees, port/status-driven activation) unless a caller opts in. -- Do not change direct raw reveal paths in IPC handlers that intentionally call `store.revealWorktreeInSidebar(...)` outside `activateAndRevealWorktree` (terminal/editor/mobile focus paths remain smooth). -- Do not change sorting/grouping/filter UI, list virtualization strategy, or sidebar styling. - -## Design - -1. Extend `activateAndRevealWorktree` options: - - `sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']`. -2. In `activateAndRevealWorktree`, call: - - `state.revealWorktreeInSidebar(worktreeId, { behavior: opts.sidebarRevealBehavior })` when provided; - - otherwise keep `state.revealWorktreeInSidebar(worktreeId)` so default behavior stays unchanged. -3. Pass `sidebarRevealBehavior: 'auto'` only from created/added-worktree flows: - - `useComposerState` full-create path; - - `useComposerState` quick-create path; - - `useIpcEvents` `onActivateWorktree` only when the event corresponds to a newly created worktree; - - `launch-work-item-direct`; - - folder add/create flows that activate a newly-added synthetic folder worktree (`AddRepoCreateStep` folder branch, `NonGitFolderDialog`, and `repos` slice `addNonGitFolder` path). -4. Keep existing navigation activations smooth, including: - - `AddRepoDialog` / `ProjectAddedDialog` “open primary worktree” actions (these can target pre-existing worktrees, not guaranteed newly created); - - all existing `activateAndRevealWorktree(...)` callers that do not opt in. -5. Keep `WorktreeList` reveal effect unchanged; it already honors `pendingRevealWorktree.behavior`. - -## Correctness Notes and Edge Cases - -- Repo-filter clearing remains unchanged: `activateAndRevealWorktree` only clears `filterRepoIds` when target repo is excluded. -- Other visibility constraints are not auto-cleared. If the target exists but is hidden by other sidebar state, `resolvePendingSidebarReveal(...)` keeps the reveal pending. -- The reveal effect uncollapses lineage/group containers before scroll; behavior changes only animation mode, not visibility resolution. -- Pending reveal is a single store slot (`pendingRevealWorktree`). Concurrent reveal requests are last-writer-wins; this change should not alter that behavior. -- If activation cannot resolve the worktree (`getKnownWorktreeById` miss), behavior remains unchanged (`false`, no reveal queued). -- `ui:activateWorktree` is an overloaded IPC used by both creation and non-creation activation paths. The renderer must choose `'auto'` only for create cases (for example, worktree absent before fetch and present after fetch), and keep default smooth reveal for existing-worktree activations. -- Multi-window consistency remains per renderer window store; each window applies its own reveal behavior locally. -- This change is renderer-only; it does not add main-process coordination and does not make reveal ordering transactional across concurrent async creators. - -## Tests - -Add/adjust focused tests in `worktree-activation` coverage: - -- explicit `sidebarRevealBehavior: 'auto'` is forwarded to `revealWorktreeInSidebar(worktreeId, { behavior: 'auto' })`; -- no option still calls `revealWorktreeInSidebar(worktreeId)` (store default remains smooth). - -Add call-site regression tests (recommended, small): - -- one composer create path passes `'auto'`; -- one non-created navigation path stays default (no behavior option). - -## Rollout - -1. Add `sidebarRevealBehavior` option in `activateAndRevealWorktree`. -2. Update created-worktree callers to pass `'auto'`. -3. Add tests above. -4. Run targeted Vitest tests, then `pnpm typecheck` and `pnpm lint`. -5. Validate in Electron: with sidebar overflow, create a worktree and verify the list jumps to it without smooth animation. diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index e1403aed2df..08c63240cc6 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -36,7 +36,7 @@ Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar. -[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono. - **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 22e461eb611..b62d2fd2caa 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -36,7 +36,7 @@ スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。 - **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index b2fc4857112..dc4bc8355fe 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. - **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 6c3fc52354c..5d268491ef0 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -36,7 +36,7 @@ Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar. -[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Baixar APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [Baixar APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- @@ -238,10 +238,6 @@ Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular. - **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**. - **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios. -- **WeChat:** Escaneie o QR code para entrar na comunidade. - - QR code do WeChat para a comunidade Orca - - **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues). - **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar. - **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários. diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 4c538b08f22..497e98fe919 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -36,7 +36,7 @@ 用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。 -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile) @@ -227,7 +227,7 @@ yay -S stably-orca-bin 与桌面应用配对,用手机监控并指挥你的智能体。 - **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) +- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) --- @@ -235,6 +235,11 @@ yay -S stably-orca-bin - **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。 - **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。 +- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。 + + Orca 社区微信群二维码 + Orca 社区备用微信群二维码 + - **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。 - **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。 - **支持我们:** 给这个仓库点 [Star](https://github.com/stablyai/orca),关注我们的日常发布。 diff --git a/docs/source-control-push-failure-ai-recovery.md b/docs/source-control-push-failure-ai-recovery.md new file mode 100644 index 00000000000..136b7e0031a --- /dev/null +++ b/docs/source-control-push-failure-ai-recovery.md @@ -0,0 +1,368 @@ +# Source Control Push Failure AI Recovery + +## Problem + +Push, force-push, publish, and sync push-stage failures flow through the shared +remote operation formatter in `src/renderer/src/lib/source-control-remote-error.ts:94` +and the editor-store toast paths in `src/renderer/src/store/slices/editor.ts:3716` +and `src/renderer/src/store/slices/editor.ts:3821`. A local pre-push hook or +lint hook is not an auth, protected-branch, or transport problem, so generic +remote guidance sends the user in the wrong direction. + +PR #7787 proves the behavior is useful, but the prototype needs tightening before +it is maintainable: + +- `src/renderer/src/components/right-sidebar/SourceControl.tsx:1938` and + `src/renderer/src/components/right-sidebar/SourceControl.tsx:5799` repeat + push-failure predicates and feed live branch/file state into a prompt that says + "at failure time". +- `src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts:63` + imports push detection again instead of consuming one derived recovery model. +- `src/renderer/src/components/right-sidebar/source-control-ai-commit-failure-launch.ts:24` + and `src/renderer/src/components/right-sidebar/source-control-ai-push-failure-launch.ts:24` + are copied launch flows with action-specific copy. +- `src/shared/source-control-commit-failure-agent-command.ts:6` and + `src/shared/source-control-push-failure-agent-command.ts:6` are identical + command-template builders. +- `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx:21` + already contains a reusable split button, while `SourceControl.tsx:6415` + carries a second inline version. +- `src/renderer/src/lib/source-control-remote-error.ts:148` currently treats + any `isSync` error as push-like for hook detection. `syncBranch` calls the + formatter for fetch, upstream-status, pull, and push failures, so the renderer + cannot prove a sync error came from the push stage without an explicit marker. +- `src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts:2` + imports prompt helpers through the large React module instead of shared prompt + modules. + +## Goal + +Add first-class recovery for pre-push hook failures without growing a parallel +push-only subsystem: + +1. Toasts for push, force-push, publish, and sync push-stage failures say + "blocked" for detected pre-push or lint-hook output. +2. Source Control shows a concise Push blocked panel with Details and AI Fix + only for a failure snapshot that is both push-like and hook-like. +3. AI Fix launches the configured `fixPushFailure` Source Control action with a + safe, provider-neutral prompt. +4. Commit-failure recovery and push-failure recovery share launch, + command-template, split-button, and dense error-panel primitives. + +## Non-goals + +- Do not fix server-side pre-receive hooks, hosted CI failures, or provider-side + protected-branch failures from this entry point. +- Do not bypass hooks, add `--no-verify`, push from the launched agent, create a + PR, or assume GitHub-specific terminology. +- Do not add app-wide persistence for this transient failure panel. +- Do not redesign the Source Control panel or the Source Control AI settings + model. +- Do not fold unrelated launch surfaces such as checks recovery into this change + unless a tiny shared helper is already needed for commit/push recovery. + +## Design + +1. Keep push-hook detection and prompt building in shared code. + - `src/shared/source-control-push-failure.ts` owns normalization, bounded + scanning, summary text, Details eligibility, prompt truncation, prompt + rules, and changed-file list bounding. + - Detection must stay conservative: explicit `pre-push`/`prepush`, + `hook declined to push`, hook-runner output in push context, or lint output + in push context. Auth, non-fast-forward, protected branch, pre-receive, + submodule, and generic transport failures stay on the existing remote-error + path. + - Keep the output scan bounded at 64 KiB and the prompt failure-output + section bounded. Also cap prompt file lines to a small constant with an + omitted-count line; do not rely on the git-status cap, which is far too high + for prompt context. + +2. Make remote-error formatting sync-stage aware. + - Extend `RemoteOperationErrorOptions` with an explicit push-stage flag for + sync, for example `isSyncPushStage`. + - Gate push-hook classification on `isPush`, `isForcePush`, `publish`, or + `isSyncPushStage`; do not use bare `isSync` as evidence of a push failure. + - In `syncBranch`, pass the push-stage flag only inside the two inner + `pushRuntimeGit` catch blocks. The outer fetch, upstream-status, and pull + catches still pass `isSync` for Sync-shaped generic copy but must not render + blocked hook copy. + - Carry the same push-stage fact back to the renderer, either by tagging the + thrown `Error` with a narrow exported marker or by wrapping it in a typed + error that preserves the original message/cause. `SourceControl` must set + `syncPushStage` only from that marker, not from `kind === 'sync'` plus + hook-like stderr. + - Keep submodule push messages before hook detection so submodule guidance + remains more specific than AI recovery. + +3. Replace one-off command builders with one shared recovery command builder. + - Add a shared builder that accepts `{ actionId, promptOverride, + commandInputTemplate, basePrompt }` and renders the existing Source Control + AI command template for launch actions. + - Keep compatibility exports for commit and push helpers if nearby tests or + call sites still use those names, but make them wrappers over the generic + builder. + - Move prompt-builder tests to import from shared modules, not + `SourceControl.tsx`. + +4. Capture and derive push recovery state once. + - Extend `SourceControlActionError` with the raw error, a push-stage marker + for sync failures, the branch name at failure time, a bounded status-entry + snapshot at failure time, and a per-worktree sequence token. + - `runRemoteAction` clears the worktree's previous error and increments its + sequence before starting. A catch may write an error only if its sequence + still owns that worktree, so a slow failure cannot overwrite a newer retry + or success. + - Add a focused renderer helper that accepts the captured + `SourceControlActionError` and the current branch name, then returns either + `null` or a model with raw/sanitized detail text, summary, details flag, + kind label, and AI prompt. + - The helper returns `null` unless the operation is `push`, `force_push`, + `publish`, or `sync` with the sync push-stage marker and + `isPushHookFailure(rawError)` passes. + - If the current branch is known and differs from the captured branch, hide or + clear the model instead of launching an agent with wrong branch context. + - Pass that single model to `useSourceControlAi` and `CommitArea`; neither + prop construction nor `CommitArea` should repeat the push-hook predicate. + +5. Use one generic recovery launcher for commit and push failures. + - Replace the separate commit and push launch files with + `source-control-ai-recovery-launch.ts`, or keep the old files as one-line + wrappers if import stability is cheaper. + - The generic launcher owns connection resolution, SSH/local agent discovery, + saved-agent validation, agent-args validation, agent selection, terminal + launch, focus, and success/failure toasts. + - Resolve connection without collapsing local and unresolved states: read + `const worktreeConnectionId = getConnectionId(worktreeId)`, use it when it + is a string or `null`, and only fall back to `sourceRepoConnectionId` when + the worktree lookup returns `undefined`. If the fallback is also + `undefined`, show the workspace-connection error instead of launching + locally. `null` means proven local; a string means SSH/remote. + - Validate saved CLI args before agent detection or terminal creation. On + Windows, keep using PowerShell-safe planning through + `planAgentCliArgsSuffix(..., 'powershell')`. + - Action-specific data is limited to action id, base prompt, empty-prompt + copy, unavailable-agent copy, and success copy. + +6. Keep the recovery hook thin. + - `use-source-control-recovery-ai.ts` builds the commit prompt, accepts the + already-derived push recovery prompt/model, keeps independent loading + flags, and calls the generic launcher. + - It should not import push-hook detection, resolve agents, or duplicate + launch plumbing. + +7. Extract and reuse the recovery UI. + - Reuse or replace the existing + `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx` + rather than adding another split-button component. + - Move the dense recovery notice and Details dialog into focused renderer + modules named for source-control recovery. Avoid `helpers`, `utils`, and new + max-lines suppressions. + - Reuse the same notice/dialog component for commit and push recovery with + action-specific labels, summary, details, prompt, saved recipe, and launch + callback. + - Keep normal remote errors filtered out when a push recovery model is + rendered, so the user does not see both Push blocked and generic remote + error copy for the same failure. + +8. Retain `fixPushFailure` in the existing Source Control AI action model. + - The prototype already adds `fixPushFailure` to + `src/shared/source-control-ai-actions.ts`; keep it as a launch action like + `fixCommitFailure`. + - Verify default settings, normalizers, global settings rows, repository + override rows, labels, descriptions, and variable chips all include Push + failure fixes. + - Do not create a push-specific settings store or migration. Existing + `normalizeSourceControlAiSettings` hydrates missing action defaults. + +## Data Flow + +- User clicks Push, Force Push, Publish Branch, or Sync. +- `runRemoteAction` clears that worktree's previous remote error, records a new + sequence token, and starts the editor-store remote action. +- Git push fails and throws. +- `pushBranch` or sync's inner push-stage catch calls + `resolveRemoteOperationErrorMessage` with push-like options. Sync fetch, pull, + and upstream-status failures do not pass the push-stage flag. +- Sync's inner push-stage catch marks the rethrown error as push-stage so + `runRemoteAction` can preserve `syncPushStage` in the captured snapshot. +- The formatter classifies hook output before auth/transport fallbacks only for + push-like options and shows blocked toast copy. +- `SourceControl` catches the same error, and if the sequence still owns the + worktree, stores `{ kind, rawError, message, syncPushStage, branchName, + entriesSnapshot }`. +- The push recovery helper derives one `pushRecovery` model from that snapshot. +- `CommitArea` renders normal remote errors or the Push blocked recovery notice. +- AI Fix builds the `fixPushFailure` command input and launches the selected + agent in the owning local or SSH runtime. + +## Edge Cases + +- Auth, missing repo, protected-branch, pre-receive, non-fast-forward, and + transport failures must not show the Push blocked panel. +- Submodule push errors keep their existing specialized messages before + push-hook detection. +- Sync failures from fetch, upstream-status, or pull stages must not show Push + blocked, even if stderr contains words like "lint" or "hook". +- Create PR intent remote failures use the same `runRemoteAction` plumbing; if + CommitArea is not visible, blocked toast copy is still required but no hidden + panel work is needed. +- A newer remote operation for the same worktree must clear stale recovery state + immediately and prevent an older in-flight failure from writing after it. +- Switching worktrees or branches must not show another worktree or branch's + push failure. +- External edits or another Orca window may make the failure stale. Do not add + cross-window persistence for this feature; rely on local retry/remount/branch + mismatch clearing and list this as residual risk. +- Source Control AI hidden for a repo still shows the blocked notice and Details; + it hides AI Fix. +- SSH worktrees must detect and launch agents on the owning connection, not a + local fallback. +- Windows launch planning must continue to use PowerShell-safe agent args. +- Very large hook output and very large changed-file sets must be bounded before + prompt generation. +- ANSI/control output must not leak into summaries or Details comparison logic. + Details may show sanitized text while preserving enough line breaks for + debugging. +- Empty custom command templates must stay empty so the launcher rejects them + with a clear settings error. +- Prompt text must treat file paths, branch names, and hook output as data, not + instructions. + +## Test Plan + +- Shared unit tests: + - `src/shared/source-control-push-failure.test.ts` covers detection positives, + auth/protected/pre-receive/non-fast-forward negatives, ANSI/control + stripping, bounded scanning, details comparison, prompt output truncation, + prompt file-list capping, and provider-neutral prompt rules. + - Shared command-template tests cover the generic recovery builder plus + commit/push wrappers, including empty templates and prompt overrides. + - `src/shared/source-control-ai-actions.test.ts` covers `fixPushFailure` + label, default template, variables, normalization, and action-list inclusion. +- Remote formatter and store tests: + - `src/renderer/src/lib/source-control-remote-error.test.ts` covers push, + force-push, publish, and sync push-stage blocked copy plus sync non-push + stage negatives, auth/non-fast-forward/protected/pre-receive/submodule + regressions. + - `src/renderer/src/store/slices/editor.test.ts` covers the `pushBranch` toast + path and both sync push-stage and sync non-push-stage toasts. +- Renderer model and UI tests: + - A focused push recovery derivation test covers operation kind, sync + push-stage marker, branch mismatch, stale sequence ownership, snapshot file + entries, summary, details flag, prompt contents, and ordinary remote errors. + - `CommitArea` tests cover the Push blocked notice, Details dialog, AI Fix + visible/hidden, ordinary remote errors, no duplicate remote error when a push + model exists, and unchanged commit-failure recovery rendering. + - Settings tests cover global and repository Source Control AI action rows + showing Push failure fixes. +- Launcher tests: + - Generic recovery launcher tests cover commit and push action ids, invalid CLI + args before detection, empty template rejection, unavailable saved agent, + local versus SSH agent detection, successful launch/focus, and success copy. +- Checks: + - Run targeted vitest files above, `pnpm typecheck`, `pnpm lint`, and + `pnpm check:max-lines-ratchet`. + +## UI Quality Bar + +The Source Control recovery UI must match `docs/STYLEGUIDE.md`: monochrome, +quiet, dense, token-based, and consistent with adjacent commit-failure recovery. +Use existing shadcn `Button`, `DropdownMenu`, and `Dialog` primitives, lucide +icons, `card`/`border`/`destructive`/`muted` tokens, and the existing action +recipe row patterns. The Push blocked notice should keep the summary readable at +narrow right-sidebar widths, avoid nested cards, avoid new color values, show +stable split-button geometry while launching, and keep Details as progressive +disclosure for long hook output. Dialogs and dropdowns must remain usable in +light/dark mode and under SSH latency. + +## Review Screenshots + +1. Source Control after a simulated pre-push hook failure: Push blocked notice + with summary, AI Fix, and Details. +2. Push failure Details dialog showing hook output and Fix with AI. +3. AI Fix customize-launch dialog for `fixPushFailure`. +4. Ordinary push auth/protected/remote failure state: regular remote error, no + Push blocked notice. +5. Sync fetch/pull-stage failure state: Sync-shaped remote error, no Push + blocked notice. +6. Source Control AI hidden for the repo: Push blocked notice and Details, no AI + Fix. +7. Source Control AI settings showing the Push failure fixes launch recipe. +8. Adjacent commit-failure recovery notice still rendering correctly after the + shared UI extraction. + +## Rollout + +1. Tighten shared push-failure classifier/prompt bounds and add the generic + recovery command builder. +2. Make remote formatter/store sync-stage aware and update toast tests. +3. Add the `SourceControlActionError` snapshot/sequence data and one push + recovery derivation helper. +4. Replace duplicated commit/push launchers with the generic recovery launcher + and thin hook wiring. +5. Extract/reuse source-control recovery UI components, including the existing + split-button module. +6. Verify `fixPushFailure` settings/default/normalizer coverage and i18n labels. +7. Update targeted tests and run typecheck, lint, and max-lines ratchet. +8. Validate rendered Electron states with screenshots before opening the PR. + +## Lightweight Eng Review + +- Scope: Reduced from PR #7787's push-specific parallel path to a shared + recovery layer. Keep the feature limited to local pre-push/lint-hook detection, + blocked copy, an inline recovery notice, Details, and AI launch. No persistence + or provider-specific recovery. +- Architecture/data flow: Shared code owns classification, prompt text, and + command-template rendering; the editor store owns stage-aware toast formatting; + `SourceControl` owns transient worktree-scoped failure snapshots and sequence + invalidation; the generic launcher owns local/SSH agent discovery and terminal + launch. +- Failure modes covered: + - Sync stage ambiguity: only the inner push stage may classify as hook-blocked. + - Auth/protected/pre-receive/non-fast-forward/submodule/transport errors stay + out of push recovery. + - Branch or worktree switches suppress stale recovery models. + - Per-worktree sequence tokens prevent older in-flight failures from + overwriting newer retries or successes. + - Empty custom templates, invalid CLI args, unavailable saved agents, missing + worktree context, SSH host detection, and Windows shell planning fail before + terminal launch. + - Large or hostile hook output and file lists are bounded and treated as data + in prompts. +- Test coverage required: + - Shared classifier/prompt/command/action-model tests in `src/shared`. + - Remote formatter and editor-store tests for push, force-push, publish, sync + push-stage, and sync non-push-stage paths. + - Renderer derivation and `CommitArea` tests for Push blocked, Details, AI Fix, + hidden AI actions, stale branch/sequence, ordinary remote errors, and + existing commit failure recovery. + - Generic launcher tests for local/SSH, invalid args, saved-agent unavailable, + empty template, and success launch. + - Settings tests for global and repo Push failure fixes rows. + - Electron validation for all required screenshots; no separate E2E is + required if renderer tests cover the model and Electron validates the real UI. +- Performance/blast radius: No polling, watchers, migrations, or startup work. + Runtime cost is one bounded classifier pass over at most 64 KiB, bounded prompt + output, and a bounded file snapshot only when a remote action fails. Largest + blast radius is the shared recovery UI and launcher used by existing commit + failures, so commit recovery needs tests and a screenshot. +- UI quality bar: Match `docs/STYLEGUIDE.md` and adjacent Source Control density: + token-based error state, stable split-button geometry, progressive Details + disclosure, no nested cards, concise copy, light/dark compatibility, and + visible disabled/loading state under SSH latency. +- Required review screenshots: + 1. Push blocked notice after pre-push hook failure. + 2. Push failure Details dialog with Fix with AI. + 3. `fixPushFailure` customize-launch dialog. + 4. Ordinary push auth/protected/remote failure with no Push blocked panel. + 5. Sync non-push-stage failure with no Push blocked panel. + 6. Source Control AI hidden: Push blocked without AI Fix. + 7. Source Control AI settings showing Push failure fixes. + 8. Existing commit-failure recovery notice after shared UI extraction. +- Residual risks: Classifier false positives remain possible for unusual local + hook-runner output that mentions push context; keep tests biased toward + preserving auth, protected-branch, and non-fast-forward behavior. Failure + panels are renderer-local, so another Orca window or an external terminal can + fix the problem while the old panel remains visible until local retry, remount, + or branch/worktree change. Electron validation may need a mocked or temporary + repo scenario to trigger a real pre-push hook without mutating user data. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ca69fc8a7cd..dbe2fbc02b3 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -2,6 +2,7 @@ import { resolve } from 'node:path' import { defineConfig } from 'electron-vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard' // Why: the telemetry transport is gated by two compile-time constants that // only the official CI release workflow sets. Contributor / `pnpm dev` / @@ -182,13 +183,16 @@ export default defineConfig({ 'stt-worker': resolve('src/main/speech/stt-worker.ts'), 'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'), 'file-watcher-worker': resolve('src/main/runtime/file-watcher-worker.ts'), + // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults + // can't take down the main process (issue #7547). + 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), // Why: electron-vite cleans out/main in dev. The dev CLI imports // this path for `orca agent hooks ...`, so it must survive rebuilds. 'agent-hooks/managed-agent-hook-controls': resolve( 'src/main/agent-hooks/managed-agent-hook-controls.ts' ) }, - plugins: [createStartupDiagnosticsBootstrapPlugin()] + plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()] } }, // Why: compile-time substitution for the telemetry gate. See the block diff --git a/mobile/app.json b/mobile/app.json index b50147f0497..d5844ff527a 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.24", + "version": "0.0.25", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 5 + "versionCode": 6 }, "plugins": [ "expo-router", diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index ad94114bd66..d1c5ebff14a 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -102,6 +102,14 @@ export default function RootLayout() { return } handledNotificationIdsRef.current.add(notificationId) + // Why: RootLayout never unmounts, so cap this tap-dedup set (FIFO) rather + // than letting it grow one id per notification tapped for the app's life. + if (handledNotificationIdsRef.current.size > 256) { + const oldest = handledNotificationIdsRef.current.values().next().value + if (oldest !== undefined) { + handledNotificationIdsRef.current.delete(oldest) + } + } const path = await getNavigationPath(response.notification.request.content.data) clearLastNotificationResponse() @@ -169,6 +177,7 @@ export default function RootLayout() { + diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx new file mode 100644 index 00000000000..cc19497e080 --- /dev/null +++ b/mobile/app/connection-log.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { View, Text, StyleSheet, Pressable, Platform } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import * as Clipboard from 'expo-clipboard' +import Constants from 'expo-constants' +import { ChevronLeft, Copy, Check } from 'lucide-react-native' +import { colors, spacing, typography } from '../src/theme/mobile-theme' +import { ConnectionLog } from '../src/components/ConnectionLog' +import { loadHosts } from '../src/transport/host-store' +import { connectionLogStore } from '../src/transport/connection-log-buffer' +import { + useHostClient, + useLastConnectedAt, + useReconnectAttempt +} from '../src/transport/client-context' +import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report' +import type { ConnectionLogEntry, HostProfile } from '../src/transport/types' + +// Why: getSnapshot must be referentially stable when there's no data — +// a fresh [] per call would make useSyncExternalStore re-render forever. +const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = [] + +// Why: reading the log is most needed while a host is failing, so this +// screen also *acquires* the host client — opening it kicks a dial and the +// log fills live instead of showing a stale tail. +export default function ConnectionLogScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const [hosts, setHosts] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [copied, setCopied] = useState(false) + + useEffect(() => { + let stale = false + void loadHosts().then((loaded) => { + if (stale) { + return + } + setHosts(loaded) + setSelectedId((prev) => prev ?? loaded[0]?.id ?? null) + }) + return () => { + stale = true + } + }, []) + + const selected = hosts.find((h) => h.id === selectedId) ?? null + const { state } = useHostClient(selected?.id) + const reconnectAttempts = useReconnectAttempt(selected?.id) + const lastConnectedAt = useLastConnectedAt(selected?.id) + + const subscribe = useCallback( + (listener: () => void) => + selectedId ? connectionLogStore.subscribe(selectedId, listener) : () => {}, + [selectedId] + ) + const getSnapshot = useCallback( + () => (selectedId ? connectionLogStore.get(selectedId) : EMPTY_ENTRIES), + [selectedId] + ) + const entries = useSyncExternalStore(subscribe, getSnapshot) + + const copyDiagnostics = useCallback(async () => { + if (!selected) { + return + } + const report = buildConnectionDiagnosticsReport({ + hostName: selected.name, + endpoint: selected.endpoint, + state, + reconnectAttempts, + lastConnectedAt, + platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(), + appVersion: Constants.expoConfig?.version ?? 'unknown', + entries + }) + await Clipboard.setStringAsync(report) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [selected, state, reconnectAttempts, lastConnectedAt, entries]) + + return ( + + + router.back()}> + + + Connection log + + + {hosts.length > 1 && ( + + {hosts.map((host) => ( + setSelectedId(host.id)} + > + + {host.name} + + + ))} + + )} + + {selected ? ( + <> + + + {state} + {reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''} + + void copyDiagnostics()}> + {copied ? ( + + ) : ( + + )} + {copied ? 'Copied' : 'Copy diagnostics'} + + + {entries.length > 0 ? ( + + ) : ( + + No connection events yet this session. Events appear as the app dials this host. + + )} + + ) : ( + No paired hosts. + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + hostPicker: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + marginBottom: spacing.md + }, + hostChip: { + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + borderRadius: 16, + backgroundColor: colors.bgRaised + }, + hostChipActive: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + hostChipText: { + fontSize: typography.metaSize, + color: colors.textSecondary, + maxWidth: 160 + }, + hostChipTextActive: { + color: colors.textPrimary, + fontWeight: '600' + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm + }, + statusText: { + fontSize: typography.metaSize, + color: colors.textSecondary + }, + copyButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + 2, + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + borderRadius: 8, + backgroundColor: colors.bgRaised + }, + copyButtonText: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary + }, + emptyText: { + fontSize: typography.metaSize, + color: colors.textMuted, + lineHeight: 18 + } +}) diff --git a/mobile/app/h/[hostId]/history/[worktreeId].tsx b/mobile/app/h/[hostId]/history/[worktreeId].tsx index 8ac0eb8a89b..19198e4e5f1 100644 --- a/mobile/app/h/[hostId]/history/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/history/[worktreeId].tsx @@ -1,207 +1,28 @@ -import { useCallback, useEffect, useState } from 'react' -import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' -import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react-native' -import { useHostClient } from '../../../../src/transport/client-context' -import type { RpcSuccess } from '../../../../src/transport/types' -import { colors, spacing, typography } from '../../../../src/theme/mobile-theme' -import { - fetchMobileGitHistory, - mapMobileCommitRows, - type MobileCommitRow -} from '../../../../src/source-control/mobile-git-history' -import type { GitBranchChangeEntry } from '../../../../../src/shared/types' +import { Redirect, useLocalSearchParams } from 'expo-router' +import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' -function firstParam(value: string | string[] | undefined): string { - return Array.isArray(value) ? (value[0] ?? '') : (value ?? '') -} - -export default function HistoryScreen() { +// History is now a segment of the Source Control hub. This route stays as a thin +// redirect so existing deep links (and any cached navigation) land on the hub with +// the History segment selected. +export default function HistoryRedirect() { const params = useLocalSearchParams<{ hostId?: string | string[] worktreeId?: string | string[] + name?: string | string[] + origin?: string | string[] }>() - const hostId = firstParam(params.hostId) - const worktreeId = firstParam(params.worktreeId) - const router = useRouter() - const insets = useSafeAreaInsets() - const { client, state: connState } = useHostClient(hostId) - - const [rows, setRows] = useState(null) - const [error, setError] = useState(null) - const [expanded, setExpanded] = useState(null) - const [filesById, setFilesById] = useState>({}) - - useEffect(() => { - let active = true - if (!client || connState !== 'connected' || !worktreeId) { - return - } - // Reset prior error/rows so a successful retry doesn't stay stuck behind a - // stale error (error wins render precedence). - setError(null) - setRows(null) - void (async () => { - try { - const result = await fetchMobileGitHistory(client, worktreeId) - if (active) { - setRows(mapMobileCommitRows(result, Date.now())) - } - } catch (err) { - if (active) { - setError(err instanceof Error ? err.message : 'Failed to load history') - } - } - })() - return () => { - active = false - } - }, [client, connState, worktreeId]) - - const toggleCommit = useCallback( - (row: MobileCommitRow) => { - const next = expanded === row.id ? null : row.id - setExpanded(next) - if (next && client && !filesById[row.id]) { - setFilesById((prev) => ({ ...prev, [row.id]: 'loading' })) - void client - .sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id }) - .then((response) => { - const entries = response.ok - ? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries - : [] - setFilesById((prev) => ({ ...prev, [row.id]: entries })) - }) - .catch(() => setFilesById((prev) => ({ ...prev, [row.id]: [] }))) - } - }, - [client, expanded, filesById, worktreeId] - ) - - const renderCommit = useCallback( - ({ item }: { item: MobileCommitRow }) => { - const files = filesById[item.id] - const isOpen = expanded === item.id - return ( - - [styles.commitHeader, pressed && styles.commitHeaderPressed]} - onPress={() => toggleCommit(item)} - > - {isOpen ? ( - - ) : ( - - )} - - - {item.subject} - - - {item.shortId} · {item.author} · {item.relativeTime} - - - - {isOpen ? ( - - {files === 'loading' || files === undefined ? ( - - ) : files.length === 0 ? ( - No file changes - ) : ( - files.map((file) => ( - - - {file.path} - - - {file.added ? +{file.added} : null} - {file.removed ? -{file.removed} : null} - - - )) - )} - - ) : null} - - ) - }, - [expanded, filesById, toggleCommit] - ) - return ( - - - router.back()} accessibilityLabel="Back"> - - - Commit History - - {error ? ( - - {error} - - ) : rows === null ? ( - - - - ) : rows.length === 0 ? ( - - No commits. - - ) : ( - row.id} - contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }} - /> - )} - + ) } - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.bgBase }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - gap: spacing.sm - }, - back: { padding: spacing.xs }, - title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, - state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg }, - stateText: { color: colors.textMuted, fontSize: typography.bodySize }, - commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle }, - commitHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm + 2 - }, - commitHeaderPressed: { backgroundColor: colors.bgRaised }, - commitMain: { flex: 1, minWidth: 0 }, - commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize }, - commitMeta: { - color: colors.textMuted, - fontSize: typography.metaSize, - fontFamily: typography.monoFamily, - marginTop: 2 - }, - files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 }, - fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, - filePath: { - flex: 1, - color: colors.textSecondary, - fontSize: typography.metaSize, - fontFamily: typography.monoFamily - }, - fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily }, - add: { color: colors.gitDecorationAdded }, - del: { color: colors.gitDecorationDeleted }, - empty: { color: colors.textMuted, fontSize: typography.metaSize } -}) diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 16baabe9177..71bda7b64dc 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -552,6 +552,17 @@ export function HostScreen({ } }, [connState, client]) + useFocusEffect( + useCallback(() => { + // Why: opening the host is a strong user signal — reset a backed-off or + // trickling reconnect loop (and probe a possibly half-open socket) + // immediately instead of waiting out its timer. Deps stay empty so this + // fires per focus transition, not per connection-state change; nudging + // on every reconnecting↔connecting flip would defeat the backoff. + clientRef.current?.notifyForeground() + }, []) + ) + useFocusEffect( useCallback(() => { // The embedded sidebar drives its own polling below; focus never fires diff --git a/mobile/app/h/[hostId]/pr/[worktreeId].tsx b/mobile/app/h/[hostId]/pr/[worktreeId].tsx index 2e0530b0f4b..682e4d3481c 100644 --- a/mobile/app/h/[hostId]/pr/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/pr/[worktreeId].tsx @@ -1,30 +1,29 @@ -import { useLocalSearchParams } from 'expo-router' -import { useHostClient } from '../../../../src/transport/client-context' -import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' -import { MobilePrViewPanel } from '../../../../src/components/pr-sidebar/MobilePrViewPanel' - -// Narrow-layout full-screen PR route. The standalone panel can't ride on the review -// screen's diff state, so branch/head SHA are resolved here from git.status + branchCompare. -export default function MobilePrViewScreen() { - const { hostId, worktreeId } = useLocalSearchParams<{ hostId: string; worktreeId: string }>() - const { client, state: connState } = useHostClient(hostId) - const { branch, headSha, status, isGithubRepo, repoLoaded, loaded } = useMobilePrBranchContext({ - client, - connState, - worktreeId - }) +import { Redirect, useLocalSearchParams } from 'expo-router' +import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' +// The Pull Request view is now a segment of the Source Control hub. This route +// stays as a thin redirect so existing deep links land on the hub with the Pull +// Request segment selected. The wide-layout dock opens the same hub with +// initialTab="pr" (SessionDockColumn), not this route. +export default function PrRedirect() { + const params = useLocalSearchParams<{ + hostId?: string | string[] + worktreeId?: string | string[] + name?: string | string[] + origin?: string | string[] + }>() return ( - ) } diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 994798712f4..2043f28e1a9 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -24,6 +24,7 @@ import { AlertTriangle, ArrowUp, Bot, + ChevronDown, ChevronLeft, ChevronRight, ChevronsRight, @@ -61,7 +62,10 @@ import { useReconnectAttempt, useLastConnectedAt } from '../../../../src/transport/client-context' -import { classifyConnection } from '../../../../src/transport/connection-health' +import { + classifyConnection, + verdictDisplayLabel +} from '../../../../src/transport/connection-health' import { useResponsiveLayout } from '../../../../src/layout/responsive-layout' import { type ActivePanel, @@ -72,6 +76,7 @@ import { import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' import { SessionDockColumn } from '../../../../src/session/SessionDockColumn' import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types' +import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result' import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation' import { triggerMediumImpact, @@ -100,6 +105,7 @@ import { isTerminalLiveInputWithinByteLimit, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' +import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' @@ -851,20 +857,16 @@ export default function SessionScreen() { setActivePanel(null) } }, [canDockPanel, activePanel]) - // Session-level PR context feeds the docked PR panel and gates the GitHub-only - // PR entry so GitLab/other providers do not open a GitHub RPC surface. - const { - branch: prBranch, - headSha: prHeadSha, - status: prStatus, - isGithubRepo: prIsGithubRepo, - repoLoaded: prRepoContextLoaded, - loaded: prContextLoaded - } = useMobilePrBranchContext({ - client, - connState, - worktreeId - }) + // Session-level GitHub remote probe gates the PR dock icon so non-GitHub + // providers do not open the hosted-review surface. Branch/head/status for the + // hub are loaded inside MobileSourceControlPanel — skip the unused identity RPCs. + const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } = + useMobilePrBranchContext({ + client, + connState, + worktreeId, + includeBranchIdentity: false + }) useEffect(() => { if (prRepoContextLoaded && !prIsGithubRepo && activePanel === 'pr') { setActivePanel(null) @@ -998,6 +1000,9 @@ export default function SessionScreen() { const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) const deviceTokenRef = useRef(null) + // Why: state (not a ref) — the connection verdict needs a re-render once + // the endpoint loads so the Tailscale hint can appear. + const [hostEndpoint, setHostEndpoint] = useState(null) const clientRef = useRef(null) const connStateRef = useRef(connState) // Why: measured once from TerminalWebView on mount, then passed with every @@ -1006,6 +1011,7 @@ export default function SessionScreen() { const viewportMeasuredRef = useRef(false) const terminalRefs = useRef>(new Map()) const liveInputRef = useRef(null) + const commandInputRef = useRef(null) const liveInputFocusTimerRef = useRef | null>(null) const sendLiveTerminalInputRef = useRef(async () => false) const sessionTabActionSheetKeyboardHideSubRef = useRef h.id === hostId) if (host) { deviceTokenRef.current = host.deviceToken + setHostEndpoint(host.endpoint) } }) return () => { @@ -2684,6 +2691,11 @@ export default function SessionScreen() { timers.push(setTimeout(fn, ms)) } void (async () => { + const reportActivationOutcome = (response: RpcSuccess | null): void => { + if (!disposed && response && headlessActivationNeedsHostRenderer(response.result)) { + showToast('Open Orca on the host to wake sleeping agents.', 3000) + } + } if (client && created !== '1') { // Why: mobile needs host-owned tabs hydrated for this route, but should // not pull other paired clients, especially desktop, into this worktree. @@ -2692,6 +2704,7 @@ export default function SessionScreen() { worktree: `id:${worktreeId}`, notifyClients: false }) + .then((response) => reportActivationOutcome(response.ok ? response : null)) .catch(() => null) } if (disposed) { @@ -2713,12 +2726,13 @@ export default function SessionScreen() { return } void (async () => { - await client + const activationResponse = await client .sendRequest('worktree.activate', { worktree: `id:${worktreeId}`, notifyClients: false }) .catch(() => null) + reportActivationOutcome(activationResponse?.ok ? activationResponse : null) if (disposed) { return } @@ -2734,7 +2748,7 @@ export default function SessionScreen() { clearTimeout(t) } } - }, [client, connState, created, fetchSessionTabs, fetchTerminals, worktreeId]) + }, [client, connState, created, fetchSessionTabs, fetchTerminals, showToast, worktreeId]) useEffect(() => { if (!client || connState !== 'connected') { @@ -3208,6 +3222,15 @@ export default function SessionScreen() { ] ) + const dismissSoftwareKeyboard = useCallback(() => { + dismissTerminalKeyboard({ + clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef), + commandInput: commandInputRef.current, + dismissKeyboard: () => Keyboard.dismiss(), + liveInput: liveInputRef.current + }) + }, []) + const handleTerminalTap = useCallback( (handle: string) => { if (handle !== activeHandleRef.current) { @@ -4253,13 +4276,14 @@ export default function SessionScreen() { void handleCreateTerminal() }, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId]) - // Why: the reconnect loop parks at its give-up cap; without an in-session - // affordance the only recovery is leaving the screen or restarting the - // app (issue #5049). Surface tap-to-retry once the verdict escalates. + // Why: the reconnect loop slows to a 90s trickle at its give-up cap; + // surface tap-to-retry once the verdict escalates so recovery doesn't + // wait out the trickle timer (issue #5049). const connectionVerdict = classifyConnection({ state: connState, reconnectAttempts, - lastConnectedAt + lastConnectedAt, + endpoint: hostEndpoint }) const showConnectionRetry = connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable' @@ -4272,7 +4296,7 @@ export default function SessionScreen() { ? '1 tab' : `${visibleTabs.length} tabs` : showConnectionRetry - ? `${connectionVerdict.label} — tap to retry` + ? `${verdictDisplayLabel(connectionVerdict)} — tap to retry` : MOBILE_SESSION_STATUS_LABELS[connState] // Why: keep safe-area padding in layout at all times, then visually translate @@ -4411,14 +4435,18 @@ export default function SessionScreen() { setActivePanel(action.next) return } + const descriptor = panelRouteDescriptor(action.panel) router.push({ - pathname: panelRouteDescriptor(action.panel).pathname, + pathname: descriptor.pathname, params: { hostId, worktreeId, name: worktreeName || '', - // Source control's post-diff-open dismissal keys off origin: 'session' (U2). - ...(action.panel === 'sourceControl' ? { origin: 'session' } : {}) + // SC + PR both land on the source-control hub; post-diff-open dismissal + // keys off origin: 'session' (U2). Files keeps its own route without origin. + ...(action.panel === 'sourceControl' || action.panel === 'pr' ? { origin: 'session' } : {}), + // The PR panel routes into the hub's Pull Request segment via descriptor params. + ...descriptor.params } }) } @@ -4780,10 +4808,38 @@ export default function SessionScreen() { > {/* Accessory keys */} + {/* Why: a fixed, always-visible escape hatch from the open + keyboard. Kept outside the horizontal ScrollView so it does + not scroll away, and out of the terminal-byte shortcut path so + it cannot be hidden by user shortcut customization (#5106). */} + {keyboardLift > 0 && ( + [ + styles.keyboardDismissKey, + pressed && styles.accessoryKeyPressed + ]} + onPress={dismissSoftwareKeyboard} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Dismiss keyboard" + accessibilityHint="Hides the software keyboard and keeps the current terminal session open." + > + + + + + + )} {/* Why: with default tap handling the first tap on any accessory key dismisses the open keyboard and is swallowed, so live input lost its keyboard on every Esc/Tab press (#5106). */} - setInput((previousText) => normalizeTerminalTextInput(text, previousText)) - } + // Why: iOS kills an active dictation/IME session when JS + // writes a value that differs from the native field text; + // store the raw field text and normalize at send time. + onChangeText={setInput} placeholder="Type a command…" placeholderTextColor={colors.textMuted} autoCapitalize="none" @@ -5074,13 +5132,6 @@ export default function SessionScreen() { hostId={hostId} worktreeId={worktreeId} name={worktreeName || ''} - client={client} - connState={connState} - branch={prBranch} - headSha={prHeadSha} - gitStatus={prStatus} - isGithubRepo={prIsGithubRepo} - branchContextLoaded={prContextLoaded && prRepoContextLoaded} availableWidth={sessionContentRowWidth} onRequestClose={() => setActivePanel(null)} onFileOpenStart={handleFileOpenStart} diff --git a/mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts b/mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts index 9ffd1487d07..1bb852c4218 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts +++ b/mobile/app/h/[hostId]/session/mobile-session-command-input-styles.ts @@ -68,10 +68,16 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({ zIndex: 20 }, accessoryBar: { + flexDirection: 'row', + alignItems: 'center', borderTopWidth: 1, borderTopColor: colors.borderSubtle, backgroundColor: colors.bgPanel }, + accessoryScroll: { + flex: 1, + minWidth: 0 + }, accessoryContent: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, @@ -110,6 +116,29 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({ accessoryKeyTextDisabled: { color: colors.textMuted }, + keyboardDismissKey: { + alignItems: 'center', + justifyContent: 'center', + marginLeft: spacing.sm, + marginVertical: spacing.xs, + backgroundColor: colors.bgRaised, + paddingHorizontal: spacing.sm + 2, + paddingVertical: 0, + borderRadius: radii.button, + minWidth: 36, + height: 28 + }, + keyboardDismissGlyph: { + alignItems: 'center', + height: 18, + justifyContent: 'flex-start', + position: 'relative', + width: 18 + }, + keyboardDismissChevron: { + bottom: -2, + position: 'absolute' + }, inputBar: { flexDirection: 'row', alignItems: 'center', diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx index b9346eecaa5..1de11662dd8 100644 --- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx @@ -1,6 +1,7 @@ import { useLocalSearchParams } from 'expo-router' import { MobileSourceControlPanel } from '../../../../src/source-control/MobileSourceControlPanel' import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' +import { parseSourceControlHubTab } from '../../../../src/source-control/mobile-source-control-hub-tab' export default function MobileSourceControlScreen() { const params = useLocalSearchParams<{ @@ -8,6 +9,7 @@ export default function MobileSourceControlScreen() { worktreeId?: string | string[] name?: string | string[] origin?: string | string[] + tab?: string | string[] }>() return ( ) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index acaf97b7646..eaee0e5f935 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -34,7 +34,7 @@ import { useForceReconnect, usePrimeHosts } from '../src/transport/client-context' -import { classifyConnection } from '../src/transport/connection-health' +import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' import type { ConnectionState, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' @@ -827,7 +827,8 @@ export default function HomeScreen() { const verdict = classifyConnection({ state, reconnectAttempts: attempts, - lastConnectedAt + lastConnectedAt, + endpoint: item.endpoint }) const isError = verdict.kind === 'warning' || @@ -859,7 +860,7 @@ export default function HomeScreen() { - {verdict.label} + {verdictDisplayLabel(verdict)} {connected && info ? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}` : ''} diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index 0a1a63b7c27..5ae53e0084c 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -14,13 +14,9 @@ import { ChevronLeft, ChevronDown, ChevronUp, - WifiOff, - Shield, - Monitor, - Clock, - Globe, Activity, CheckCircle2, + ScrollText, XCircle, AlertTriangle } from 'lucide-react-native' @@ -30,7 +26,12 @@ import { startDiagnosticFetchTimeout, type DiagnosticFetchTimeout } from '../src/diagnostics/diagnostic-fetch-timeout' -import { formatEndpoint, testHostReachability } from '../src/diagnostics/host-reachability' +import { + formatEndpoint, + testHostReachability, + unreachableHostDetail +} from '../src/diagnostics/host-reachability' +import { troubleshootCommonIssues } from '../src/diagnostics/troubleshoot-common-issues' type DiagnosticStatus = 'idle' | 'running' | 'done' @@ -40,66 +41,6 @@ type CheckResult = { detail: string } -type TroubleshootSection = { - id: string - icon: React.ReactNode - title: string - steps: string[] -} - -const sections: TroubleshootSection[] = [ - { - id: 'wifi', - icon: , - title: 'Different WiFi Networks', - steps: [ - 'Both devices must be on the same local network.', - 'Ethernet and WiFi must share the same subnet.', - 'Try reconnecting WiFi on both devices.' - ] - }, - { - id: 'firewall', - icon: , - title: 'Firewall Blocking Port 6768', - steps: [ - 'macOS: System Settings → Network → Firewall — allow Orca.', - 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', - 'Linux: sudo ufw allow 6768', - 'Corporate/school networks may block P2P — try a personal hotspot.' - ] - }, - { - id: 'desktop', - icon: , - title: 'Desktop App Not Running', - steps: [ - 'Orca must be open on your desktop to accept connections.', - 'Try restarting Orca — the companion server starts on launch.', - 'After an update, you may need to re-pair via QR code.' - ] - }, - { - id: 'timeout', - icon: , - title: 'Connection Timeout', - steps: [ - 'Check WiFi signal strength on your phone.', - 'Go back to the host list and tap your host to retry.', - 'Restart both apps if timeouts persist.' - ] - }, - { - id: 'vpn', - icon: , - title: 'VPN Interference', - steps: [ - 'VPNs can route local traffic through a remote server.', - 'Disable the VPN or enable split tunneling / "Allow LAN".' - ] - } -] - function StatusIcon({ status }: { status: CheckResult['status'] }) { switch (status) { case 'pass': @@ -211,7 +152,7 @@ export default function TroubleshootScreen() { status: reachable ? 'pass' : 'fail', detail: reachable ? `Reachable at ${formatEndpoint(host.endpoint)}` - : `Cannot reach ${formatEndpoint(host.endpoint)}` + : unreachableHostDetail(host.endpoint) }) setChecks([...results]) } @@ -273,6 +214,17 @@ export default function TroubleshootScreen() { + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed + ]} + onPress={() => router.push('/connection-log')} + > + + View connection log + + {checks.length > 0 && ( {checks.map((check, i) => ( @@ -295,7 +247,7 @@ export default function TroubleshootScreen() { Common issues - {sections.map((section, i) => ( + {troubleshootCommonIssues.map((section, i) => ( {i > 0 && } + + + + + Orca Mobile – Source Control Hub + + + +
+ +
+ 9:41 + + + + + + + + + + + + + +
+ + +
+ +
+

Source Control

+
make-center-split-more-visible
+
+ +
+ + +
+ + + +
+ + +
+
+ + + + + + + make-center-split-more-visible +
+
+ + ↑2 + ↓0 + + 3 changed · 1 staged +
+
+
+ + + + + + #7701 + Open + + ✓ 12/12 + 💬 3 + + + + + + +
+
+ + +
+ +
+
+ +
+ Staged Changes + 1 + + +
+
+
+ ↩ + Unstage +
+
+ 🗑 + Discard +
+
+ + + + + + M + + + + + + + + EditorFileTab.tsx + + ‎src/renderer/src/components/tab-bar/ + +12−4 + + + + + + + +
+
+ + +
+ Changes + 3 + + +
+
+
+Stage
+
🗑Discard
+
+ + + M + + + + + CenterSplitDivider.tsx + + ‎src/renderer/src/components/split-view/ + +34−8 + + + + +
+
+
+
+Stage
+
🗑Discard
+
+ + + M + + + + + split-view-geometry.ts + + ‎src/renderer/src/lib/ + +6−2 + + + + +
+
+
+
+Stage
+
🗑Discard
+
+ + + A + + + + + SplitDividerHandle.test.tsx + + ‎src/renderer/src/components/split-view/__tests__/ + +48−0 + + + + +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + +
+ 0 selected + + + +
+
+ + +
+
+
+ +
+
+ Open + #7701 + octocat wants to merge into main +
+
Make center split divider more visible
+
+ make-center-split-more-visible → main · updated 20m ago +
+
+ + +
+ 1 check failing + + +
+
+ + + + + e2e / playwright (ubuntu-latest) + Failing · 1 test · 2m 14s + + +
+ + +
+
+ Merge method + Squash and merge + + + + +
+
+
+
Auto-merge when checks pass
+
Merges automatically once required checks are green
+
+
+ +
+
+ +
1 failing check must pass before merging
+
+ + +
+ Unresolved comments + · 3 + + +
+
+ R + + rauchg · 18m ago + Can we clamp the divider width so it doesn't overlap the gutter on narrow + panes? + CenterSplitDivider.tsx:42 + +
+
+ M + + maren · 25m ago + Add a test for the min-width geometry branch. + split-view-geometry.ts:16 + +
+ + +
+ Reviewers +
+
+ O + octocat + Approved +
+
+ D + dstndev + Review requested +
+ + +
+ All checks + · 11 passed · 1 failing + + +
+
+ + + build / typecheckPassed · 1m 02s +
+
+ + + lint / oxlintPassed · 38s +
+
+
+
+ + +
+
+
+
+
+
+
Make center split divider more visible
+
+ a1b2c3d · Jinjing · 18m ago +
+
+
+
+
+
+
Add split-view geometry helper
+
+ 9f8e7d6 · Jinjing · 42m ago +
+
+
+ +
+ 2 commits ahead of origin/main + +
+ +
+
+
+
Merge pull request #7699 from orca/main
+
+ 0f7658d · web-flow · 3h ago +
+
+
+
+
+
+
Update README downloads badge
+
2dad1f0 · Jinjing · 5h ago
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+ + + + diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index 5ee7ca6b4e7..3200dcc5d71 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -15,7 +15,7 @@ const MAX_TABLE_COLUMNS = 8 function openMarkdownUrl(url: string): void { const trimmed = url.trim() if (/^(https?:|mailto:)/i.test(trimmed)) { - void Linking.openURL(trimmed) + void Linking.openURL(trimmed).catch(() => {}) } } diff --git a/mobile/src/components/MobilePRSidebar.tsx b/mobile/src/components/MobilePRSidebar.tsx index 932f72e262a..42829df0310 100644 --- a/mobile/src/components/MobilePRSidebar.tsx +++ b/mobile/src/components/MobilePRSidebar.tsx @@ -39,6 +39,8 @@ type Props = { gitStatus: MobileGitStatusResult | null headSha: string | null bottomInset?: number + // Hub chrome already shows open-on-web; hide the in-body icon there. + showOpenOnWeb?: boolean } // Mutation hooks run unconditionally here and gate internally until a PR is ready. @@ -52,17 +54,15 @@ export function MobilePRSidebar({ gitBranch, gitStatus, headSha, - bottomInset = 0 + bottomInset = 0, + showOpenOnWeb = true }: Props) { const branch = prSidebarRenderBranch(state) // prNumber is 0 until ready; the hook gates on `ready` so it never fires early. const prNumber = state.kind === 'ready' ? state.data.pr.number : 0 - const prRepo = - state.kind === 'ready' - ? state.data.pr.prRepo - ? { owner: state.data.pr.prRepo.owner, repo: state.data.pr.prRepo.repo } - : null - : null + // Prefer the stable PRInfo.prRepo reference — cloning owner/repo each render + // reallocates and thrash-updates the mutation/comment/title hooks. + const prRepo = state.kind === 'ready' ? (state.data.pr.prRepo ?? null) : null const actions = useMobilePrActions({ client, connState, @@ -95,6 +95,9 @@ export function MobilePRSidebar({ style={{ flex: 1 }} contentContainerStyle={[styles.scrollContent, { paddingBottom: bottomInset }]} keyboardShouldPersistTaps="handled" + // Why: root-comment / reply composers sit at the bottom of this scroll + // area; without keyboard insets the focused field stays under the keyboard. + automaticallyAdjustKeyboardInsets showsVerticalScrollIndicator={false} >
) @@ -129,7 +133,8 @@ function PrSidebarContent({ actions, commentActions, titleAction, - triage + triage, + showOpenOnWeb }: { branch: ReturnType state: PrSidebarState @@ -144,6 +149,7 @@ function PrSidebarContent({ commentActions: MobilePrCommentActions titleAction: MobilePrTitleAction triage: MobilePrAiTriage + showOpenOnWeb: boolean }) { if (branch === 'loading') { return ( @@ -209,6 +215,7 @@ function PrSidebarContent({ titleAction={titleAction} triage={triage} refetch={refetch} + showOpenOnWeb={showOpenOnWeb} /> ) } @@ -223,7 +230,8 @@ function PrSidebarSections({ commentActions, titleAction, triage, - refetch + refetch, + showOpenOnWeb }: { data: Extract['data'] client: RpcClient | null @@ -233,6 +241,7 @@ function PrSidebarSections({ titleAction: MobilePrTitleAction triage: MobilePrAiTriage refetch: () => void + showOpenOnWeb: boolean }) { const pr = data.pr // Bind the triage launchers to this PR's data; the prompt builders are pure so @@ -262,19 +271,30 @@ function PrSidebarSections({ isBusy: triage.isBusy('resolve-conflicts'), error: triage.error } + // One card for identity + actions so the ready PR isn't a stack of thin + // duplicate blocks (badge row, title, branches, then another action band). return ( <> - - {/* Conflicting-files section mirrors desktop order: directly below the header, - before actions/checks. Renders only when the PR has merge conflicts. */} + + + + + + + {/* Own titled section when present; null otherwise (no empty chrome). */} - {}) } diff --git a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx index e1b19263f54..f587074e12e 100644 --- a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx +++ b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx @@ -1,13 +1,10 @@ -import { useEffect } from 'react' -import { Pressable, StyleSheet, Text, View } from 'react-native' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter } from 'expo-router' -import { ChevronLeft, X } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../../theme/mobile-theme' +import { StyleSheet, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { colors } from '../../theme/mobile-theme' import type { ConnectionState } from '../../transport/types' import type { RpcClient } from '../../transport/rpc-client' import type { MobileGitStatusResult } from '../../source-control/mobile-git-status' -import { useMobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller' +import type { MobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller' import { MobilePRSidebar } from '../MobilePRSidebar' type Props = { @@ -19,13 +16,13 @@ type Props = { gitStatus: MobileGitStatusResult | null isGithubRepo?: boolean branchContextLoaded?: boolean - // Embedded (docked) drops the full-screen SafeAreaView chrome and shows a close - // affordance; the dock column owns the safe-area insets. Full-screen otherwise. - embedded?: boolean - onRequestClose?: () => void + controller: MobilePrSidebarController } -export function MobilePrViewPanel({ +// Chromeless PR sidebar body for the source-control hub's Pull Request segment. +// The hub owns the header, segmented control, load triggers, and the shared +// controller (one fetch feeds both the branch-card chip and this body). +export function MobilePrViewPanelBody({ client, connState, worktreeId, @@ -34,32 +31,10 @@ export function MobilePrViewPanel({ gitStatus, isGithubRepo = true, branchContextLoaded = true, - embedded = false, - onRequestClose + controller }: Props) { - const router = useRouter() const insets = useSafeAreaInsets() - const controller = useMobilePrSidebarController({ - client, - connState, - worktreeId, - branch, - headSha - }) - // A docked/full-screen PR panel is always visible — there is no drawer to open, - // so trigger the load directly once context is ready rather than gating on the - // showPRSidebar overlay flag (KTD4). - const prSidebarKind = controller.prSidebarState.kind - const refetch = controller.refetchPRSidebar - useEffect(() => { - if (branch && isGithubRepo && prSidebarKind === 'hidden') { - refetch() - } - }, [branch, isGithubRepo, prSidebarKind, refetch]) - - // Embedded: the dock column applies the bottom inset; full-screen relies on its own - // SafeAreaView (edges top only), so content must clear the home indicator itself. const sidebarState = !branchContextLoaded ? ({ kind: 'loading' } as const) : !isGithubRepo @@ -73,63 +48,24 @@ export function MobilePrViewPanel({ message: 'Current branch unavailable.' } as const) : controller.prSidebarState - const sidebar = ( - - ) - - if (embedded) { - return ( - - - - [styles.iconButton, pressed && styles.iconButtonPressed]} - onPress={onRequestClose} - hitSlop={8} - accessibilityLabel="Close pull request panel" - > - - - - Pull Request - - - - {sidebar} - - ) - } return ( - - - - [styles.iconButton, pressed && styles.iconButtonPressed]} - onPress={() => router.back()} - hitSlop={8} - accessibilityLabel="Back to session" - > - - - - Pull Request - - - - {sidebar} - + + + ) } @@ -137,34 +73,5 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgBase - }, - header: { - backgroundColor: colors.bgPanel, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle - }, - topBar: { - minHeight: 58, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - paddingHorizontal: spacing.md - }, - iconButton: { - width: 36, - height: 36, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button - }, - iconButtonPressed: { - backgroundColor: colors.bgRaised - }, - title: { - flex: 1, - minWidth: 0, - color: colors.textPrimary, - fontSize: typography.titleSize, - fontWeight: '600' } }) diff --git a/mobile/src/components/pr-sidebar/PRActionsSection.tsx b/mobile/src/components/pr-sidebar/PRActionsSection.tsx index 86b910a08ca..5fae6803889 100644 --- a/mobile/src/components/pr-sidebar/PRActionsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRActionsSection.tsx @@ -7,7 +7,6 @@ import type { RpcClient } from '../../transport/rpc-client' import type { MobilePrActions } from '../../session/use-mobile-pr-actions' import { unlinkMobilePr } from '../../source-control/mobile-pr-link' import { ConfirmModal } from '../ConfirmModal' -import { PRSection } from './PRSection' import { canShowMobilePRAutoMergeControl } from './pr-auto-merge-availability' import { resolveMobilePrMergeMethod, resolvePrActionAvailability } from './pr-actions-state' import { prActionsStyles as styles } from './pr-actions-styles' @@ -25,12 +24,13 @@ type Confirm = | { kind: 'merge'; method: GitHubPRMergeMethod } | { kind: 'state'; state: 'open' | 'closed' } -// Merge, auto-merge toggle, and close/reopen. Destructive actions route through -// ConfirmModal first (R5). The firing row shows a spinner in place of its icon -// and disables; other rows stay interactive (uniform visual). +// Merge primary; Close/Reopen + Unlink share one secondary row. No section title — +// button labels are self-explanatory and a header wasted a full row on mobile. export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }: Props) { const [confirm, setConfirm] = useState(null) const [unlinking, setUnlinking] = useState(false) + // Local unlink errors — unlink is not routed through the actions engine. + const [unlinkError, setUnlinkError] = useState(null) // Mobile keeps merge one-tap: use the repo default instead of surfacing a // desktop-style method picker in the narrow PR action stack. @@ -41,23 +41,31 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } const mergeBusy = actions.isBusy({ kind: 'merge' }) const autoMergeBusy = actions.isBusy({ kind: 'autoMerge' }) const stateBusy = actions.isBusy({ kind: 'state' }) + const unlinkBusy = unlinking || mergeBusy || autoMergeBusy || stateBusy const showAutoMerge = avail.canAutoMerge && canShowMobilePRAutoMergeControl({ ...pr, autoMergeEnabled: autoMerge || pr.autoMergeEnabled === true }) + const showSecondary = avail.canClose || avail.canReopen || avail.canUnlink + const actionError = unlinkError ?? actions.error const unlink = useCallback(async (): Promise => { if (!client || unlinking) { return } setUnlinking(true) + setUnlinkError(null) try { const outcome = await unlinkMobilePr(client, worktreeId) if (outcome.ok) { onUnlinked() + } else { + setUnlinkError(outcome.error) } + } catch (err) { + setUnlinkError(err instanceof Error ? err.message : 'Failed to unlink pull request.') } finally { setUnlinking(false) } @@ -89,6 +97,8 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } if (!confirm) { return } + // Engine errors take over the shared error line after this; drop unlink text. + setUnlinkError(null) if (confirm.kind === 'merge') { actions.merge(confirm.method) } else { @@ -99,8 +109,7 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } const copy = confirmCopy() return ( - - {/* Merge controls only while the PR can still be merged (open/draft). */} + {avail.canMerge ? ( setConfirm({ kind: 'merge', method: effectiveMethod })} + onPress={() => { + setUnlinkError(null) + setConfirm({ kind: 'merge', method: effectiveMethod }) + }} disabled={mergeBusy} accessibilityRole="button" accessibilityLabel="Merge pull request" @@ -124,13 +136,15 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } ) : null} - {/* Auto-merge toggle — optimistic, reverts on transient failure. */} {showAutoMerge ? ( Auto-merge when ready actions.setAutoMerge(!autoMerge, effectiveMethod)} + onPress={() => { + setUnlinkError(null) + actions.setAutoMerge(!autoMerge, effectiveMethod) + }} disabled={autoMergeBusy} accessibilityRole="switch" accessibilityState={{ checked: autoMerge }} @@ -147,49 +161,59 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked } ) : null} - {/* Close (open PRs) / Reopen (closed PRs) — confirmed before firing (R5). */} - {avail.canClose || avail.canReopen ? ( - setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })} - disabled={stateBusy} - accessibilityRole="button" - accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'} - > - {stateBusy ? : null} - - {avail.canClose ? 'Close' : 'Reopen'} - - + {showSecondary ? ( + + {avail.canClose || avail.canReopen ? ( + { + setUnlinkError(null) + setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' }) + }} + disabled={stateBusy} + accessibilityRole="button" + accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'} + > + {stateBusy ? : null} + + {avail.canClose ? 'Close' : 'Reopen'} + + + ) : null} + {avail.canUnlink ? ( + void unlink()} + disabled={unlinkBusy} + accessibilityRole="button" + accessibilityLabel="Unlink pull request" + > + {unlinking ? ( + + ) : ( + + )} + Unlink + + ) : null} + ) : null} - {/* Unlink the PR from this worktree. Disabled while another PR mutation is in - flight so clearing the link can't race a merge/close refetch. */} - {avail.canUnlink ? ( - void unlink()} - disabled={unlinking || mergeBusy || autoMergeBusy || stateBusy} - accessibilityRole="button" - accessibilityLabel="Unlink pull request" - > - {unlinking ? ( - - ) : ( - - )} - Unlink - - ) : null} + {actionError ? {actionError} : null} - {actions.error ? {actions.error} : null} - - {/* A Modal is taken out of the flex flow, so it adds no body gap here. */} setConfirm(null)} /> - +
) } diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx index 051f1392e9d..b6c0a1009d6 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx @@ -5,6 +5,7 @@ import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/type import type { GitHubPrRepoSlug } from '../../session/github-pr-rpc' import { colors } from '../../theme/mobile-theme' import { canAddRootComment } from '../../session/pr-comment-actions' +import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state' import type { MobilePrCommentActions } from '../../session/use-mobile-pr-comment-actions' import { PRSection } from './PRSection' import { CommentMarkdown } from './CommentMarkdown' @@ -50,10 +51,15 @@ const COMMENT_PAGE = 12 // review comments, reactions, and collapsible resolved threads. export function PRCommentsSection({ details, prState, prRepo, actions }: Props) { // details is null while phase 2 (the heavy comments/body payload) is still loading. + // A synthetic placeholder means phase 2 failed — do not paint that as empty success. const loadingDetails = details === null + const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details) const body = details?.body ?? '' - const comments = useMemo(() => details?.comments ?? [], [details]) - const isPr = details?.item.type === 'pr' + const comments = useMemo( + () => (details && !isPrSidebarDetailsPlaceholder(details) ? details.comments : []), + [details] + ) + const isPr = details != null && !detailsFailed && details.item.type === 'pr' // Per-card action bundle (stable callbacks from the hook) — built once so the // memo'd cards don't re-render on unrelated timeline changes. @@ -94,6 +100,10 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props) {loadingDetails ? ( + ) : detailsFailed ? ( + + Could not load description. Tap refresh to try again. + ) : body.trim() ? ( ) : ( @@ -113,6 +123,8 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props) > {loadingDetails ? ( + ) : detailsFailed ? ( + Could not load comments. Tap refresh to try again. ) : ( {comments.length === 0 ? ( diff --git a/mobile/src/components/pr-sidebar/PRReviewersSection.tsx b/mobile/src/components/pr-sidebar/PRReviewersSection.tsx index ad16241c206..bc09ecd3502 100644 --- a/mobile/src/components/pr-sidebar/PRReviewersSection.tsx +++ b/mobile/src/components/pr-sidebar/PRReviewersSection.tsx @@ -5,6 +5,7 @@ import { colors } from '../../theme/mobile-theme' import type { GitHubWorkItemDetails } from '../../../../src/shared/types' import type { RpcClient } from '../../transport/rpc-client' import type { MobilePrActions } from '../../session/use-mobile-pr-actions' +import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state' import { getPRReviewerRows } from './pr-checks-presentation' import { ReviewerPickerDrawer } from './ReviewerPickerDrawer' import { PRSection } from './PRSection' @@ -20,8 +21,17 @@ type Props = { // Requested reviewers + their latest review status, with a picker to request / // remove (optimistic add/remove via the actions hook). export function PRReviewersSection({ details, actions, client, worktreeId }: Props) { + // details === null means phase 2 (work-item payload) is still in flight — same + // signal Comments uses. Do not treat that as "no reviewers" or the section goes + // blank while checks (phase 1) already paint. A synthetic placeholder means + // phase 2 failed (no body/comments/reviews landed). + const loadingDetails = details === null + const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details) const authoritativeRows = useMemo( - () => (details?.item ? getPRReviewerRows(details.item) : []), + () => + details?.item && !isPrSidebarDetailsPlaceholder(details) + ? getPRReviewerRows(details.item) + : [], [details] ) const [pickerOpen, setPickerOpen] = useState(false) @@ -46,21 +56,27 @@ export function PRReviewersSection({ details, actions, client, worktreeId }: Pro return author ? [author, ...logins] : logins }, [authoritativeRows, details]) - return ( - setPickerOpen(true)} - accessibilityRole="button" - accessibilityLabel="Add or remove reviewers" - > - - - } + const addButton = ( + setPickerOpen(true)} + accessibilityRole="button" + accessibilityLabel="Add or remove reviewers" > - {rows.length === 0 ? ( + + + ) + + return ( + + {loadingDetails ? ( + + + Loading reviewers… + + ) : detailsFailed ? ( + Could not load reviewers. Tap refresh to try again. + ) : rows.length === 0 ? ( No reviewers requested ) : ( rows.map((row) => { diff --git a/mobile/src/components/pr-sidebar/PRSection.tsx b/mobile/src/components/pr-sidebar/PRSection.tsx index a573258d41a..0ed5ce90416 100644 --- a/mobile/src/components/pr-sidebar/PRSection.tsx +++ b/mobile/src/components/pr-sidebar/PRSection.tsx @@ -3,22 +3,28 @@ import { Text, View } from 'react-native' import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles' type Props = { - title: string + // Optional: omit for self-explanatory sections (e.g. action buttons) so the + // header row doesn't waste vertical space on mobile. + title?: string // Optional trailing control(s) in the header row (e.g. add-reviewer, checks // summary + rerun). Rendered right-aligned opposite the title. trailing?: ReactNode children: ReactNode } -// Shared card shell for the titled PR sections (Actions/Reviewers/Checks). Mirrors -// the desktop PR page's card-with-header-divider so the sections read consistently. +// Shared card shell for PR sections (Actions/Reviewers/Checks). Mirrors the +// desktop PR page's card-with-header-divider so the sections read consistently. +// Header is omitted when neither title nor trailing is provided. export function PRSection({ title, trailing, children }: Props) { + const showHeader = Boolean(title) || trailing != null return ( - - {title} - {trailing ? {trailing} : null} - + {showHeader ? ( + + {title ? {title} : null} + {trailing ? {trailing} : null} + + ) : null} {children} ) diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx index e2f31f3cc41..8a399babe78 100644 --- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx +++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' -import { ArrowRight, Pencil } from 'lucide-react-native' +import { ArrowRight, ExternalLink, Pencil } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' import type { GitHubWorkItemDetails, PRInfo } from '../../../../src/shared/types' import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-action' @@ -16,11 +16,21 @@ type Props = { details: GitHubWorkItemDetails | null // Inline title-edit action; the pencil affordance only shows when the PR is editable. titleAction: MobilePrTitleAction + // Hub chrome already surfaces open-on-web; hide the duplicate icon in that case. + showOpenOnWeb?: boolean + // When true, render without section chrome so identity can share a card with actions. + bare?: boolean } -// Header: state badge (incl. draft — display-only), title, author, head->base. -// The title is inline-editable on an open/draft PR (desktop parity). -export function PRSidebarHeader({ pr, details, titleAction }: Props) { +// Compact identity: state + # + author on one meta row, title, head→base. +// # lives only in the meta row (not also after the title) to avoid repetition. +export function PRSidebarHeader({ + pr, + details, + titleAction, + showOpenOnWeb = true, + bare = false +}: Props) { const item = details?.item const badge = prStateBadge(pr.state) const badgeColor = statusColor(badge.token) @@ -29,58 +39,79 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) { const baseRef = item?.baseRefName ?? null const headRef = item?.branchName ?? null const editable = canEditPRTitle(pr.state) - // Tapping the state badge or the #number opens the PR on its host (GitHub/etc.) - // in the phone browser — pr.url is the canonical web URL. const openPr = pr.url ? () => openMobilePrUrl(pr.url) : undefined - return ( - - - [ - styles.badge, - { borderColor: badgeColor }, - pressed && { opacity: 0.6 } - ]} - > - {badge.label} - - - {author ? by {author} : null} - {baseRef && headRef ? ( - // head -> base reads in merge direction (desktop ChecksPanel parity). - - {headRef} - - {baseRef} - + const body = ( + <> + + + [ + styles.badge, + { borderColor: badgeColor }, + pressed && { opacity: 0.6 } + ]} + > + {badge.label} + + + #{pr.number} + + {author ? · {author} : null} + + {showOpenOnWeb && openPr ? ( + [styles.iconButton, pressed && { opacity: 0.6 }]} + > + + ) : null} + + {baseRef && headRef ? ( + + + {headRef} + + + + {baseRef} + + + ) : null} + + ) + + if (bare) { + return {body} + } + return ( + + {body} ) } function PRTitle({ title, - number, editable, - openPr, titleAction }: { title: string - number: number editable: boolean - openPr: (() => void) | undefined titleAction: MobilePrTitleAction }) { const [editing, setEditing] = useState(false) @@ -106,7 +137,7 @@ function PRTitle({ if (editing) { return ( - + - - {title}{' '} - - #{number} - - + {title} {editable ? ( diff --git a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx index 97d89a34b3e..8a3a161f358 100644 --- a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx +++ b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { ActivityIndicator, FlatList, Pressable, Text, TextInput, View } from 'react-native' +import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' import { Check } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' import type { GitHubAssignableUser } from '../../../../src/shared/types' @@ -26,9 +26,10 @@ type LoadState = | { status: 'error'; message: string } | { status: 'loaded'; users: GitHubAssignableUser[] } -// A search + FlatList of github.listAssignableUsers in a BottomDrawer (not a new -// primitive). Seeded with already-requested/latest reviewers + author at the top. -// Optimistic add/remove via onToggle. +// Searchable assignable-user list in a BottomDrawer. Mapped rows (not FlatList): +// this drawer is opened from the PR ScrollView, and a VirtualizedList nested in +// that ScrollView throws and can leave the Reviewers section blank. +// Seeded reviewers + author sort first for quick un-request. export function ReviewerPickerDrawer({ visible, onClose, @@ -47,16 +48,22 @@ export function ReviewerPickerDrawer({ } let cancelled = false setLoad({ status: 'loading' }) - void fetchAssignableUsers(client, worktreeId).then((outcome) => { - if (cancelled) { - return - } - setLoad( - outcome.ok - ? { status: 'loaded', users: outcome.result } - : { status: 'error', message: outcome.error } - ) - }) + void fetchAssignableUsers(client, worktreeId) + .then((outcome) => { + if (cancelled) { + return + } + setLoad( + outcome.ok + ? { status: 'loaded', users: outcome.result } + : { status: 'error', message: outcome.error } + ) + }) + .catch(() => { + if (!cancelled) { + setLoad({ status: 'error', message: 'Failed to load people' }) + } + }) return () => { cancelled = true } @@ -107,15 +114,12 @@ export function ReviewerPickerDrawer({ No matching people ) : ( - u.login} - keyboardShouldPersistTaps="handled" - renderItem={({ item }) => { + + {ordered.map((item) => { const requested = isRequested(item.login) return ( onToggle(item.login)} accessibilityRole="button" @@ -134,8 +138,8 @@ export function ReviewerPickerDrawer({ ) - }} - /> + })} + )} ) diff --git a/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts b/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts index c3f0ad68581..edf308350e4 100644 --- a/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts +++ b/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts @@ -25,8 +25,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({ section: { backgroundColor: colors.bgPanel, borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle, - overflow: 'hidden' + borderBottomColor: colors.borderSubtle }, // Section header row: title + optional trailing control, divided from the body // by a hairline border (desktop `h-10 border-b px-3`). @@ -55,7 +54,25 @@ export const mobilePrSidebarStyles = StyleSheet.create({ fontSize: 13, fontWeight: '600' }, - // Header section: state badge, title, author, base<-head branches. + // Identity + actions share one section card (hub redesign): no per-block chrome. + identityBlock: { + gap: spacing.sm + }, + // State badge + #number + author on one row; open-on-web flush right when shown. + metaRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm + }, + metaLeft: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: spacing.xs + }, badge: { alignSelf: 'flex-start', paddingHorizontal: spacing.sm, @@ -69,6 +86,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({ fontWeight: '700' }, prTitle: { + flex: 1, color: colors.textPrimary, fontSize: typography.titleSize, fontWeight: '700', @@ -78,6 +96,12 @@ export const mobilePrSidebarStyles = StyleSheet.create({ color: colors.textSecondary, fontSize: typography.metaSize }, + // #number in the meta row — stronger than author so the identity scans first. + prMetaStrong: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '600' + }, // Title row: tappable area pairing the title with a trailing edit affordance. titleRow: { flexDirection: 'row', @@ -97,10 +121,11 @@ export const mobilePrSidebarStyles = StyleSheet.create({ gap: spacing.xs }, branchPill: { + flexShrink: 1, color: colors.textPrimary, fontSize: typography.metaSize, fontFamily: typography.monoFamily, - backgroundColor: colors.bgPanel, + backgroundColor: colors.bgRaised, paddingHorizontal: spacing.xs, paddingVertical: 2, borderRadius: radii.button @@ -139,6 +164,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({ color: colors.textSecondary, fontSize: typography.metaSize }, + // Loading / empty status row inside Reviewers (and similar) section bodies. + reviewersStatus: { + minHeight: 44, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, summaryLabel: { fontSize: typography.bodySize, fontWeight: '700' @@ -267,8 +299,10 @@ export const mobilePrSidebarStyles = StyleSheet.create({ fontSize: typography.bodySize, marginBottom: spacing.sm }, + // No maxHeight / FlatList: the parent BottomDrawer scrolls this block so we + // never nest a VirtualizedList inside the PR page ScrollView. pickerList: { - maxHeight: 320 + gap: 0 }, pickerRow: { minHeight: 44, diff --git a/mobile/src/components/pr-sidebar/pr-actions-styles.ts b/mobile/src/components/pr-sidebar/pr-actions-styles.ts index 865516e3468..6d89e75bf98 100644 --- a/mobile/src/components/pr-sidebar/pr-actions-styles.ts +++ b/mobile/src/components/pr-sidebar/pr-actions-styles.ts @@ -5,6 +5,19 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme' // line). Split out of mobile-pr-sidebar-styles to keep that file under the // 300-line cap. export const prActionsStyles = StyleSheet.create({ + // Bare block when identity + actions share one section card. + actionsBlock: { + gap: spacing.sm + }, + // Close/Reopen + Unlink share a row so secondary actions don't stack full-width. + secondaryRow: { + flexDirection: 'row', + alignItems: 'stretch', + gap: spacing.sm + }, + secondaryButton: { + flex: 1 + }, // Primary CTA (merge) and secondary action buttons (close/reopen/rerun/add). actionButton: { minHeight: 44, diff --git a/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts b/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts index 3d44a9e0cc2..e6cb4966ca2 100644 --- a/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts +++ b/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts @@ -5,7 +5,8 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme' // match the PR comment timeline; split out to keep PRCommentComposer focused. export const prCommentComposerStyles = StyleSheet.create({ container: { - gap: spacing.sm + // Input → Cancel/Save needs clear separation (title edit was flush without this). + gap: spacing.md }, input: { minHeight: 64, diff --git a/mobile/src/diagnostics/connection-diagnostics-report.test.ts b/mobile/src/diagnostics/connection-diagnostics-report.test.ts new file mode 100644 index 00000000000..d2326ffa022 --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-report.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report' + +const NOW = Date.UTC(2026, 6, 9, 22, 0, 0) + +describe('buildConnectionDiagnosticsReport', () => { + it('summarizes a failing Tailscale host with its log', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 1', + endpoint: 'ws://100.65.9.106:6768', + state: 'reconnecting', + reconnectAttempts: 12, + lastConnectedAt: NOW - 5 * 60_000, + platform: 'ios 26.5.1', + appVersion: '0.0.29', + entries: [ + { + id: 'log-1', + ts: NOW - 60_000, + level: 'error', + message: 'WebSocket connect timeout', + detail: 'No TCP/WS handshake within 12s — endpoint unreachable?' + } + ], + nowMs: NOW + }) + + expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1') + expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)') + expect(report).toContain('State: reconnecting (reconnect attempts: 12)') + expect(report).toContain('(5m 0s ago)') + expect(report).toContain( + '[error] WebSocket connect timeout — No TCP/WS handshake within 12s — endpoint unreachable?' + ) + }) + + it('marks never-connected sessions and empty logs', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 2', + endpoint: 'ws://192.168.1.50:6768', + state: 'connecting', + reconnectAttempts: 0, + lastConnectedAt: null, + platform: 'android 15', + appVersion: '0.0.29', + entries: [], + nowMs: NOW + }) + + expect(report).toContain('Endpoint: 192.168.1.50:6768') + expect(report).not.toContain('(Tailscale)') + expect(report).toContain('Last connected: never this session') + expect(report).toContain('No connection events recorded this session.') + }) +}) diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts new file mode 100644 index 00000000000..c762c69799e --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-report.ts @@ -0,0 +1,58 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' +import type { ConnectionLogEntry, ConnectionState } from '../transport/types' +import { formatEndpoint } from './host-reachability' + +// Why: one shareable text blob answering everything we historically had to +// ask reporters one message at a time (endpoint type, state, attempt count, +// last-connected, versions, and the reconnect lifecycle log). +export function buildConnectionDiagnosticsReport(args: { + hostName: string + endpoint: string + state: ConnectionState + reconnectAttempts: number + lastConnectedAt: number | null + platform: string + appVersion: string + entries: readonly ConnectionLogEntry[] + nowMs?: number +}): string { + const now = args.nowMs ?? Date.now() + const lines: string[] = [] + lines.push('Orca Mobile connection diagnostics') + lines.push(`Generated: ${new Date(now).toISOString()}`) + lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`) + lines.push(`Host: ${args.hostName}`) + lines.push( + `Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}` + ) + lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`) + lines.push( + args.lastConnectedAt == null + ? 'Last connected: never this session' + : `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)` + ) + lines.push('') + if (args.entries.length === 0) { + lines.push('No connection events recorded this session.') + } else { + lines.push(`Connection log (${args.entries.length} events, oldest first):`) + for (const entry of args.entries) { + const detail = entry.detail ? ` — ${entry.detail}` : '' + lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`) + } + } + return lines.join('\n') +} + +function formatAgo(ms: number): string { + const seconds = Math.max(0, Math.round(ms / 1000)) + if (seconds < 60) { + return `${seconds}s` + } + const minutes = Math.floor(seconds / 60) + if (minutes < 60) { + return `${minutes}m ${seconds % 60}s` + } + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m` +} diff --git a/mobile/src/diagnostics/host-reachability.test.ts b/mobile/src/diagnostics/host-reachability.test.ts index 9b2933a4e0f..d20bc8c28cf 100644 --- a/mobile/src/diagnostics/host-reachability.test.ts +++ b/mobile/src/diagnostics/host-reachability.test.ts @@ -1,5 +1,27 @@ import { describe, expect, it, vi } from 'vitest' -import { testHostReachability } from './host-reachability' +import { testHostReachability, unreachableHostDetail } from './host-reachability' + +describe('unreachableHostDetail', () => { + it('points at Tailscale for tailnet CGNAT endpoints', () => { + expect(unreachableHostDetail('ws://100.65.9.106:6768')).toBe( + 'Cannot reach 100.65.9.106:6768 — check Tailscale' + ) + }) + + it('points at Tailscale for MagicDNS endpoints', () => { + expect(unreachableHostDetail('ws://my-desktop.tailnet-1234.ts.net:6768')).toBe( + 'Cannot reach my-desktop.tailnet-1234.ts.net:6768 — check Tailscale' + ) + }) + + it('stays generic for LAN endpoints', () => { + expect(unreachableHostDetail('ws://192.168.1.50:6768')).toBe('Cannot reach 192.168.1.50:6768') + }) + + it('does not treat non-CGNAT 100.x addresses as Tailscale', () => { + expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768') + }) +}) describe('testHostReachability', () => { it('returns false without leaving timers when WebSocket rejects a malformed endpoint', async () => { diff --git a/mobile/src/diagnostics/host-reachability.ts b/mobile/src/diagnostics/host-reachability.ts index 7ae292126e8..495d65a9764 100644 --- a/mobile/src/diagnostics/host-reachability.ts +++ b/mobile/src/diagnostics/host-reachability.ts @@ -1,3 +1,5 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' + const HOST_REACHABILITY_TIMEOUT_MS = 4000 // Why: troubleshooting needs a cheap endpoint probe without completing the @@ -56,3 +58,13 @@ export function formatEndpoint(endpoint: string): string { return endpoint } } + +// Why: an unreachable 100.x/*.ts.net host almost always means the phone's +// Tailscale tunnel is down or wedged (known iOS failure mode, fixed by +// toggling the VPN) — point at that instead of a bare "Cannot reach". +export function unreachableHostDetail(endpoint: string): string { + if (isTailscaleEndpoint(endpoint)) { + return `Cannot reach ${formatEndpoint(endpoint)} — check Tailscale` + } + return `Cannot reach ${formatEndpoint(endpoint)}` +} diff --git a/mobile/src/diagnostics/troubleshoot-common-issues.tsx b/mobile/src/diagnostics/troubleshoot-common-issues.tsx new file mode 100644 index 00000000000..5dc91eb65fc --- /dev/null +++ b/mobile/src/diagnostics/troubleshoot-common-issues.tsx @@ -0,0 +1,73 @@ +import { WifiOff, Shield, Monitor, Clock, Globe } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' + +export type TroubleshootSection = { + id: string + icon: React.ReactNode + title: string + steps: string[] +} + +export const troubleshootCommonIssues: TroubleshootSection[] = [ + { + id: 'wifi', + icon: , + title: 'Different WiFi Networks', + steps: [ + 'Both devices must be on the same local network (unless connected through Tailscale).', + 'Ethernet and WiFi must share the same subnet.', + 'Try reconnecting WiFi on both devices.' + ] + }, + { + id: 'firewall', + icon: , + title: 'Firewall Blocking Port 6768', + steps: [ + 'macOS: System Settings → Network → Firewall — allow Orca.', + 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', + 'Linux: sudo ufw allow 6768', + 'Corporate/school networks may block P2P — try a personal hotspot.' + ] + }, + { + id: 'desktop', + icon: , + title: 'Desktop App Not Running', + steps: [ + 'Orca must be open on your desktop to accept connections.', + 'Try restarting Orca — the companion server starts on launch.', + 'After an update, you may need to re-pair via QR code.' + ] + }, + { + id: 'timeout', + icon: , + title: 'Connection Timeout', + steps: [ + 'Check WiFi signal strength on your phone.', + 'Go back to the host list and tap your host to retry.', + 'Restart both apps if timeouts persist.' + ] + }, + { + id: 'tailscale', + icon: , + title: 'Tailscale Host Unreachable', + steps: [ + 'Host addresses like 100.x.x.x or *.ts.net connect through Tailscale — keep it ON.', + 'iOS/Android can silently wedge the tunnel: toggle Tailscale off and back on in the Tailscale app.', + 'Check the desktop is awake and shows as connected in your tailnet.', + 'Update the Tailscale app — recent releases fix reconnect bugs.' + ] + }, + { + id: 'vpn', + icon: , + title: 'Other VPN Interference', + steps: [ + 'Non-Tailscale VPNs can route local traffic through a remote server.', + 'Disable that VPN or enable split tunneling / "Allow LAN".' + ] + } +] diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts index 46eab52ec92..7ae4315086b 100644 --- a/mobile/src/notifications/mobile-notifications.test.ts +++ b/mobile/src/notifications/mobile-notifications.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import * as Notifications from 'expo-notifications' -import { subscribeToDesktopNotifications } from './mobile-notifications' +import { + setScheduledNotificationsMaxForTests, + subscribeToDesktopNotifications +} from './mobile-notifications' import type { RpcClient } from '../transport/rpc-client' import { loadPushNotificationsEnabled } from '../storage/preferences' @@ -268,4 +271,48 @@ describe('subscribeToDesktopNotifications', () => { expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled() }) + + // Why: notificationId is unique per completion, so the map grew unbounded when + // the desktop never sent a dismiss (the remote-mobile case). It is now capped. + it('evicts the oldest scheduled entry once the cap is exceeded', async () => { + setScheduledNotificationsMaxForTests(1) + try { + vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true) + vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({ + status: 'granted', + canAskAgain: true + } as never) + vi.mocked(Notifications.scheduleNotificationAsync) + .mockResolvedValueOnce('scheduled-old') + .mockResolvedValueOnce('scheduled-new') + vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined) + let onEvent: ((data: unknown) => void) | null = null + const client = { + subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => { + onEvent = callback + return vi.fn() + }), + getState: vi.fn(() => 'connected'), + sendRequest: vi.fn() + } as unknown as RpcClient + + subscribeToDesktopNotifications(client, 'host-1') + onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:old' }) + await flushAsync() + onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' }) + await flushAsync() + + // The older entry was evicted by the cap: dismissing it is a no-op... + onEvent?.({ type: 'dismiss', notificationId: 'agent:old' }) + await flushAsync() + expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old') + + // ...while the most-recent entry is retained and still dismissable. + onEvent?.({ type: 'dismiss', notificationId: 'agent:new' }) + await flushAsync() + expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new') + } finally { + setScheduledNotificationsMaxForTests() + } + }) }) diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts index 83abea6fb8a..9ab936f75c6 100644 --- a/mobile/src/notifications/mobile-notifications.ts +++ b/mobile/src/notifications/mobile-notifications.ts @@ -31,10 +31,42 @@ type ScheduledNotificationState = { const scheduledNotificationsByHostAndNotificationId = new Map() +// Why: notificationId embeds a per-completion timestamp (buildAgentNotificationId), +// so every agent-task-complete inserts a new, never-reused key. Entries are only +// removed when the desktop sends a matching dismiss — which a remote mobile user +// (not at the desktop) frequently never gets — so the map grew for the app's whole +// life. Bound it; a settled entry only retains a small identifier used for later +// programmatic dismissal, unnecessary for long-past completions. +const MAX_SCHEDULED_NOTIFICATIONS = 256 +let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS + function getStoredNotificationKey(hostId: string, notificationId: string): string { return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}` } +// Evict the oldest SETTLED entries (never one mid-schedule) until within the cap. +// Map iteration is insertion order, so the first match is the oldest. +function boundScheduledNotifications(): void { + while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) { + let evicted = false + for (const [key, state] of scheduledNotificationsByHostAndNotificationId) { + if (!state.pending) { + scheduledNotificationsByHostAndNotificationId.delete(key) + evicted = true + break + } + } + if (!evicted) { + break + } + } +} + +/** Test-only: override the cap (pass no arg to restore the default). */ +export function setScheduledNotificationsMaxForTests(max?: number): void { + maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS +} + export type NotificationPermissionState = { granted: boolean status: string @@ -155,6 +187,7 @@ async function showLocalNotification(event: NotificationEvent, hostId: string): return } notificationState.identifier = scheduledIdentifier + boundScheduledNotifications() } finally { if (notificationState.pending === pending) { notificationState.pending = undefined diff --git a/mobile/src/session/SessionDockColumn.tsx b/mobile/src/session/SessionDockColumn.tsx index f02e89f7329..2f7ae6b5ca3 100644 --- a/mobile/src/session/SessionDockColumn.tsx +++ b/mobile/src/session/SessionDockColumn.tsx @@ -1,27 +1,16 @@ import { memo } from 'react' import { View, StyleSheet } from 'react-native' -import type { ConnectionState } from '../transport/types' -import type { RpcClient } from '../transport/rpc-client' import { MobileSourceControlPanel } from '../source-control/MobileSourceControlPanel' import { MobileFileExplorerPanel } from '../files/MobileFileExplorerPanel' -import { MobilePrViewPanel } from '../components/pr-sidebar/MobilePrViewPanel' import { mobilePrSidebarStyles } from '../components/pr-sidebar/mobile-pr-sidebar-styles' import { useMobileDockResize } from './use-mobile-dock-resize' import type { ActivePanel } from './session-panel-host' -import type { MobileGitStatusResult } from '../source-control/mobile-git-status' type Props = { activePanel: Exclude hostId: string worktreeId: string name: string - client: RpcClient | null - connState: ConnectionState - branch: string | null - headSha: string | null - gitStatus: MobileGitStatusResult | null - isGithubRepo: boolean - branchContextLoaded: boolean availableWidth: number onRequestClose: () => void onFileOpenStart?: () => void @@ -40,13 +29,6 @@ export function SessionDockColumn({ hostId, worktreeId, name, - client, - connState, - branch, - headSha, - gitStatus, - isGithubRepo, - branchContextLoaded, availableWidth, onRequestClose, onFileOpenStart, @@ -63,13 +45,6 @@ export function SessionDockColumn({ hostId={hostId} worktreeId={worktreeId} name={name} - client={client} - connState={connState} - branch={branch} - headSha={headSha} - gitStatus={gitStatus} - isGithubRepo={isGithubRepo} - branchContextLoaded={branchContextLoaded} onRequestClose={onRequestClose} onFileOpenStart={onFileOpenStart} onOpenedFileDiff={onOpenedFileDiff} @@ -85,18 +60,14 @@ const DockPanelContent = memo(function DockPanelContent({ hostId, worktreeId, name, - client, - connState, - branch, - headSha, - gitStatus, - isGithubRepo, - branchContextLoaded, onRequestClose, onFileOpenStart, onOpenedFileDiff }: DockPanelContentProps) { - if (activePanel === 'sourceControl') { + // Source Control and Pull Request share one hub instance so swapping dock icons + // keeps commit draft / visited-tab / scroll state and lands on the right segment + // (design: PR dock maps to hub + tab=pr). + if (activePanel === 'sourceControl' || activePanel === 'pr') { return ( ) } - if (activePanel === 'files') { - return ( - - ) - } return ( - diff --git a/mobile/src/session/github-pr-rpc.test.ts b/mobile/src/session/github-pr-rpc.test.ts index 1adb72ec8b8..56904e482cd 100644 --- a/mobile/src/session/github-pr-rpc.test.ts +++ b/mobile/src/session/github-pr-rpc.test.ts @@ -172,6 +172,23 @@ describe('readWorkItemDetails', () => { expect(parsed?.item.latestReviews).toEqual([{ login: 'ok', state: null, avatarUrl: null }]) }) + it('accepts raw gh latestReviews author.login nesting', () => { + const parsed = readWorkItemDetails({ + item: { + id: 'n', + type: 'pr', + number: 1, + state: 'open', + latestReviews: [ + { author: { login: 'coderabbitai', avatarUrl: 'https://a' }, state: 'COMMENTED' } + ] + } + }) + expect(parsed?.item.latestReviews).toEqual([ + { login: 'coderabbitai', state: 'COMMENTED', avatarUrl: 'https://a' } + ]) + }) + it('returns null when item is unparseable', () => { expect(readWorkItemDetails({ item: { number: 1 } })).toBeNull() expect(readWorkItemDetails(null)).toBeNull() diff --git a/mobile/src/session/github-pr-value-readers.ts b/mobile/src/session/github-pr-value-readers.ts index e7dcf9313ba..6ba8ccd6954 100644 --- a/mobile/src/session/github-pr-value-readers.ts +++ b/mobile/src/session/github-pr-value-readers.ts @@ -123,14 +123,23 @@ export function readReviewSummary(value: unknown): GitHubPRReviewSummary | null if (!isRecord(value)) { return null } - const login = readString(value.login) + // Desktop maps latestReviews to top-level `login`. Raw `gh pr view --json` + // keeps nested `author.login` — accept both so mobile never drops reviewers. + const nestedAuthor = isRecord(value.author) ? value.author : null + const login = + readString(value.login) ?? (nestedAuthor ? readString(nestedAuthor.login) : undefined) if (login === undefined) { return null } + const avatarUrl = + readString(value.avatarUrl) ?? + (nestedAuthor + ? (readString(nestedAuthor.avatarUrl) ?? readString(nestedAuthor.avatar_url) ?? null) + : null) return { login, state: readString(value.state) ?? null, - avatarUrl: readString(value.avatarUrl) ?? null + avatarUrl } } diff --git a/mobile/src/session/mobile-pr-sidebar-state.ts b/mobile/src/session/mobile-pr-sidebar-state.ts index 5f5b679b210..1b4d9a1b50b 100644 --- a/mobile/src/session/mobile-pr-sidebar-state.ts +++ b/mobile/src/session/mobile-pr-sidebar-state.ts @@ -140,6 +140,62 @@ export async function loadPrSidebarDetails( } } +// UI treats details===null as "still loading". After phase 2 finishes (success or +// non-fatal failure), never leave null — use prior comments if any, else empty body. +export function resolvePrSidebarDetailsAfterPhase2(args: { + fetched: GitHubWorkItemDetails | null + prior: GitHubWorkItemDetails | null + pr: PRInfo +}): GitHubWorkItemDetails { + if (args.fetched != null) { + return args.fetched + } + if (args.prior != null) { + return args.prior + } + return emptyPrSidebarDetails(args.pr) +} + +// Placeholder details so Description/Comments leave the spinner after a failed phase 2. +// The synthetic id is the only id shape we mint — real GitHub node ids never match. +export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails { + return { + item: { + id: `pr-${pr.number}`, + type: 'pr', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + labels: [], + updatedAt: pr.updatedAt, + author: null + }, + body: '', + comments: [] + } +} + +// True when phase 2 failed and we synthesized a stand-in (no body/comments/reviews). +export function isPrSidebarDetailsPlaceholder(details: GitHubWorkItemDetails): boolean { + return details.item.id === `pr-${details.item.number}` +} + +// Phase-2 still needs a real payload: null (in flight / not started) or a +// synthetic placeholder from a failed fetch. Real details (including empty body) +// do not qualify — ensure/retry must not re-pull those. +export function prSidebarDetailsNeedFetch( + details: GitHubWorkItemDetails | null | undefined +): boolean { + return details == null || isPrSidebarDetailsPlaceholder(details) +} + +// Soft head refresh may restart an in-flight phase-1 load; only skip when there is +// no visible/in-flight sidebar work (hidden or terminal error states). +export function shouldSoftRefreshPrSidebarOnHeadChange(kind: PrSidebarState['kind']): boolean { + return kind === 'ready' || kind === 'none' || kind === 'loading' +} + // Stale-response guard (KTD6): a load tagged with an older sequence must not // overwrite a newer one. The hook bumps a monotonic counter per load. export function shouldApplyResult(resultSeq: number, latestSeq: number): boolean { diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index be295594f81..aca17cfb509 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -41,6 +41,8 @@ describe('mobile session startup', () => { expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan( startupEffect.indexOf('await fetchSessionTabs()') ) + expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(response.result)') + expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'") }) it('activates an already-selected pending terminal tab after hydration', () => { diff --git a/mobile/src/session/pr-actions-engine.test.ts b/mobile/src/session/pr-actions-engine.test.ts index 72bde872409..d879454c50c 100644 --- a/mobile/src/session/pr-actions-engine.test.ts +++ b/mobile/src/session/pr-actions-engine.test.ts @@ -80,6 +80,51 @@ describe('PrActionsEngine — transport-rejection-normalized outcomes settle cle expect(engine.error).toBe('socket hung up') expect(engine.busy).toBeNull() }) + + it('surfaces a refetch throw as error without rejecting the action promise', async () => { + const engine = new PrActionsEngine({ + mutations: { + mergePR: async () => ({ ok: true }), + setPRAutoMerge: async () => ({ ok: true }), + updatePRState: async () => ({ ok: true }), + requestReviewers: async () => ({ ok: true }), + removeReviewers: async () => ({ ok: true }), + rerunChecks: async () => ({ ok: true }) + }, + prNumber: 1, + refetch: async () => { + throw new Error('refresh failed') + }, + onChange: () => {} + }) + await expect(engine.merge()).resolves.toBeUndefined() + expect(engine.error).toBe('refresh failed') + expect(engine.busy).toBeNull() + }) + + it('does not notify on no-op setError(null) at action start', async () => { + const onChange = vi.fn() + const engine = new PrActionsEngine({ + mutations: { + mergePR: async () => ({ ok: true }), + setPRAutoMerge: async () => ({ ok: true }), + updatePRState: async () => ({ ok: true }), + requestReviewers: async () => ({ ok: true }), + removeReviewers: async () => ({ ok: true }), + rerunChecks: async () => ({ ok: true }) + }, + prNumber: 1, + refetch: () => {}, + onChange + }) + // Idle: error is already null. Action start must only notify for busy, not a + // redundant clearError path. + onChange.mockClear() + const p = engine.merge() + // First notify is setBusy only (setError(null) no-ops). + expect(onChange).toHaveBeenCalledTimes(1) + await p + }) }) describe('PrActionsEngine — PR identity changes', () => { diff --git a/mobile/src/session/pr-actions-engine.ts b/mobile/src/session/pr-actions-engine.ts index a647b62dfe9..7c34954534d 100644 --- a/mobile/src/session/pr-actions-engine.ts +++ b/mobile/src/session/pr-actions-engine.ts @@ -129,7 +129,12 @@ export class PrActionsEngine { return f } + // Why: action start pairs setBusy + setError(null); skip notify when unchanged + // so we don't force a full PR panel re-render for free. private setBusy(key: PrActionBusyKey | null): void { + if (key === null ? this.busy === null : busyKeyEquals(this.busy, key)) { + return + } this.busy = key this.cfg.onChange() } @@ -143,6 +148,9 @@ export class PrActionsEngine { } private setError(message: string | null): void { + if (this.error === message) { + return + } this.error = message this.cfg.onChange() } @@ -180,7 +188,14 @@ export class PrActionsEngine { } if (outcome.ok) { handlers.onSuccess() - await this.cfg.refetch() + // Why: void engine.merge() callers are fire-and-forget; refetch must not LogBox. + try { + await this.cfg.refetch() + } catch (err) { + if (this.identity === identity) { + this.setError(err instanceof Error ? err.message : 'Failed to refresh pull request.') + } + } return } // Both failure classes clear optimism to authoritative; only the message @@ -205,6 +220,10 @@ export class PrActionsEngine { prRepo: cfg.prRepo }) await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} }) + } catch (err) { + if (this.identity === identity) { + this.setError(err instanceof Error ? err.message : 'Failed to merge pull request.') + } } finally { this.clearBusyIfOwned(identity, { kind: 'merge' }) } diff --git a/mobile/src/session/session-panel-host.test.ts b/mobile/src/session/session-panel-host.test.ts index 1028e489821..fb2845143d1 100644 --- a/mobile/src/session/session-panel-host.test.ts +++ b/mobile/src/session/session-panel-host.test.ts @@ -86,7 +86,8 @@ describe('panelRouteDescriptor', () => { pathname: '/h/[hostId]/files/[worktreeId]' }) expect(panelRouteDescriptor('pr')).toEqual({ - pathname: '/h/[hostId]/pr/[worktreeId]' + pathname: '/h/[hostId]/source-control/[worktreeId]', + params: { tab: 'pr' } }) }) }) diff --git a/mobile/src/session/session-panel-host.ts b/mobile/src/session/session-panel-host.ts index 280c368c543..fdf08602752 100644 --- a/mobile/src/session/session-panel-host.ts +++ b/mobile/src/session/session-panel-host.ts @@ -47,13 +47,18 @@ export function resolvePanelAction(args: { // Single source of truth for each panel's expo-router pathname pattern so narrow-push // and any deep-linking agree; the caller supplies the [hostId]/[worktreeId] params. -export function panelRouteDescriptor(panel: Exclude): { pathname: string } { +// The Pull Request panel is a segment of the source-control hub, so its narrow-push +// targets that route with `tab: 'pr'` rather than the standalone (redirecting) route. +export function panelRouteDescriptor(panel: Exclude): { + pathname: string + params?: Record +} { switch (panel) { case 'sourceControl': return { pathname: '/h/[hostId]/source-control/[worktreeId]' } case 'files': return { pathname: '/h/[hostId]/files/[worktreeId]' } case 'pr': - return { pathname: '/h/[hostId]/pr/[worktreeId]' } + return { pathname: '/h/[hostId]/source-control/[worktreeId]', params: { tab: 'pr' } } } } diff --git a/mobile/src/session/use-mobile-pr-branch-context.ts b/mobile/src/session/use-mobile-pr-branch-context.ts index ef2cf1a48d5..d0816b02f71 100644 --- a/mobile/src/session/use-mobile-pr-branch-context.ts +++ b/mobile/src/session/use-mobile-pr-branch-context.ts @@ -34,12 +34,16 @@ export function deriveMobilePrBranchContext( // Loads repo eligibility independently from branch/SHA. The header PR icon only // needs the cheap GitHub probe; the panel can keep loading branch context after // the entry point is already stable in the top bar. +// +// `includeBranchIdentity: false` skips git.status + branchCompare — use when the +// caller only gates a PR entry on hosted-repo eligibility (session dock icon). export function useMobilePrBranchContext(input: { client: RpcClient | null connState: ConnectionState worktreeId: string + includeBranchIdentity?: boolean }): MobilePrBranchContext { - const { client, connState, worktreeId } = input + const { client, connState, worktreeId, includeBranchIdentity = true } = input const [context, setContext] = useState({ branch: null, headSha: null, @@ -60,7 +64,7 @@ export function useMobilePrBranchContext(input: { status: null, isGithubRepo: false, repoLoaded: false, - loaded: false + loaded: !includeBranchIdentity }) return } @@ -70,7 +74,8 @@ export function useMobilePrBranchContext(input: { status: null, isGithubRepo: false, repoLoaded: false, - loaded: false + // Repo-only mode is "loaded" for branch fields immediately (they stay null). + loaded: !includeBranchIdentity }) void loadMobilePrRepoContext(client, worktreeId) @@ -95,6 +100,12 @@ export function useMobilePrBranchContext(input: { } }) + if (!includeBranchIdentity) { + return () => { + cancelled = true + } + } + void loadMobilePrBranchIdentity(client, worktreeId) .then((next) => { if (!cancelled) { @@ -121,7 +132,7 @@ export function useMobilePrBranchContext(input: { return () => { cancelled = true } - }, [ready, client, worktreeId]) + }, [ready, client, worktreeId, includeBranchIdentity]) return context } diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller-probe.test.ts b/mobile/src/session/use-mobile-pr-sidebar-controller-probe.test.ts new file mode 100644 index 00000000000..0645b6659c4 --- /dev/null +++ b/mobile/src/session/use-mobile-pr-sidebar-controller-probe.test.ts @@ -0,0 +1,100 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' + +const fetchGithubRepoSlugMock = vi.fn() + +// Only the repo probe matters here; the load path never runs without a branch. +vi.mock('./github-pr-rpc', () => ({ + fetchGithubRepoSlug: (...args: unknown[]) => fetchGithubRepoSlugMock(...args), + fetchHostedReviewForBranch: vi.fn(), + fetchPRChecks: vi.fn(), + fetchPRForBranch: vi.fn(), + fetchWorkItemDetails: vi.fn() +})) +vi.mock('../source-control/mobile-pr-link', () => ({ + fetchWorktreeLinkedPR: vi.fn(async () => null) +})) + +import { + useMobilePrSidebarController, + type MobilePrSidebarController +} from './use-mobile-pr-sidebar-controller' + +let captured: MobilePrSidebarController | null = null + +function Harness(props: Parameters[0]) { + captured = useMobilePrSidebarController(props) + return null +} + +async function flush(): Promise { + // Two ticks: the probe's resolved promise (.then) plus its setState commit. + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('useMobilePrSidebarController repo probe', () => { + const client = { sendRequest: vi.fn() } as unknown as RpcClient + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + captured = null + fetchGithubRepoSlugMock.mockReset() + }) + + afterEach(() => { + act(() => { + renderer?.unmount() + }) + renderer = null + }) + + // Regression: repo eligibility must not be gated on a branch. A detached HEAD / + // mid-rebase worktree (branch === null) still has to resolve the probe, or the + // Pull Request segment gets stranded on a forever spinner instead of the + // "Current branch unavailable" state. + it('resolves the probe when connected without a branch (detached HEAD)', async () => { + fetchGithubRepoSlugMock.mockResolvedValue({ ok: true, result: { owner: 'o', repo: 'r' } }) + await act(async () => { + renderer = create( + createElement(Harness, { + client, + connState: 'connected', + worktreeId: 'w', + branch: null, + headSha: 'sha' + }) + ) + }) + await flush() + + expect(fetchGithubRepoSlugMock).toHaveBeenCalledWith(client, 'w') + expect(captured?.prSidebarRepoProbeLoaded).toBe(true) + expect(captured?.prSidebarIsGithubRepo).toBe(true) + // No branch means no PR load ran — state stays hidden, never a spinner. + expect(captured?.prSidebarState.kind).toBe('hidden') + }) + + it('does not probe until the client is connected', async () => { + fetchGithubRepoSlugMock.mockResolvedValue({ ok: true, result: { owner: 'o', repo: 'r' } }) + await act(async () => { + renderer = create( + createElement(Harness, { + client, + connState: 'connecting', + worktreeId: 'w', + branch: null, + headSha: 'sha' + }) + ) + }) + await flush() + + expect(fetchGithubRepoSlugMock).not.toHaveBeenCalled() + expect(captured?.prSidebarRepoProbeLoaded).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts index 005c4dc0e5b..f402f7b58ad 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts @@ -4,11 +4,17 @@ import type { HostedReviewInfo } from '../../../src/shared/hosted-review' import type { GitHubPrReadOutcome } from './github-pr-rpc' import { classifyPrSidebarFailure, + emptyPrSidebarDetails, + isPrSidebarDetailsPlaceholder, + prSidebarDetailsNeedFetch, loadPrSidebarData, loadPrSidebarDetails, + resolvePrSidebarDetailsAfterPhase2, shouldApplyResult, + shouldSoftRefreshPrSidebarOnHeadChange, type PrSidebarLoadDeps } from './mobile-pr-sidebar-state' +import { buildMobilePrSidebarIdentity } from './use-mobile-pr-sidebar-controller' function ok(result: T): GitHubPrReadOutcome { return { ok: true, result } @@ -200,3 +206,84 @@ describe('shouldApplyResult', () => { expect(shouldApplyResult(2, 3)).toBe(false) }) }) + +describe('shouldSoftRefreshPrSidebarOnHeadChange', () => { + it('restarts ready/none/loading so mid-flight head advances are not stuck', () => { + expect(shouldSoftRefreshPrSidebarOnHeadChange('ready')).toBe(true) + expect(shouldSoftRefreshPrSidebarOnHeadChange('none')).toBe(true) + expect(shouldSoftRefreshPrSidebarOnHeadChange('loading')).toBe(true) + }) + + it('skips hidden and terminal failures (chip bootstrap / retry own those)', () => { + expect(shouldSoftRefreshPrSidebarOnHeadChange('hidden')).toBe(false) + expect(shouldSoftRefreshPrSidebarOnHeadChange('error')).toBe(false) + expect(shouldSoftRefreshPrSidebarOnHeadChange('blocked')).toBe(false) + }) +}) + +describe('resolvePrSidebarDetailsAfterPhase2', () => { + it('prefers a successful fetch over prior details', () => { + const prior = emptyPrSidebarDetails(PR) + expect(resolvePrSidebarDetailsAfterPhase2({ fetched: DETAILS, prior, pr: PR })).toBe(DETAILS) + }) + + it('keeps prior details when the fetch is non-fatal null', () => { + const prior = emptyPrSidebarDetails(PR) + expect(resolvePrSidebarDetailsAfterPhase2({ fetched: null, prior, pr: PR })).toBe(prior) + }) + + it('uses empty details when phase 2 fails with nothing prior (no forever spinner)', () => { + const empty = resolvePrSidebarDetailsAfterPhase2({ + fetched: null, + prior: null, + pr: PR + }) + expect(empty.body).toBe('') + expect(empty.comments).toEqual([]) + expect(empty.item.number).toBe(7) + expect(empty.item.type).toBe('pr') + expect(isPrSidebarDetailsPlaceholder(empty)).toBe(true) + }) +}) + +describe('isPrSidebarDetailsPlaceholder / prSidebarDetailsNeedFetch', () => { + it('detects the synthetic hyphen id, not the host pr:n shape', () => { + const placeholder = emptyPrSidebarDetails(PR) + expect(isPrSidebarDetailsPlaceholder(placeholder)).toBe(true) + expect(isPrSidebarDetailsPlaceholder(DETAILS)).toBe(false) + // Host work-item ids use a colon (`pr:7`); placeholders use a hyphen (`pr-7`). + expect( + isPrSidebarDetailsPlaceholder({ + ...DETAILS, + item: { ...DETAILS.item, id: `pr:${PR.number}` } + }) + ).toBe(false) + }) + + it('needs fetch for null and placeholders, not real details', () => { + expect(prSidebarDetailsNeedFetch(null)).toBe(true) + expect(prSidebarDetailsNeedFetch(undefined)).toBe(true) + expect(prSidebarDetailsNeedFetch(emptyPrSidebarDetails(PR))).toBe(true) + expect(prSidebarDetailsNeedFetch(DETAILS)).toBe(false) + }) +}) + +describe('buildMobilePrSidebarIdentity', () => { + it('keys identity on worktree + branch, not head SHA', () => { + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe('w1\u0000feat') + // Head advances on commit must not form a new identity — soft refresh keeps ready UI. + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe( + buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' }) + ) + }) + + it('is null without a branch and changes when branch or worktree changes', () => { + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: null })).toBeNull() + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe( + buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'b' }) + ) + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe( + buildMobilePrSidebarIdentity({ worktreeId: 'w2', branch: 'a' }) + ) + }) +}) diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.ts index b14f0dec783..752b2ecb3b8 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.ts @@ -11,7 +11,10 @@ import { import { loadPrSidebarData, loadPrSidebarDetails, + prSidebarDetailsNeedFetch, + resolvePrSidebarDetailsAfterPhase2, shouldApplyResult, + shouldSoftRefreshPrSidebarOnHeadChange, type PrSidebarLoadDeps, type PrSidebarState } from './mobile-pr-sidebar-state' @@ -27,12 +30,20 @@ type PrSidebarControllerInput = { headSha: string | null } -function buildPrSidebarIdentity(args: { +// Load options for the shared PR controller. The Source Control hub chip only needs +// phase 1 (PR + checks); phase 2 (comments/body) is heavy and should wait until the +// Pull Request segment is actually open. +export type PrSidebarLoadOptions = { + includeDetails?: boolean +} + +// Identity is worktree + branch only. Head SHA advances on every commit and must not +// wipe a ready chip/sidebar to "loading" — soft-refresh uses the new head instead. +export function buildMobilePrSidebarIdentity(args: { worktreeId: string branch: string | null - headSha: string | null }): string | null { - return args.branch ? `${args.worktreeId}\u0000${args.branch}\u0000${args.headSha ?? ''}` : null + return args.branch ? `${args.worktreeId}\u0000${args.branch}` : null } export function useMobilePrSidebarController(input: PrSidebarControllerInput) { @@ -41,13 +52,31 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { // independent of whether the branch has an open PR (a no-PR branch shows an // empty state rather than hiding the icon). const [isGithubRepo, setIsGithubRepo] = useState(false) + // False until the probe resolves for this worktree. Consumers gate "unavailable + // for this provider" copy on it — isGithubRepo=false is meaningless mid-probe. + const [repoProbeLoaded, setRepoProbeLoaded] = useState(false) const [state, setState] = useState({ kind: 'hidden' }) const [showPRSidebar, setShowPRSidebar] = useState(false) const loadSeqRef = useRef(0) + // Phase-2-only fetches use a separate sequence so they cannot cancel a concurrent + // phase-1 soft refresh (and vice versa) when chip bootstrap left details null. + const detailsSeqRef = useRef(0) + // The (seq, prNumber) of the phase-2 fetch currently in flight. The hub's + // fill-in effect fires as soon as phase 1 renders ready with null details — + // exactly when load()'s own phase 2 just started. Without this claim, every + // cold PR-segment open fetched the heavy details payload twice. + const detailsInFlightRef = useRef<{ seq: number; prNumber: number } | null>(null) const stateIdentityRef = useRef(null) + const stateRef = useRef(state) + stateRef.current = state + const headShaRef = useRef(headSha) - const ready = client !== null && connState === 'connected' && !!branch - const identity = buildPrSidebarIdentity({ worktreeId, branch, headSha }) + // Repo eligibility (a GitHub remote) is independent of the branch, so the probe + // must not require one: a detached HEAD / mid-rebase worktree (branch === null) + // would otherwise never set repoProbeLoaded, stranding the PR segment on a + // forever spinner instead of the "Current branch unavailable" state. + const probeReady = client !== null && connState === 'connected' + const identity = buildMobilePrSidebarIdentity({ worktreeId, branch }) const buildDeps = useCallback((): PrSidebarLoadDeps | null => { if (!client) { @@ -63,66 +92,221 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { }, [client]) // Probe whether this is a GitHub repo to decide icon availability (GitHub-only). + // Worktree change must reset eligibility; a brief disconnect must not — otherwise + // the hub shows "unavailable for this provider" and hides the chip mid-session. + useEffect(() => { + setIsGithubRepo(false) + setRepoProbeLoaded(false) + }, [worktreeId]) + useEffect(() => { let cancelled = false - if (!ready || !client) { - setIsGithubRepo(false) + if (!probeReady || !client) { return } - void fetchGithubRepoSlug(client, worktreeId).then((outcome) => { - if (!cancelled) { - setIsGithubRepo(outcome.ok && outcome.result !== null) - } - }) + void fetchGithubRepoSlug(client, worktreeId) + .then((outcome) => { + if (!cancelled) { + setIsGithubRepo(outcome.ok && outcome.result !== null) + setRepoProbeLoaded(true) + } + }) + .catch(() => { + // Why: sendGithubPrRead already normalizes throws, but a cancelled + // unmount + any unexpected rejection must not surface as LogBox. + if (!cancelled) { + setIsGithubRepo(false) + setRepoProbeLoaded(true) + } + }) return () => { cancelled = true } - }, [ready, client, worktreeId]) + }, [probeReady, client, worktreeId]) useEffect(() => { if (!identity) { loadSeqRef.current += 1 + detailsSeqRef.current += 1 stateIdentityRef.current = null setState({ kind: 'hidden' }) return } if (stateIdentityRef.current !== null && stateIdentityRef.current !== identity) { - // Why: ready/loading data is scoped to branch+head. A branch switch must - // not let the open panel keep rendering the previous PR as "fresh." + // Why: ready/loading data is scoped to branch. A branch switch must not let + // the open panel keep rendering the previous PR as "fresh." loadSeqRef.current += 1 + detailsSeqRef.current += 1 stateIdentityRef.current = null setState({ kind: 'hidden' }) } }, [identity]) - const load = useCallback(async () => { + const load = useCallback( + async (options?: PrSidebarLoadOptions) => { + const includeDetails = options?.includeDetails ?? true + const deps = buildDeps() + const loadIdentity = identity + if (!deps || !branch || !loadIdentity) { + return + } + const seq = loadSeqRef.current + 1 + loadSeqRef.current = seq + // In-flight phase-2 work is NOT invalidated here: this load only takes phase-2 + // ownership when its own phase 2 actually starts (the bump below), so a load + // superseded at the phase-1 guard can never orphan a detailsSeq and silently + // discard the only details fetch. A stale ensure applying mid-phase-1 is safe — + // its identity/number/kind guards only let matching details through. + const previousIdentity = stateIdentityRef.current + stateIdentityRef.current = loadIdentity + // Soft refresh: same branch already showing ready/none stays visible while + // checks re-fetch (head advanced after commit). Hard loading only on first load + // or after a real identity wipe. + const keepVisible = + previousIdentity === loadIdentity && + (stateRef.current.kind === 'ready' || + stateRef.current.kind === 'none' || + stateRef.current.kind === 'loading') + if (!keepVisible) { + setState({ kind: 'loading' }) + } + // Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with + // forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves. + const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha }) + if ( + !shouldApplyResult(seq, loadSeqRef.current) || + stateIdentityRef.current !== loadIdentity + ) { + return + } + stateIdentityRef.current = loadIdentity + + // Keep prior comments/body visible across phase 1 when the same PR is still open. + // loadPrSidebarData always returns details:null; without this, soft refresh and + // PR-tab refresh blank the comment tree until phase 2 finishes. + const priorDetails = + next.kind === 'ready' && + stateRef.current.kind === 'ready' && + stateRef.current.data.details != null && + stateRef.current.data.pr.number === next.data.pr.number + ? stateRef.current.data.details + : null + + if (next.kind === 'ready' && priorDetails != null) { + setState({ kind: 'ready', data: { ...next.data, details: priorDetails } }) + if (!includeDetails) { + return + } + } else { + setState(next) + if (next.kind !== 'ready' || !includeDetails) { + return + } + } + + // Phase 2: refresh (or first-load) the heavy comments/body payload. + const detailsSeq = detailsSeqRef.current + 1 + detailsSeqRef.current = detailsSeq + detailsInFlightRef.current = { seq: detailsSeq, prNumber: next.data.pr.number } + const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number) + // Release the claim unless a newer phase-2 superseded it (never clear theirs). + if (detailsInFlightRef.current?.seq === detailsSeq) { + detailsInFlightRef.current = null + } + // Phase-2 ownership is encoded by detailsSeq + identity + PR number alone — + // deliberately NOT by loadSeq: a chip-only soft refresh bumps loadSeq without + // bumping detailsSeq, and must not discard the in-flight details it preserved + // (ensure dedupes against this claim, so nothing would re-fetch them). + if ( + detailsSeq !== detailsSeqRef.current || + stateIdentityRef.current !== loadIdentity || + stateRef.current.kind !== 'ready' || + stateRef.current.data.pr.number !== next.data.pr.number + ) { + return + } + // Non-fatal null must not leave details===null (UI treats that as forever-loading). + const details = resolvePrSidebarDetailsAfterPhase2({ + fetched: fetchedDetails, + prior: stateRef.current.data.details, + pr: stateRef.current.data.pr + }) + setState({ kind: 'ready', data: { ...stateRef.current.data, details } }) + }, + [buildDeps, branch, headSha, identity, worktreeId] + ) + + // Phase-2 only — used when the hub opens the PR segment after a chip-only load. + // Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1. + // Retries synthetic placeholders too: a failed phase-2 installs non-null empty + // details so Description/Comments leave the spinner, and without this ensure + // would never re-fetch on tab re-open. + const ensurePrSidebarDetails = useCallback(async () => { + const current = stateRef.current + if (current.kind !== 'ready' || !prSidebarDetailsNeedFetch(current.data.details)) { + return + } const deps = buildDeps() const loadIdentity = identity - if (!deps || !branch || !loadIdentity) { + if (!deps || !loadIdentity || stateIdentityRef.current !== loadIdentity) { return } - const seq = loadSeqRef.current + 1 - loadSeqRef.current = seq - stateIdentityRef.current = loadIdentity - setState({ kind: 'loading' }) - // Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with - // forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves. - const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha }) - if (!shouldApplyResult(seq, loadSeqRef.current) || stateIdentityRef.current !== loadIdentity) { + const prNumber = current.data.pr.number + // A live phase-2 fetch for this PR is already in flight (its claim still owns + // the latest details seq) — do not start a duplicate. + const inFlight = detailsInFlightRef.current + if (inFlight && inFlight.prNumber === prNumber && inFlight.seq === detailsSeqRef.current) { return } - stateIdentityRef.current = loadIdentity - setState(next) - if (next.kind !== 'ready') { + const detailsSeq = detailsSeqRef.current + 1 + detailsSeqRef.current = detailsSeq + detailsInFlightRef.current = { seq: detailsSeq, prNumber } + const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, prNumber) + if (detailsInFlightRef.current?.seq === detailsSeq) { + detailsInFlightRef.current = null + } + if ( + detailsSeq !== detailsSeqRef.current || + stateIdentityRef.current !== loadIdentity || + stateRef.current.kind !== 'ready' || + stateRef.current.data.pr.number !== prNumber + ) { return } - // Phase 2: lazy-load the heavy comments/body payload and merge it in, so it never - // blocks the actionable PR UI. Re-check the seq so a newer load isn't clobbered. - const details = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number) - if (shouldApplyResult(seq, loadSeqRef.current) && stateIdentityRef.current === loadIdentity) { - setState({ kind: 'ready', data: { ...next.data, details } }) + const details = resolvePrSidebarDetailsAfterPhase2({ + fetched: fetchedDetails, + prior: stateRef.current.data.details, + pr: stateRef.current.data.pr + }) + setState({ + kind: 'ready', + data: { ...stateRef.current.data, details } + }) + }, [buildDeps, identity, worktreeId]) + + // Soft-refresh checks when HEAD advances on the same branch (post-commit). + // Also restarts an in-flight phase-1 load so a mid-flight head advance is not + // applied with a stale SHA (headShaRef would otherwise advance with no reload). + useEffect(() => { + if (headShaRef.current === headSha) { + return } - }, [buildDeps, branch, headSha, identity, worktreeId]) + headShaRef.current = headSha + // Identity was just wiped (branch/worktree switch): stateRef still holds the + // pre-wipe state in this effect flush, which would start a load flavored for + // the OLD surface (e.g. heavy details on the Changes tab). Let the owning + // surface's hidden-state effects drive the first load for the new identity. + if (stateIdentityRef.current === null) { + return + } + const current = stateRef.current + if (!shouldSoftRefreshPrSidebarOnHeadChange(current.kind)) { + return + } + void load({ + includeDetails: current.kind === 'ready' && current.data.details != null + }) + }, [headSha, load]) const openPRSidebar = useCallback(() => { setShowPRSidebar(true) @@ -131,17 +315,28 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { stateIdentityRef.current !== identity || (state.kind !== 'ready' && state.kind !== 'loading') ) { - void load() + void load({ includeDetails: true }) + } else if (state.kind === 'ready' && prSidebarDetailsNeedFetch(state.data.details)) { + // Retries phase-2 placeholders too (failed details load, not only null). + void ensurePrSidebarDetails() } - }, [identity, state.kind, load]) + }, [identity, state, load, ensurePrSidebarDetails]) + + const retry = useCallback(() => { + void load({ includeDetails: true }) + }, [load]) return { prSidebarState: state, prSidebarIsGithubRepo: isGithubRepo, + prSidebarRepoProbeLoaded: repoProbeLoaded, showPRSidebar, setShowPRSidebar, openPRSidebar, - retryPRSidebar: load, - refetchPRSidebar: load + retryPRSidebar: retry, + refetchPRSidebar: load, + ensurePrSidebarDetails } } + +export type MobilePrSidebarController = ReturnType diff --git a/mobile/src/source-control/MobileGitHistoryList.tsx b/mobile/src/source-control/MobileGitHistoryList.tsx new file mode 100644 index 00000000000..d88fae540d8 --- /dev/null +++ b/mobile/src/source-control/MobileGitHistoryList.tsx @@ -0,0 +1,271 @@ +import { memo, useCallback, useEffect, useState } from 'react' +import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native' +import { ChevronDown, ChevronRight } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { ConnectionState, RpcSuccess } from '../transport/types' +import type { RpcClient } from '../transport/rpc-client' +import { useForceReconnect } from '../transport/client-context' +import { + fetchMobileGitHistory, + mapMobileCommitRows, + type MobileCommitRow +} from './mobile-git-history' +import { resolveMobileHistoryScreenView } from './mobile-history-screen-state' +import type { GitBranchChangeEntry } from '../../../src/shared/types' + +type Props = { + client: RpcClient | null + connState: ConnectionState + worktreeId: string + // Needed so Retry can revive a parked reconnect loop (STA-1511 / #5049). + hostId: string + bottomInset: number + // Bumped by the hub header refresh so History reloads without remounting. + refreshNonce?: number +} + +// Headerless commit-history list. Extracted from the /history route so the hub's +// History segment and the standalone route render the same body over one code path. +// Memoized: it stays mounted (hidden) while the Changes segment is active, and must +// not re-reconcile its FlatList on every commit-message keystroke re-render. +export const MobileGitHistoryList = memo(function MobileGitHistoryList({ + client, + connState, + worktreeId, + hostId, + bottomInset, + refreshNonce = 0 +}: Props) { + const forceReconnect = useForceReconnect() + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + const [reloadNonce, setReloadNonce] = useState(0) + const [expanded, setExpanded] = useState(null) + const [filesById, setFilesById] = useState>({}) + + // Worktree identity change must wipe history immediately — even while + // disconnected — so a kept-mounted hub segment never shows another tree's commits. + useEffect(() => { + setRows(null) + setError(null) + setExpanded(null) + setFilesById({}) + }, [worktreeId]) + + useEffect(() => { + let active = true + if (!client || connState !== 'connected' || !worktreeId) { + // Why: leave already-loaded rows (and expand state) alone across a drop — + // resolveMobileHistoryScreenView keeps them visible (STA-1511). + return + } + // Reset prior error/rows so a successful retry doesn't stay stuck behind a + // stale error (error wins render precedence). + setError(null) + setRows(null) + setExpanded(null) + setFilesById({}) + void (async () => { + try { + const result = await fetchMobileGitHistory(client, worktreeId) + if (active) { + setRows(mapMobileCommitRows(result, Date.now())) + } + } catch (err) { + if (active) { + setError(err instanceof Error ? err.message : 'Failed to load history') + } + } + })() + return () => { + active = false + } + }, [client, connState, reloadNonce, refreshNonce, worktreeId]) + + const retry = useCallback(() => { + setError(null) + // Why: retrying the fetch is useless while the transport's reconnect loop + // is parked at its backoff cap — revive the connection instead (mirrors + // MobileSourceControlPanel / issue #5049). The load effect re-runs via + // connState once the fresh client connects. + if (connState !== 'connected' && hostId) { + void forceReconnect(hostId) + return + } + setReloadNonce((n) => n + 1) + }, [connState, forceReconnect, hostId]) + + const toggleCommit = useCallback( + (row: MobileCommitRow) => { + const next = expanded === row.id ? null : row.id + setExpanded(next) + if (next && !filesById[row.id]) { + // No client (disconnected while cached rows stay visible): resolve to an + // empty file list so the row shows "No file changes" instead of a spinner + // that never completes — no request can be made. + if (!client) { + setFilesById((prev) => ({ ...prev, [row.id]: [] })) + return + } + setFilesById((prev) => ({ ...prev, [row.id]: 'loading' })) + void client + .sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id }) + .then((response) => { + const entries = response.ok + ? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries + : [] + setFilesById((prev) => { + // Drop stale responses if the row is no longer loading (collapsed + re-opened). + if (prev[row.id] !== 'loading') { + return prev + } + return { ...prev, [row.id]: entries } + }) + }) + .catch(() => + setFilesById((prev) => { + if (prev[row.id] !== 'loading') { + return prev + } + return { ...prev, [row.id]: [] } + }) + ) + } + }, + [client, expanded, filesById, worktreeId] + ) + + const renderCommit = useCallback( + ({ item }: { item: MobileCommitRow }) => { + const files = filesById[item.id] + const isOpen = expanded === item.id + return ( + + [styles.commitHeader, pressed && styles.commitHeaderPressed]} + onPress={() => toggleCommit(item)} + > + {isOpen ? ( + + ) : ( + + )} + + + {item.subject} + + + {item.shortId} · {item.author} · {item.relativeTime} + + + + {isOpen ? ( + + {files === 'loading' || files === undefined ? ( + + ) : files.length === 0 ? ( + No file changes + ) : ( + files.map((file) => ( + + + {file.path} + + + {file.added ? +{file.added} : null} + {file.removed ? -{file.removed} : null} + + + )) + )} + + ) : null} + + ) + }, + [expanded, filesById, toggleCommit] + ) + + const view = resolveMobileHistoryScreenView({ + connected: client !== null && connState === 'connected', + rows, + error + }) + + if (view.kind === 'error' || view.kind === 'waiting') { + return ( + + + {view.kind === 'waiting' ? 'Waiting for desktop...' : view.message} + + + Retry + + + ) + } + if (view.kind === 'loading') { + return ( + + + + ) + } + if (view.kind === 'empty') { + return ( + + No commits. + + ) + } + return ( + row.id} + contentContainerStyle={{ paddingBottom: spacing.lg + bottomInset }} + /> + ) +}) + +const styles = StyleSheet.create({ + state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg }, + stateText: { color: colors.textMuted, fontSize: typography.bodySize }, + retryButton: { + marginTop: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgRaised + }, + retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, + commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle }, + commitHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2 + }, + commitHeaderPressed: { backgroundColor: colors.bgRaised }, + commitMain: { flex: 1, minWidth: 0 }, + commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize }, + commitMeta: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily, + marginTop: 2 + }, + files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 }, + fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + filePath: { + flex: 1, + color: colors.textSecondary, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily + }, + fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily }, + add: { color: colors.gitDecorationAdded }, + del: { color: colors.gitDecorationDeleted }, + empty: { color: colors.textMuted, fontSize: typography.metaSize } +}) diff --git a/mobile/src/source-control/MobileSourceControlBranchCard.tsx b/mobile/src/source-control/MobileSourceControlBranchCard.tsx new file mode 100644 index 00000000000..04aae01b0bc --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlBranchCard.tsx @@ -0,0 +1,78 @@ +import { Pressable, Text, View } from 'react-native' +import { GitBranch } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-source-control-styles' +import { MobileSourceControlPrChip } from './MobileSourceControlPrChip' +import type { MobilePrChipSummary } from './mobile-pr-chip-summary' +import { mobileConflictAbortLabel } from './mobile-source-control-conflict-abort' + +type Props = { + branchLabel: string + syncLabel: string | null + unstagedCount: number + stagedCount: number + branchCount: number + conflictOperation: string | null + // True while any serial git IO is in flight — disables Abort so ops don't race. + conflictBusy: boolean + // True only while abort-merge / abort-rebase itself is running (label accuracy). + conflictAborting: boolean + onAbortConflict: (operation: string) => void + // The PR chip is shown only on repos with a hosted-review remote; null hides it. + prChip: MobilePrChipSummary | null + onOpenPr: () => void +} + +// Persistent card at the top of every hub segment: branch identity, sync/counts, +// conflict state, and the PR chip. Shared so PR/History see the same status the +// Changes lens does without re-deriving it. +export function MobileSourceControlBranchCard({ + branchLabel, + syncLabel, + unstagedCount, + stagedCount, + branchCount, + conflictOperation, + conflictBusy, + conflictAborting, + onAbortConflict, + prChip, + onOpenPr +}: Props) { + const showConflict = conflictOperation !== null && conflictOperation !== 'unknown' + return ( + + + + + + {branchLabel} + + + {syncLabel ? {syncLabel} : null} + + + {unstagedCount} changed + {stagedCount} staged + {branchCount > 0 ? {branchCount} on branch : null} + {showConflict ? ( + + {conflictOperation} + {conflictOperation === 'merge' || conflictOperation === 'rebase' ? ( + [styles.abortButton, pressed && styles.abortPressed]} + disabled={conflictBusy} + onPress={() => onAbortConflict(conflictOperation)} + > + + {mobileConflictAbortLabel(conflictOperation, conflictAborting)} + + + ) : null} + + ) : null} + + {prChip ? : null} + + ) +} diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index 1b50db62479..a28504a5c53 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -1,5 +1,5 @@ import { ActivityIndicator, Pressable, SectionList, Text, TextInput, View } from 'react-native' -import { GitBranch, Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native' +import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native' import { colors, spacing } from '../theme/mobile-theme' import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry' import { MobileCommitFailurePanel } from './MobileCommitFailurePanel' @@ -7,15 +7,17 @@ import { KEYBOARD_COMMIT_BAR_CLEARANCE } from './mobile-source-control-screen-st import { makeRenderFileRow, BranchCompareFooter } from './MobileSourceControlFileRows' import type { MobileSourceControlState } from './use-mobile-source-control-state' import { styles } from './mobile-source-control-styles' +import { hubStyles } from './mobile-source-control-hub-styles' type Props = { state: MobileSourceControlState } -// The ready-state body: summary card, changed-files list, and commit bar. +// The ready-state Changes segment body: quick actions, changed-files list, and commit bar. export function MobileSourceControlContent({ state }: Props) { const { insets, + connState, busyAction, commitMessage, setCommitMessage, @@ -28,23 +30,17 @@ export function MobileSourceControlContent({ state }: Props) { keyboardLift, openingPath, openingBranchPath, - status, sections, - branchEntries, hasVisibleChanges, stageablePaths, unstageablePaths, stagedCount, - unstagedCount, - branchLabel, - syncLabel, primaryAction, createPrAction, stageAll, unstageAll, generateCommitMessage, cancelGenerateCommitMessage, - abortConflictOperation, openFile, openBranchDiff, runGitAction @@ -56,41 +52,16 @@ export function MobileSourceControlContent({ state }: Props) { return ( <> - - - - - - {branchLabel} - - - {syncLabel ? {syncLabel} : null} - - - {unstagedCount} changed - {stagedCount} staged - {branchEntries.length > 0 ? ( - {branchEntries.length} on branch - ) : null} - {status && status.conflictOperation !== 'unknown' ? ( - - {status.conflictOperation} - {(status.conflictOperation === 'merge' || status.conflictOperation === 'rebase') && ( - [styles.abortButton, pressed && styles.abortPressed]} - disabled={busyAction !== null} - onPress={() => void abortConflictOperation(status.conflictOperation)} - > - - {busyAction === `abort-${status.conflictOperation}` - ? 'Aborting…' - : `Abort ${status.conflictOperation}`} - - - )} - - ) : null} + {connState !== 'connected' ? ( + // Why: once data has loaded the screen looks alive even when the + // desktop link is down, so taps appear to do nothing (STA-1511). + // Surface the reconnect state where the user is looking. + + + Reconnecting to desktop... + ) : null} + {commitFailureRecovery ? ( ) : ( void onRefresh: () => void + // When set (PR segment ready with a host URL), show open-on-web flush-right of + // the title so the control stays visible while the PR body scrolls. + onOpenPrWeb?: () => void + prNumber?: number | null } export function MobileSourceControlHeader({ @@ -16,7 +20,9 @@ export function MobileSourceControlHeader({ worktreeLabel, ioBusy, onBack, - onRefresh + onRefresh, + onOpenPrWeb, + prNumber = null }: Props) { return ( @@ -40,6 +46,21 @@ export function MobileSourceControlHeader({ {worktreeLabel} + {onOpenPrWeb ? ( + [styles.refreshButton, pressed && styles.refreshButtonPressed]} + onPress={onOpenPrWeb} + hitSlop={8} + accessibilityRole="link" + accessibilityLabel={ + prNumber != null + ? `Open pull request #${prNumber} on the web` + : 'Open pull request on the web' + } + > + + + ) : null} [ styles.refreshButton, diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx index 256cb9c0ab6..25656aae2bd 100644 --- a/mobile/src/source-control/MobileSourceControlPanel.tsx +++ b/mobile/src/source-control/MobileSourceControlPanel.tsx @@ -1,3 +1,4 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Pressable, Text, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { colors } from '../theme/mobile-theme' @@ -6,7 +7,18 @@ import { useMobileSourceControlActionSheet } from './use-mobile-source-control-a import { MobileSourceControlHeader } from './MobileSourceControlHeader' import { MobileSourceControlContent } from './MobileSourceControlContent' import { MobileSourceControlModals } from './MobileSourceControlModals' +import { MobileSourceControlSegments } from './MobileSourceControlSegments' +import { MobileSourceControlBranchCard } from './MobileSourceControlBranchCard' +import { MobileGitHistoryList } from './MobileGitHistoryList' import { styles } from './mobile-source-control-styles' +import { hubStyles } from './mobile-source-control-hub-styles' +import type { SourceControlHubTab } from './mobile-source-control-hub-tab' +import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary' +import { isMobileConflictAborting } from './mobile-source-control-conflict-abort' +import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller' +import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state' +import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel' +import { openMobilePrUrl } from '../components/MobilePrComposeSheet' export type MobileSourceControlPanelProps = { hostId: string @@ -15,6 +27,8 @@ export type MobileSourceControlPanelProps = { /** Where the panel was launched from; drives the file-open dismissal path. */ origin?: string embedded?: boolean + /** Initial hub segment (from the route's `tab` deep-link param). */ + initialTab?: SourceControlHubTab onRequestClose?: () => void onFileOpenStart?: () => void onOpenedFileDiff?: (relativePath: string) => void @@ -26,10 +40,48 @@ export function MobileSourceControlPanel({ name = '', origin = '', embedded = false, + initialTab = 'changes', onRequestClose, onFileOpenStart, onOpenedFileDiff }: MobileSourceControlPanelProps) { + const [activeTab, setActiveTab] = useState(initialTab) + // Track first visit so Changes/History keep scroll state across segment switches + // without paying the mount cost until the user actually opens them. PR unmounts + // when inactive (WebViews / comment tree) but its controller state stays for the chip. + const [visitedTabs, setVisitedTabs] = useState>( + () => new Set([initialTab]) + ) + const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0) + + // Deep-link / push with a different `tab` param should adopt the new segment + // (expo-router can reuse the screen instance when only query params change). + useEffect(() => { + setActiveTab(initialTab) + setVisitedTabs((prev) => { + if (prev.has(initialTab)) { + return prev + } + const next = new Set(prev) + next.add(initialTab) + return next + }) + }, [initialTab]) + + const selectTab = useCallback((tab: SourceControlHubTab) => { + setActiveTab(tab) + setVisitedTabs((prev) => { + if (prev.has(tab)) { + return prev + } + const next = new Set(prev) + next.add(tab) + return next + }) + }, []) + const openHistoryTab = useCallback(() => selectTab('history'), [selectTab]) + const openPrTab = useCallback(() => selectTab('pr'), [selectTab]) + const state = useMobileSourceControlState({ hostId, worktreeId, @@ -38,12 +90,15 @@ export function MobileSourceControlPanel({ embedded, onRequestClose, onFileOpenStart, - onOpenedFileDiff + onOpenedFileDiff, + onOpenHistory: openHistoryTab }) const actionSheetActions = useMobileSourceControlActionSheet(state) const { + client, connState, forceReconnect, + insets, router, setRootRef, worktreeLabel, @@ -51,25 +106,207 @@ export function MobileSourceControlPanel({ busyAction, openingPath, openingBranchPath, - loadStatus + loadStatus, + status, + branchCompareResult, + branchLabel, + syncLabel, + unstagedCount, + stagedCount, + branchEntries, + abortConflictOperation } = state const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null + const ready = screenState.kind === 'ready' + + // One PR controller feeds both the branch-card chip and the Pull Request + // segment, so the chip's rollup can never disagree with the checks list it + // links to. Branch + head come from the already-loaded git.status — no second + // status read. The chip loads independently, so it never blocks the file list. + // Keep last-known identity across a transient status unload (disconnect / + // failed refresh) so the controller does not wipe ready → hidden → cold start. + // Head SHA matches the review path: status.head ?? branchCompare headOid. + const lastPrBranchRef = useRef(null) + const lastPrHeadRef = useRef(null) + useEffect(() => { + lastPrBranchRef.current = null + lastPrHeadRef.current = null + }, [worktreeId]) + const statusBranch = status?.branch ?? null + const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null + // Write last-known identity in an effect, not the render body: a discarded + // concurrent render must not leave the fallback holding a never-committed value. + useEffect(() => { + if (statusBranch) { + lastPrBranchRef.current = statusBranch + } + if (statusHead) { + lastPrHeadRef.current = statusHead + } + }, [statusBranch, statusHead]) + const prBranch = statusBranch ?? lastPrBranchRef.current + const prHeadSha = statusHead ?? lastPrHeadRef.current + const prController = useMobilePrSidebarController({ + client, + connState, + worktreeId, + branch: prBranch, + headSha: prHeadSha + }) + const isHostedRepo = prController.prSidebarIsGithubRepo + const prSidebarKind = prController.prSidebarState.kind + const refetchPr = prController.refetchPRSidebar + const ensurePrDetails = prController.ensurePrSidebarDetails + // Refs so tab effects do not re-fire when headSha recreates load() (soft refresh). + const refetchPrRef = useRef(refetchPr) + refetchPrRef.current = refetchPr + const ensurePrDetailsRef = useRef(ensurePrDetails) + ensurePrDetailsRef.current = ensurePrDetails + + // Chip bootstrap: phase-1 only (PR + checks). Full comment payload waits until + // the Pull Request segment is open — opening SC to stage must not pull details. + useEffect(() => { + if (activeTab === 'pr') { + return + } + if (prBranch && isHostedRepo && prSidebarKind === 'hidden') { + void refetchPrRef.current({ includeDetails: false }) + } + }, [activeTab, prBranch, isHostedRepo, prSidebarKind]) + + // Number of the ready PR whose phase-2 details are still missing (null when + // none). Keyed by PR number — not a boolean — so a same-branch PR swap during a + // chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its + // comments on a forever-spinner (a stale ensure bails on the number mismatch). + // Placeholder details (failed phase 2) also count as missing so reopening the + // PR tab retries instead of leaving empty Description/Comments forever. + const prDetailsMissingFor = + prController.prSidebarState.kind === 'ready' && + prSidebarDetailsNeedFetch(prController.prSidebarState.data.details) + ? prController.prSidebarState.data.pr.number + : null + + // PR segment: full load or phase-2 fill-in. Body only mounts while active. + // Guarded (and keyed) on prBranch so a branch that arrives while the segment is + // already open — e.g. mounted on a detached HEAD, then checkout — still loads; + // kind stays 'hidden' through that transition, so kind alone can't re-fire this. + useEffect(() => { + if (activeTab !== 'pr' || !isHostedRepo || !prBranch) { + return + } + if (prSidebarKind === 'hidden') { + void refetchPrRef.current({ includeDetails: true }) + return + } + if (prDetailsMissingFor != null) { + void ensurePrDetailsRef.current() + } + }, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor]) + + const prChip = useMemo(() => { + // No branch (detached HEAD / mid-rebase) never runs a PR load, so the shared + // state stays 'hidden' — which the chip would render as a forever spinner. + // Hide the chip instead; the Pull Request segment shows "branch unavailable". + if (!isHostedRepo || !prBranch) { + return null + } + const commentCount = + prController.prSidebarState.kind === 'ready' + ? countUnresolvedReviewThreads(prController.prSidebarState.data.details?.comments) + : null + return buildMobilePrChipSummary(prController.prSidebarState, commentCount) + }, [isHostedRepo, prBranch, prController.prSidebarState]) + + // Design: refresh the active segment's body work, plus git.status for the shared + // branch card (counts/sync stay honest even while on History). Preserve ready + // status on a failed refresh so PR chip identity is not wiped to hidden. + const onRefresh = useCallback(() => { + void loadStatus({ preserveReadyOnFailure: true }) + if (activeTab === 'history') { + setHistoryRefreshNonce((n) => n + 1) + return + } + if (!isHostedRepo) { + return + } + if (activeTab === 'pr') { + void refetchPr({ includeDetails: true }) + return + } + // Changes: light chip refresh so the branch card stays current without comments. + void refetchPr({ includeDetails: false }) + }, [activeTab, isHostedRepo, loadStatus, refetchPr]) // Embedded mode docks beside the terminal: close the dock instead of popping // a route, and skip the full-screen safe-area chrome (the dock column owns it). - // Fall back to router.back() when embedded without a close handler so the button - // never silently no-ops. const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back() + // Chromeless PR body has no panel header — surface open-on-web on the hub chrome + // while the Pull Request segment is active (same affordance as the old /pr route). + const prWebUrl = + activeTab === 'pr' && + prController.prSidebarState.kind === 'ready' && + prController.prSidebarState.data.pr.url + ? prController.prSidebarState.data.pr.url + : null + const prWebNumber = + prController.prSidebarState.kind === 'ready' ? prController.prSidebarState.data.pr.number : null const header = ( void loadStatus()} + onRefresh={onRefresh} + onOpenPrWeb={prWebUrl ? () => openMobilePrUrl(prWebUrl) : undefined} + prNumber={prWebNumber} /> ) + const statusGate = + screenState.kind === 'loading' ? ( + + + + ) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? ( + + + {screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'} + + {screenState.message} + {screenState.kind === 'error' ? ( + { + // Why: retrying the request is useless while the transport's + // reconnect loop is parked at its give-up cap — revive the + // connection instead (issue #5049). loadStatus re-runs via + // its connState effect once the new client connects. + if (connState !== 'connected' && hostId) { + void forceReconnect(hostId) + return + } + void loadStatus() + }} + > + Retry + + ) : null} + + ) : null + + // History only needs the RPC client — do not block it behind git.status. + // Changes/PR need status (branch, file list, head SHA), so they stay gated. + const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes')) + const showHistory = activeTab === 'history' || visitedTabs.has('history') + // Why: only mount the PR body while its segment is active. Keep-mounting retained + // Mermaid WebViews and re-rendered the comment tree on every commit keystroke. + // Controller + chip state still live for instant re-open without a full cold start. + const showPrBody = ready && activeTab === 'pr' + const conflictOperation = status?.conflictOperation ?? null + // Git status always reports a conflictOperation enum; 'unknown' means none. + const hasActiveConflict = conflictOperation != null && conflictOperation !== 'unknown' + const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation) + return ( {embedded ? ( @@ -80,38 +317,68 @@ export function MobileSourceControlPanel({ )} - {screenState.kind === 'loading' ? ( - - + + + {/* Branch card + PR chip are the Changes/Commits glance layer. On the PR + tab they duplicate the ready PR body (#, state, checks rollup, branch + trajectory), so hide the whole card there and let the PR panel own it — + unless a merge/rebase conflict is active, which only this card can abort. */} + {ready && (activeTab !== 'pr' || hasActiveConflict) ? ( + void abortConflictOperation(operation)} + prChip={prChip} + onOpenPr={openPrTab} + /> + ) : null} + + {showChanges ? ( + + - ) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? ( - - - {screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'} - - {screenState.message} - {screenState.kind === 'error' ? ( - { - // Why: retrying the request is useless while the transport's - // reconnect loop is parked at its give-up cap — revive the - // connection instead (issue #5049). loadStatus re-runs via - // its connState effect once the new client connects. - if (connState !== 'connected' && hostId) { - void forceReconnect(hostId) - return - } - void loadStatus() - }} - > - Retry - - ) : null} + ) : activeTab === 'changes' ? ( + statusGate + ) : null} + + {showPrBody ? ( + + - ) : ( - - )} + ) : activeTab === 'pr' ? ( + statusGate + ) : null} + + {showHistory ? ( + + + + ) : null} diff --git a/mobile/src/source-control/MobileSourceControlPrChip.tsx b/mobile/src/source-control/MobileSourceControlPrChip.tsx new file mode 100644 index 00000000000..1165aae8b9b --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlPrChip.tsx @@ -0,0 +1,121 @@ +import { ActivityIndicator, Pressable, Text, View } from 'react-native' +import { + AlertTriangle, + Check, + ChevronRight, + CircleDot, + GitPullRequest, + MessageSquare, + X +} from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { statusColor } from '../components/pr-sidebar/pr-sidebar-status-color' +import { hubStyles } from './mobile-source-control-hub-styles' +import type { MobilePrChipRollup, MobilePrChipSummary } from './mobile-pr-chip-summary' + +type Props = { + summary: MobilePrChipSummary + onPress: () => void +} + +// The glanceable PR status line on the branch card. Tapping it switches to the +// Pull Request segment. Rendered only when the repo supports hosted review — the +// parent gates on that, so this component always has something meaningful to show. +export function MobileSourceControlPrChip({ summary, onPress }: Props) { + return ( + [hubStyles.chip, pressed && hubStyles.chipPressed]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={chipAccessibilityLabel(summary)} + > + + + + {summary.kind === 'loading' ? ( + <> + + + Loading pull request… + + + ) : summary.kind === 'none' ? ( + <> + Create pull request + + + + ) : summary.kind === 'unavailable' ? ( + <> + + {summary.message} + + + + ) : ( + <> + #{summary.number} + + + {summary.stateLabel} + + + + {summary.commentCount != null && summary.commentCount > 0 ? ( + + + {summary.commentCount} + + ) : null} + + + + )} + + ) +} + +function ChipRollup({ rollup }: { rollup: MobilePrChipRollup }) { + const color = statusColor(rollup.token) + return ( + + + {rollup.text} + + ) +} + +function RollupIcon({ kind, color }: { kind: MobilePrChipRollup['kind']; color: string }) { + const size = 13 + const strokeWidth = 2.3 + switch (kind) { + case 'conflict': + return + case 'failing': + return + case 'running': + return + case 'passed': + return + case 'none': + return null + } +} + +function chipAccessibilityLabel(summary: MobilePrChipSummary): string { + switch (summary.kind) { + case 'loading': + return 'Loading pull request' + case 'none': + return 'Create pull request' + case 'unavailable': + return `Pull request unavailable: ${summary.message}` + case 'ready': { + const comments = + summary.commentCount != null && summary.commentCount > 0 + ? `, ${summary.commentCount} unresolved comments` + : '' + return `Pull request #${summary.number}, ${summary.stateLabel}, ${summary.rollup.text}${comments}. Open pull request.` + } + } +} diff --git a/mobile/src/source-control/MobileSourceControlSegments.tsx b/mobile/src/source-control/MobileSourceControlSegments.tsx new file mode 100644 index 00000000000..89564aca417 --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlSegments.tsx @@ -0,0 +1,45 @@ +import { Pressable, Text, View } from 'react-native' +import { + SOURCE_CONTROL_HUB_TABS, + SOURCE_CONTROL_HUB_TAB_LABELS, + type SourceControlHubTab +} from './mobile-source-control-hub-tab' +import { hubStyles } from './mobile-source-control-hub-styles' + +type Props = { + active: SourceControlHubTab + onSelect: (tab: SourceControlHubTab) => void +} + +// The hub's top-level lens switcher. Switching is local state (no route push) so +// scroll position and the shared branch card persist across Changes/PR/History. +export function MobileSourceControlSegments({ active, onSelect }: Props) { + return ( + + {SOURCE_CONTROL_HUB_TABS.map((tab) => { + const isActive = tab === active + return ( + [ + hubStyles.segment, + isActive && hubStyles.segmentActive, + pressed && !isActive && hubStyles.segmentPressed + ]} + onPress={() => onSelect(tab)} + accessibilityRole="tab" + accessibilityState={{ selected: isActive }} + accessibilityLabel={SOURCE_CONTROL_HUB_TAB_LABELS[tab]} + > + + {SOURCE_CONTROL_HUB_TAB_LABELS[tab]} + + + ) + })} + + ) +} diff --git a/mobile/src/source-control/mobile-history-screen-state.test.ts b/mobile/src/source-control/mobile-history-screen-state.test.ts new file mode 100644 index 00000000000..1279f9a940f --- /dev/null +++ b/mobile/src/source-control/mobile-history-screen-state.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { resolveMobileHistoryScreenView } from './mobile-history-screen-state' +import type { MobileCommitRow } from './mobile-git-history' + +const row: MobileCommitRow = { + id: 'abc123', + shortId: 'abc123', + subject: 'commit', + author: 'author', + parentId: null, + relativeTime: '1m' +} + +describe('resolveMobileHistoryScreenView', () => { + it('shows waiting (not an endless spinner) when disconnected with nothing loaded', () => { + // Regression (STA-1511): the load effect no-ops while disconnected, so + // rows stay null forever — the screen must offer a retry, not a spinner. + expect(resolveMobileHistoryScreenView({ connected: false, rows: null, error: null })).toEqual({ + kind: 'waiting' + }) + }) + + it('spins only while connected and loading', () => { + expect(resolveMobileHistoryScreenView({ connected: true, rows: null, error: null })).toEqual({ + kind: 'loading' + }) + }) + + it('keeps already-loaded rows visible across a connection drop', () => { + expect(resolveMobileHistoryScreenView({ connected: false, rows: [row], error: null })).toEqual({ + kind: 'rows', + rows: [row] + }) + }) + + it('prefers the error state over waiting so failures stay actionable', () => { + expect(resolveMobileHistoryScreenView({ connected: false, rows: null, error: 'boom' })).toEqual( + { kind: 'error', message: 'boom' } + ) + }) + + it('distinguishes an empty history from a pending load', () => { + expect(resolveMobileHistoryScreenView({ connected: true, rows: [], error: null })).toEqual({ + kind: 'empty' + }) + }) +}) diff --git a/mobile/src/source-control/mobile-history-screen-state.ts b/mobile/src/source-control/mobile-history-screen-state.ts new file mode 100644 index 00000000000..8d03f33f028 --- /dev/null +++ b/mobile/src/source-control/mobile-history-screen-state.ts @@ -0,0 +1,30 @@ +import type { MobileCommitRow } from './mobile-git-history' + +export type MobileHistoryScreenView = + | { kind: 'error'; message: string } + | { kind: 'waiting' } + | { kind: 'loading' } + | { kind: 'empty' } + | { kind: 'rows'; rows: MobileCommitRow[] } + +// Why: the history load effect no-ops while the client isn't connected, so +// without an explicit 'waiting' view the screen showed an infinite spinner +// with no retry when opened during a reconnect window (STA-1511). Rows loaded +// before a drop stay visible — stale history beats a blank screen. +export function resolveMobileHistoryScreenView(input: { + connected: boolean + rows: MobileCommitRow[] | null + error: string | null +}): MobileHistoryScreenView { + const { connected, rows, error } = input + if (error) { + return { kind: 'error', message: error } + } + if (rows !== null) { + return rows.length === 0 ? { kind: 'empty' } : { kind: 'rows', rows } + } + if (!connected) { + return { kind: 'waiting' } + } + return { kind: 'loading' } +} diff --git a/mobile/src/source-control/mobile-pr-chip-summary.test.ts b/mobile/src/source-control/mobile-pr-chip-summary.test.ts new file mode 100644 index 00000000000..88186a06ea0 --- /dev/null +++ b/mobile/src/source-control/mobile-pr-chip-summary.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest' +import type { PRCheckDetail, PRComment, PRInfo } from '../../../src/shared/types' +import type { PrSidebarState } from '../session/mobile-pr-sidebar-state' +import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary' + +function pr(overrides: Partial = {}): PRInfo { + return { + number: 7701, + title: 'Make center split more visible', + state: 'open', + url: 'https://example.test/pr/7701', + checksStatus: 'success', + updatedAt: '2026-07-07T00:00:00Z', + mergeable: 'MERGEABLE', + ...overrides + } +} + +function check( + conclusion: PRCheckDetail['conclusion'], + status: PRCheckDetail['status'] = 'completed' +): PRCheckDetail { + return { name: `check-${conclusion}-${status}`, status, conclusion, url: null } +} + +function ready(prInfo: PRInfo, checks: PRCheckDetail[]): PrSidebarState { + return { kind: 'ready', data: { pr: prInfo, checks, details: null } } +} + +describe('buildMobilePrChipSummary', () => { + it('maps non-ready states', () => { + expect(buildMobilePrChipSummary({ kind: 'hidden' })).toEqual({ kind: 'loading' }) + expect(buildMobilePrChipSummary({ kind: 'loading' })).toEqual({ kind: 'loading' }) + expect(buildMobilePrChipSummary({ kind: 'none' })).toEqual({ kind: 'none' }) + expect(buildMobilePrChipSummary({ kind: 'error', message: 'net' })).toEqual({ + kind: 'unavailable', + message: 'net' + }) + expect(buildMobilePrChipSummary({ kind: 'blocked', message: 'auth' })).toEqual({ + kind: 'unavailable', + message: 'auth' + }) + }) + + it('surfaces the PR number and state badge', () => { + const summary = buildMobilePrChipSummary(ready(pr({ state: 'draft' }), [check('success')])) + expect(summary.kind).toBe('ready') + if (summary.kind !== 'ready') { + return + } + expect(summary.number).toBe(7701) + expect(summary.stateLabel).toBe('Draft') + }) + + it('rolls up passed checks as passed/total', () => { + 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' }) + }) + + it('prefers failing over running and passing', () => { + const summary = buildMobilePrChipSummary( + ready(pr(), [check('success'), check('failure'), check(null, 'in_progress')]) + ) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup).toEqual({ kind: 'failing', text: '1 failing', token: 'statusRed' }) + }) + + it('shows running when nothing has failed yet', () => { + const summary = buildMobilePrChipSummary(ready(pr(), [check('success'), check(null, 'queued')])) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup).toEqual({ kind: 'running', text: '1 running', token: 'statusAmber' }) + }) + + it('lets a merge conflict win over green checks', () => { + const summary = buildMobilePrChipSummary( + ready(pr({ mergeable: 'CONFLICTING' }), [check('success')]) + ) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup.kind).toBe('conflict') + }) + + it('reports no checks when the list is empty', () => { + const summary = buildMobilePrChipSummary(ready(pr(), [])) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup).toEqual({ kind: 'none', text: 'No checks', token: 'textSecondary' }) + }) + + it('passes through the unresolved comment count', () => { + const summary = buildMobilePrChipSummary(ready(pr(), [check('success')]), 3) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.commentCount).toBe(3) + }) +}) + +describe('countUnresolvedReviewThreads', () => { + function comment(overrides: Partial): PRComment { + return { + id: 1, + author: 'octocat', + authorAvatarUrl: '', + body: 'x', + createdAt: '2026-07-07T00:00:00Z', + url: '', + ...overrides + } + } + + it('returns null when details have not loaded', () => { + expect(countUnresolvedReviewThreads(null)).toBeNull() + expect(countUnresolvedReviewThreads(undefined)).toBeNull() + }) + + it('counts each unresolved thread once and ignores resolved threads', () => { + const comments = [ + comment({ id: 1, threadId: 't1', isResolved: false }), + comment({ id: 2, threadId: 't1', isResolved: false }), + comment({ id: 3, threadId: 't2', isResolved: true }), + comment({ id: 4, threadId: 't3' }), + comment({ id: 5 }) + ] + expect(countUnresolvedReviewThreads(comments)).toBe(2) + }) +}) diff --git a/mobile/src/source-control/mobile-pr-chip-summary.ts b/mobile/src/source-control/mobile-pr-chip-summary.ts new file mode 100644 index 00000000000..87f54d43472 --- /dev/null +++ b/mobile/src/source-control/mobile-pr-chip-summary.ts @@ -0,0 +1,104 @@ +import type { PRComment } from '../../../src/shared/types' +import type { PrSidebarState } from '../session/mobile-pr-sidebar-state' +import { + prStateBadge, + summarizePRChecks, + type MobileStatusToken +} from '../components/pr-sidebar/pr-checks-presentation' + +// Pure derivation of the branch-card PR chip from the shared PR sidebar state. +// No React/native imports so the rollup precedence is unit-testable (KTD5). The +// chip and the Pull Request segment read the SAME PrSidebarState, so the chip's +// check rollup can never disagree with the checks list it links to. + +// The single check-rollup token the chip shows. Exactly one wins, by precedence: +// merge conflict > failing > running > passed > no checks. Worst-actionable-first +// so a red/amber signal is never hidden behind a green count. +export type MobilePrChipRollup = + | { kind: 'conflict'; text: string; token: MobileStatusToken } + | { kind: 'failing'; text: string; token: MobileStatusToken } + | { kind: 'running'; text: string; token: MobileStatusToken } + | { kind: 'passed'; text: string; token: MobileStatusToken } + | { kind: 'none'; text: string; token: MobileStatusToken } + +export type MobilePrChipSummary = + | { kind: 'loading' } + // GitHub repo, but this branch has no open/linked PR — the chip becomes a + // "create pull request" affordance that routes to the PR segment's composer. + | { kind: 'none' } + // A permanent (auth/permission) or transient failure loading PR data. The chip + // degrades to a muted, tappable line rather than vanishing. + | { kind: 'unavailable'; message: string } + | { + kind: 'ready' + number: number + stateLabel: string + stateToken: MobileStatusToken + rollup: MobilePrChipRollup + // Unresolved review-comment count, when the deferred details payload has + // loaded; null while it is still loading or unavailable. + commentCount: number | null + } + +// Unresolved review threads (the "💬 n" count). Counts each inline thread once by +// threadId when it is not resolved; top-level conversation comments (no threadId) +// are not review threads and don't count. Returns null when details haven't loaded. +export function countUnresolvedReviewThreads( + comments: PRComment[] | null | undefined +): number | null { + if (!comments) { + return null + } + const unresolved = new Set() + for (const comment of comments) { + if (comment.threadId && comment.isResolved !== true) { + unresolved.add(comment.threadId) + } + } + return unresolved.size +} + +export function buildMobilePrChipSummary( + state: PrSidebarState, + commentCount: number | null = null +): MobilePrChipSummary { + switch (state.kind) { + case 'hidden': + case 'loading': + return { kind: 'loading' } + case 'none': + return { kind: 'none' } + case 'error': + case 'blocked': + return { kind: 'unavailable', message: state.message } + case 'ready': { + const badge = prStateBadge(state.data.pr.state) + return { + kind: 'ready', + number: state.data.pr.number, + stateLabel: badge.label, + stateToken: badge.token, + rollup: buildChipRollup(state), + commentCount + } + } + } +} + +function buildChipRollup(state: Extract): MobilePrChipRollup { + // Conflicts win: the checks may be green, but the PR still can't merge. + if (state.data.pr.mergeable === 'CONFLICTING') { + return { kind: 'conflict', text: 'Conflicts', token: 'statusAmber' } + } + const checks = summarizePRChecks(state.data.checks) + if (checks.failed > 0) { + return { kind: 'failing', text: `${checks.failed} failing`, token: 'statusRed' } + } + if (checks.pending > 0) { + return { kind: 'running', text: `${checks.pending} running`, token: 'statusAmber' } + } + if (checks.passed > 0) { + return { kind: 'passed', text: `${checks.passed}/${checks.total}`, token: 'statusGreen' } + } + return { kind: 'none', text: 'No checks', token: 'textSecondary' } +} diff --git a/mobile/src/source-control/mobile-source-control-actions.test.ts b/mobile/src/source-control/mobile-source-control-actions.test.ts index 5ed3deb2370..8dc281a4019 100644 --- a/mobile/src/source-control/mobile-source-control-actions.test.ts +++ b/mobile/src/source-control/mobile-source-control-actions.test.ts @@ -52,7 +52,7 @@ describe('buildMobileSourceControlActions', () => { expect(labels.some((l) => l.startsWith('Fast-forward'))).toBe(true) expect(labels).toContain('Rebase onto base') expect(labels).toContain('Switch branch') - expect(labels).toContain('History') + expect(labels).toContain('Commits') expect(labels).toContain('Create PR') }) @@ -90,7 +90,7 @@ describe('buildMobileSourceControlActions', () => { const handlers = noopHandlers() const actions = buildMobileSourceControlActions(args({ handlers })) action(actions, 'Switch branch')?.onPress() - action(actions, 'History')?.onPress() + action(actions, 'Commits')?.onPress() expect(handlers.checkout).toHaveBeenCalled() expect(handlers.history).toHaveBeenCalled() }) diff --git a/mobile/src/source-control/mobile-source-control-actions.ts b/mobile/src/source-control/mobile-source-control-actions.ts index 0e504c5a2b1..b06af8a6aab 100644 --- a/mobile/src/source-control/mobile-source-control-actions.ts +++ b/mobile/src/source-control/mobile-source-control-actions.ts @@ -217,7 +217,7 @@ export function buildMobileSourceControlActions( onPress: handlers.checkout }, { - label: 'History', + label: 'Commits', iconKey: 'history', disabled: busy, onPress: handlers.history diff --git a/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts b/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts new file mode 100644 index 00000000000..fa9e40a4746 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + isMobileConflictAborting, + mobileConflictAbortLabel +} from './mobile-source-control-conflict-abort' + +describe('isMobileConflictAborting', () => { + it('is true only for the matching abort action', () => { + expect(isMobileConflictAborting('abort-merge', 'merge')).toBe(true) + expect(isMobileConflictAborting('abort-rebase', 'rebase')).toBe(true) + }) + + it('is false for other busy actions (stage/commit must not look like abort)', () => { + expect(isMobileConflictAborting('stage-all', 'merge')).toBe(false) + expect(isMobileConflictAborting('commit', 'rebase')).toBe(false) + expect(isMobileConflictAborting(null, 'merge')).toBe(false) + expect(isMobileConflictAborting('abort-merge', 'rebase')).toBe(false) + expect(isMobileConflictAborting('abort-merge', 'unknown')).toBe(false) + }) +}) + +describe('mobileConflictAbortLabel', () => { + it('shows Aborting only while abort is in flight', () => { + expect(mobileConflictAbortLabel('merge', true)).toBe('Aborting…') + expect(mobileConflictAbortLabel('merge', false)).toBe('Abort merge') + expect(mobileConflictAbortLabel('rebase', false)).toBe('Abort rebase') + }) +}) diff --git a/mobile/src/source-control/mobile-source-control-conflict-abort.ts b/mobile/src/source-control/mobile-source-control-conflict-abort.ts new file mode 100644 index 00000000000..ac71bcbf9ed --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-conflict-abort.ts @@ -0,0 +1,18 @@ +// Pure helpers for the branch-card conflict Abort control. Kept free of React so +// the busy-label rule (abort-in-flight only) is unit-testable. + +/** True while git.abortMerge / git.abortRebase is the active serial action. */ +export function isMobileConflictAborting( + busyAction: string | null, + conflictOperation: string | null +): boolean { + if (conflictOperation !== 'merge' && conflictOperation !== 'rebase') { + return false + } + return busyAction === `abort-${conflictOperation}` +} + +/** Label for the Abort control — never says "Aborting…" for unrelated busy work. */ +export function mobileConflictAbortLabel(conflictOperation: string, aborting: boolean): string { + return aborting ? 'Aborting…' : `Abort ${conflictOperation}` +} diff --git a/mobile/src/source-control/mobile-source-control-hub-styles.ts b/mobile/src/source-control/mobile-source-control-hub-styles.ts new file mode 100644 index 00000000000..10d126177e8 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-styles.ts @@ -0,0 +1,126 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +// Styles for the hub's segmented control and the branch-card PR chip. Split from +// mobile-source-control-styles.ts so neither file crosses the line limit. +export const hubStyles = StyleSheet.create({ + // Full-width tab bar under the hub header. Edge-to-edge, no inset track and no + // inner padding — segments share height evenly so the control doesn't float in a + // pill frame with gaps above/below the active cell. + segments: { + flexDirection: 'row', + alignItems: 'stretch', + width: '100%', + backgroundColor: colors.bgPanel, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + segment: { + flex: 1, + minHeight: 40, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.xs, + // Reserved so active underline doesn't change layout height. + borderBottomWidth: 2, + borderBottomColor: 'transparent' + }, + segmentActive: { + borderBottomColor: colors.textPrimary + }, + segmentPressed: { + opacity: 0.7 + }, + segmentText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + segmentTextActive: { + color: colors.textPrimary + }, + // The PR chip sits below the count row inside the branch card, separated by a + // hairline so it reads as a distinct, tappable status line. + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginTop: spacing.md, + paddingTop: spacing.md, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + chipPressed: { + opacity: 0.7 + }, + chipIcon: { + width: 18, + alignItems: 'center' + }, + chipNumber: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '700' + }, + statePill: { + paddingHorizontal: spacing.sm, + paddingVertical: 1, + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth + }, + statePillText: { + fontSize: typography.metaSize, + fontWeight: '700' + }, + rollup: { + flexDirection: 'row', + alignItems: 'center', + gap: 4 + }, + rollupText: { + fontSize: typography.metaSize, + fontWeight: '600' + }, + comment: { + flexDirection: 'row', + alignItems: 'center', + gap: 4 + }, + commentText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600' + }, + // Pushes the chevron to the trailing edge without a fixed-width spacer. + chipSpacer: { + flex: 1, + minWidth: spacing.sm + }, + chipCreateText: { + color: colors.accentBlue, + fontSize: typography.bodySize, + fontWeight: '600' + }, + chipMutedText: { + flex: 1, + color: colors.textMuted, + fontSize: typography.metaSize + }, + // Wraps the Changes-only controls (commit-failure/error notice, create-PR entry, + // bulk Stage/Unstage row) that used to live inside the summary card, now that the + // card is shared across segments and holds only branch status. + changesControls: { + paddingHorizontal: spacing.lg, + marginTop: spacing.xs + }, + // Fills the remaining space below the header/segments/card so each segment's + // scroll view (SectionList / PR sidebar / history list) expands and scrolls. + tabBody: { + flex: 1 + }, + // Keep a previously-visited segment mounted (scroll + fetch state) without + // participating in layout while another segment is active. + tabBodyHidden: { + display: 'none' + } +}) diff --git a/mobile/src/source-control/mobile-source-control-hub-tab.test.ts b/mobile/src/source-control/mobile-source-control-hub-tab.test.ts new file mode 100644 index 00000000000..4caf135cf13 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-tab.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { parseSourceControlHubTab, SOURCE_CONTROL_HUB_TABS } from './mobile-source-control-hub-tab' + +describe('parseSourceControlHubTab', () => { + it('accepts each known tab', () => { + for (const tab of SOURCE_CONTROL_HUB_TABS) { + expect(parseSourceControlHubTab(tab)).toBe(tab) + } + }) + + it('reads the first element of an array param', () => { + expect(parseSourceControlHubTab(['pr', 'history'])).toBe('pr') + }) + + it('falls back to changes for unknown, empty, or missing values', () => { + expect(parseSourceControlHubTab('nope')).toBe('changes') + expect(parseSourceControlHubTab('')).toBe('changes') + expect(parseSourceControlHubTab(undefined)).toBe('changes') + expect(parseSourceControlHubTab(null)).toBe('changes') + expect(parseSourceControlHubTab([])).toBe('changes') + }) +}) diff --git a/mobile/src/source-control/mobile-source-control-hub-tab.ts b/mobile/src/source-control/mobile-source-control-hub-tab.ts new file mode 100644 index 00000000000..3a0a48406ce --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-tab.ts @@ -0,0 +1,29 @@ +// The three lenses of the mobile Source Control hub. Kept as a pure module (no +// React/native imports) so tab parsing is unit-testable and the deep-link `tab` +// query param and the segmented control share one source of truth. + +export type SourceControlHubTab = 'changes' | 'pr' | 'history' + +export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [ + 'changes', + 'pr', + 'history' +] as const + +export const SOURCE_CONTROL_HUB_TAB_LABELS: Record = { + changes: 'Changes', + pr: 'Pull Request', + history: 'Commits' +} + +// Normalize a route param (possibly an array from expo-router, possibly unknown) +// to a valid tab, defaulting to 'changes'. Deep links that name a stale/invalid +// tab fall back rather than render a blank body. +export function parseSourceControlHubTab( + value: string | string[] | undefined | null +): SourceControlHubTab { + const first = Array.isArray(value) ? value[0] : value + return SOURCE_CONTROL_HUB_TABS.includes(first as SourceControlHubTab) + ? (first as SourceControlHubTab) + : 'changes' +} diff --git a/mobile/src/source-control/mobile-source-control-styles.ts b/mobile/src/source-control/mobile-source-control-styles.ts index 3d22020651f..ea766731840 100644 --- a/mobile/src/source-control/mobile-source-control-styles.ts +++ b/mobile/src/source-control/mobile-source-control-styles.ts @@ -125,6 +125,24 @@ const baseStyles = StyleSheet.create({ fontWeight: '600', textTransform: 'capitalize' }, + reconnectBanner: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginHorizontal: spacing.lg, + marginTop: spacing.lg, + marginBottom: -spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.statusAmber + }, + reconnectBannerText: { + color: colors.textPrimary, + fontSize: typography.metaSize + }, actionError: { marginTop: spacing.sm, paddingHorizontal: spacing.md, diff --git a/mobile/src/source-control/use-mobile-source-control-runners.ts b/mobile/src/source-control/use-mobile-source-control-runners.ts index 29892344779..026a706a5f9 100644 --- a/mobile/src/source-control/use-mobile-source-control-runners.ts +++ b/mobile/src/source-control/use-mobile-source-control-runners.ts @@ -45,6 +45,8 @@ type Params = { setCreatedPrUrl: (next: string | null) => void setCreatedPrWarning: (next: string | null) => void recordCommitFailure: RecordMobileCommitFailure + // Hub override: switch to the History segment instead of pushing the route. + onOpenHistory?: () => void } // All git workflow + action-sheet runners for the source-control panel. Split @@ -78,7 +80,8 @@ export function useMobileSourceControlRunners(params: Params) { setShowBranchPicker, setCreatedPrUrl, setCreatedPrWarning, - recordCommitFailure + recordCommitFailure, + onOpenHistory } = params const runGitWorkflow = useCallback( @@ -248,14 +251,24 @@ export function useMobileSourceControlRunners(params: Params) { const openHistory = useCallback(() => { setShowActionSheet(false) - if (hostId && worktreeId) { - router.push( - `/h/${hostId}/history/${encodeURIComponent(worktreeId)}` as Parameters< - typeof router.push - >[0] - ) + // Inside the hub, History is a segment — switch to it rather than pushing a + // route. Fallback pushes the hub with `tab=history` (not the redirecting + // /history route) so deep links land in one hop. + if (onOpenHistory) { + onOpenHistory() + return } - }, [hostId, router, setShowActionSheet, worktreeId]) + if (hostId && worktreeId) { + router.push({ + pathname: '/h/[hostId]/source-control/[worktreeId]', + params: { + hostId, + worktreeId, + tab: 'history' + } + } as Parameters[0]) + } + }, [hostId, onOpenHistory, router, setShowActionSheet, worktreeId]) // Switch to a local branch, then reload status. const checkoutBranch = useCallback( 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 cc69b89059c..c9a8ca406b0 100644 --- a/mobile/src/source-control/use-mobile-source-control-state.ts +++ b/mobile/src/source-control/use-mobile-source-control-state.ts @@ -42,6 +42,9 @@ export type MobileSourceControlStateParams = { onRequestClose?: () => void onFileOpenStart?: () => void onOpenedFileDiff?: (relativePath: string) => void + // When the panel runs inside the hub, "History" switches the segment instead of + // pushing the standalone route. Absent for the standalone/dock usage. + onOpenHistory?: () => void } export function useMobileSourceControlState(params: MobileSourceControlStateParams) { @@ -53,7 +56,8 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara embedded, onRequestClose, onFileOpenStart, - onOpenedFileDiff + onOpenedFileDiff, + onOpenHistory } = params const insets = useSafeAreaInsets() const { client, state: connState } = useHostClient(hostId) @@ -197,7 +201,8 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara setShowBranchPicker, setCreatedPrUrl, setCreatedPrWarning, - recordCommitFailure + recordCommitFailure, + onOpenHistory }) const createPrAction = useMobileSourceControlCreatePrAction({ client, diff --git a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts new file mode 100644 index 00000000000..8dff075d85e --- /dev/null +++ b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const sessionRouteSource = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) + +// Why: iOS terminates an active keyboard-dictation (and IME) session whenever +// JS writes a value into the focused field that differs from the native text +// (RN applies it via setTextAndSelection / _setAttributedString). Terminal +// inputs therefore must echo the raw field text in their controlled value and +// apply dash normalization only on the send/mirror path. See stablyai/orca#7925. +describe('terminal iOS dictation write-back', () => { + it('does not write normalized text back into the buffered command input value', () => { + expect(sessionRouteSource).toContain('onChangeText={setInput}') + expect(sessionRouteSource).not.toContain( + 'setInput((previousText) => normalizeTerminalTextInput' + ) + }) + + it('still normalizes the buffered command text at send time', () => { + expect(sessionRouteSource).toContain('normalizeTerminalTextInput(input)') + }) +}) diff --git a/mobile/src/terminal/terminal-keyboard-dismiss.test.ts b/mobile/src/terminal/terminal-keyboard-dismiss.test.ts new file mode 100644 index 00000000000..1e523b5d5c5 --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-dismiss.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' + +import { dismissTerminalKeyboard } from './terminal-keyboard-dismiss' + +describe('dismissTerminalKeyboard', () => { + it('clears pending live input focus before blur and dismiss', () => { + const calls: string[] = [] + const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear')) + const liveInput = { blur: vi.fn(() => calls.push('live-blur')) } + const commandInput = { blur: vi.fn(() => calls.push('command-blur')) } + const dismissKeyboard = vi.fn(() => calls.push('dismiss')) + + dismissTerminalKeyboard({ + clearPendingLiveInputFocus, + commandInput, + dismissKeyboard, + liveInput + }) + + expect(calls).toEqual(['clear', 'live-blur', 'command-blur', 'dismiss']) + }) + + it('blurs both live and buffered command inputs', () => { + const liveInput = { blur: vi.fn() } + const commandInput = { blur: vi.fn() } + + dismissTerminalKeyboard({ + clearPendingLiveInputFocus: vi.fn(), + commandInput, + dismissKeyboard: vi.fn(), + liveInput + }) + + expect(liveInput.blur).toHaveBeenCalledTimes(1) + expect(commandInput.blur).toHaveBeenCalledTimes(1) + }) + + it('dismisses the keyboard without a live input handle', () => { + const commandInput = { blur: vi.fn() } + const dismissKeyboard = vi.fn() + + dismissTerminalKeyboard({ + clearPendingLiveInputFocus: vi.fn(), + commandInput, + dismissKeyboard, + liveInput: null + }) + + expect(commandInput.blur).toHaveBeenCalledTimes(1) + expect(dismissKeyboard).toHaveBeenCalledTimes(1) + }) + + it('dismisses the keyboard without a buffered command input handle', () => { + const liveInput = { blur: vi.fn() } + const dismissKeyboard = vi.fn() + + dismissTerminalKeyboard({ + clearPendingLiveInputFocus: vi.fn(), + commandInput: undefined, + dismissKeyboard, + liveInput + }) + + expect(liveInput.blur).toHaveBeenCalledTimes(1) + expect(dismissKeyboard).toHaveBeenCalledTimes(1) + }) + + it('still clears focus and dismisses when both input handles are missing', () => { + const calls: string[] = [] + const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear')) + const dismissKeyboard = vi.fn(() => calls.push('dismiss')) + + dismissTerminalKeyboard({ + clearPendingLiveInputFocus, + commandInput: undefined, + dismissKeyboard, + liveInput: null + }) + + expect(calls).toEqual(['clear', 'dismiss']) + }) +}) diff --git a/mobile/src/terminal/terminal-keyboard-dismiss.ts b/mobile/src/terminal/terminal-keyboard-dismiss.ts new file mode 100644 index 00000000000..502ed42750e --- /dev/null +++ b/mobile/src/terminal/terminal-keyboard-dismiss.ts @@ -0,0 +1,17 @@ +export type TerminalKeyboardDismissHandle = { blur: () => void } | null | undefined + +export type DismissTerminalKeyboardOptions = { + clearPendingLiveInputFocus: () => void + commandInput: TerminalKeyboardDismissHandle + dismissKeyboard: () => void + liveInput: TerminalKeyboardDismissHandle +} + +export function dismissTerminalKeyboard(options: DismissTerminalKeyboardOptions): void { + // Why: clear the queued live-input focus before blurring/dismissing so a + // pending deferred focus cannot re-open the iOS keyboard right after Hide. + options.clearPendingLiveInputFocus() + options.liveInput?.blur() + options.commandInput?.blur() + options.dismissKeyboard() +} diff --git a/mobile/src/terminal/terminal-text-input-normalization.test.ts b/mobile/src/terminal/terminal-text-input-normalization.test.ts index 29e15ea321b..c5213013e31 100644 --- a/mobile/src/terminal/terminal-text-input-normalization.test.ts +++ b/mobile/src/terminal/terminal-text-input-normalization.test.ts @@ -11,10 +11,4 @@ describe('normalizeTerminalTextInput', () => { it('keeps ASCII hyphens unchanged', () => { expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file') }) - - it('preserves longer trailing hyphen runs when iOS re-collapses the controlled value', () => { - expect(normalizeTerminalTextInput('—', '--')).toBe('---') - expect(normalizeTerminalTextInput('—', '---')).toBe('----') - expect(normalizeTerminalTextInput('git checkout —', 'git checkout --')).toBe('git checkout ---') - }) }) diff --git a/mobile/src/terminal/terminal-text-input-normalization.ts b/mobile/src/terminal/terminal-text-input-normalization.ts index 70500421b3b..6316ce1bbaa 100644 --- a/mobile/src/terminal/terminal-text-input-normalization.ts +++ b/mobile/src/terminal/terminal-text-input-normalization.ts @@ -1,18 +1,9 @@ // Why: iOS smart punctuation can rewrite two ASCII hyphens into a single -// Unicode dash before React Native delivers terminal text input. +// Unicode dash before React Native delivers terminal text input. Each dash maps +// to exactly "--": recovering longer runs (#5222) needed the previous controlled +// value written back into the field, and that write-back kills iOS dictation (#7925). const IOS_SMART_DASH_REPLACEMENT_PATTERN = /[\u2013\u2014]/g -const IOS_SMART_DASH_REPLACEMENT_TEST = /[\u2013\u2014]/ -export function normalizeTerminalTextInput(text: string, previousText = ''): string { - const normalizedText = text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--') - const previousTrailingHyphens = /-+$/.exec(previousText)?.[0] ?? '' - const previousPrefix = previousText.slice(0, previousText.length - previousTrailingHyphens.length) - const collapsedPreviousHyphenRun = - previousTrailingHyphens.length >= 2 && - IOS_SMART_DASH_REPLACEMENT_TEST.test(text) && - (text === `${previousPrefix}\u2013` || text === `${previousPrefix}\u2014`) - if (collapsedPreviousHyphenRun) { - return `${previousText}-` - } - return normalizedText +export function normalizeTerminalTextInput(text: string): string { + return text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--') } diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index 3ddb8868270..b759e30d615 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -202,6 +202,32 @@ describe('terminal live input commit hook', () => { await vi.waitFor(() => expect(sent).toEqual(['a', 'b'])) }) + it('Given iOS smart-dash text When the change arrives Then the capture echoes the raw field text and the PTY gets normalized bytes', async () => { + // Given + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When: iOS smart punctuation rewrote "--" into an en dash inside the field + handlers.handleLiveInputChange('a–') + + // Then: writing "a--" back into the controlled value would kill an active + // iOS dictation/IME session, so the capture must keep what iOS produced + expect(captures).toEqual(['a–']) + await vi.waitFor(() => expect(sent).toEqual(['a--'])) + }) + + it('Given dictation-style hypothesis revisions When changes arrive Then the field is never rewritten and the PTY converges', async () => { + // Given + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When: iOS dictation replaces its hypothesis as recognition refines + handlers.handleLiveInputChange('high') + handlers.handleLiveInputChange('hi there') + + // Then: captures only echo the field; the mirror repairs the PTY with DELs + expect(captures).toEqual(['high', 'hi there']) + await vi.waitFor(() => expect(sent).toEqual(['high', '\x7f\x7f there'])) + }) + it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => { // Given const { handlers, sent } = createTerminalLiveInputCommitHarness() diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index cdabe1bed49..9c707dfbb31 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -109,9 +109,11 @@ export function useTerminalLiveInputCommit({ clearPendingLiveInputCommit() return } - const normalizedText = normalizeTerminalTextInput(text) - setLiveInputCapture(normalizedText) - applyLiveInputMirror(activeHandle, normalizedText) + // Why: iOS kills an active dictation/IME session when JS writes a value + // that differs from the native field text, so the controlled capture must + // echo the field verbatim; only the PTY mirror sees normalized text. + setLiveInputCapture(text) + applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text)) }, [ activeHandle, diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts new file mode 100644 index 00000000000..e9a298b6a74 --- /dev/null +++ b/mobile/src/transport/client-context.test.ts @@ -0,0 +1,159 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectionState } from './types' +import type { RpcClient } from './rpc-client' + +const connectMock = vi.fn() +const loadHostsMock = vi.fn() + +vi.mock('./rpc-client', () => ({ + connect: (...args: unknown[]) => connectMock(...args) +})) +vi.mock('./host-store', () => ({ + loadHosts: () => loadHostsMock() +})) +vi.mock('./connection-revival-triggers', () => ({ + subscribeConnectionRevivalTriggers: () => () => {} +})) + +import { RpcClientProvider, useCloseHost, useHostClient } from './client-context' + +type FakeClient = RpcClient & { + emitState: (state: ConnectionState) => void + closeMock: ReturnType +} + +function makeFakeClient(initialState: ConnectionState): FakeClient { + let state = initialState + const listeners = new Set<(state: ConnectionState) => void>() + const closeMock = vi.fn() + return { + sendRequest: vi.fn(), + subscribe: vi.fn(() => () => {}), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => state, + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + notifyForeground: vi.fn(), + close: closeMock, + closeMock, + emitState: (next) => { + state = next + for (const listener of listeners) { + listener(next) + } + } + } as FakeClient +} + +const HOST = { + id: 'host-1', + name: 'Host 1', + endpoint: 'ws://127.0.0.1:1', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 0 +} + +type Harness = { + readonly hook: ReturnType + readonly closeHost: (hostId: string) => void + readonly unmount: () => void +} + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => spy.mockRestore() +} + +async function renderHarness(hostId: string): Promise { + let hook: ReturnType | null = null + let closeHost: ((hostId: string) => void) | null = null + let renderer: ReactTestRenderer | null = null + + function Probe(): null { + hook = useHostClient(hostId) + closeHost = useCloseHost() + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) + } finally { + restore() + } + if (!hook || !closeHost || !renderer) { + throw new Error('harness did not render') + } + const mounted = renderer as ReactTestRenderer + return { + get hook() { + if (!hook) { + throw new Error('hook not rendered') + } + return hook + }, + closeHost: (id) => { + if (!closeHost) { + throw new Error('closeHost not rendered') + } + closeHost(id) + }, + unmount: () => mounted.unmount() + } +} + +beforeEach(() => { + connectMock.mockReset() + loadHostsMock.mockReset() +}) + +describe('useHostClient', () => { + it('drops the closed client when the host entry is removed', async () => { + const fake = makeFakeClient('connected') + connectMock.mockReturnValue(fake) + loadHostsMock.mockResolvedValue([HOST]) + + const harness = await renderHarness(HOST.id) + expect(harness.hook.client).toBe(fake) + expect(harness.hook.state).toBe('connected') + + // Regression (STA-1511): closeHost deletes the entry; before the fix the + // hook kept handing out the closed client, so mounted screens kept + // driving requests that could never resolve. + await act(async () => { + harness.closeHost(HOST.id) + }) + expect(fake.closeMock).toHaveBeenCalled() + expect(harness.hook.client).toBeNull() + expect(harness.hook.state).toBe('disconnected') + + harness.unmount() + }) + + it('reports disconnected instead of hanging when the host id is unknown', async () => { + loadHostsMock.mockResolvedValue([]) + + const harness = await renderHarness('missing-host') + expect(connectMock).not.toHaveBeenCalled() + expect(harness.hook.client).toBeNull() + expect(harness.hook.state).toBe('disconnected') + + harness.unmount() + }) +}) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index f889cba51ff..41f1b5092e8 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -20,6 +20,7 @@ import { type ReactNode } from 'react' import { connect, type RpcClient } from './rpc-client' +import { connectionLogStore } from './connection-log-buffer' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { loadHosts } from './host-store' import type { ConnectionState, HostProfile } from './types' @@ -133,6 +134,11 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { return null } if (!host) { + // Why: returning silently leaves mounted screens on a permanent + // spinner (STA-1511) — surface 'disconnected' so they can render + // their waiting/retry affordance instead. + notifyHostState(hostId, 'disconnected') + notifyAllHosts() return null } } @@ -145,7 +151,12 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { let client: RpcClient try { - client = connect(host.endpoint, host.deviceToken, host.publicKeyB64) + client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, { + // Why: retain reconnect lifecycle events for the Connection Log + // screen — without this the reasons a host is stuck live only in + // console.log, which users can't see or share. + onLog: (entry) => connectionLogStore.append(hostId, entry) + }) } catch { // Why: connect() can throw synchronously if the public key is // malformed or the endpoint URL is invalid. Notify so the UI @@ -406,6 +417,12 @@ export function useHostClient(hostId: string | undefined): { if (found && found.client !== clientRef.current) { clientRef.current = found.client force((n) => n + 1) + } else if (!found && clientRef.current) { + // Why: closeHost deletes the entry without a replacement; holding the + // closed client would let screens keep issuing requests that can never + // resolve (STA-1511). Null it so they render disconnected states. + clientRef.current = null + force((n) => n + 1) } }) const initial = ctx.acquire(hostId) diff --git a/mobile/src/transport/connection-health.test.ts b/mobile/src/transport/connection-health.test.ts new file mode 100644 index 00000000000..22e752a6335 --- /dev/null +++ b/mobile/src/transport/connection-health.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { classifyConnection, verdictDisplayLabel } from './connection-health' + +describe('classifyConnection Tailscale hint', () => { + const base = { + state: 'reconnecting' as const, + lastConnectedAt: null, + nowMs: 1_000_000 + } + + it('adds the hint to the warning verdict for a tailnet CGNAT endpoint', () => { + const verdict = classifyConnection({ + ...base, + reconnectAttempts: 3, + endpoint: 'ws://100.65.9.106:6768' + }) + expect(verdict).toMatchObject({ kind: 'warning', hint: 'check Tailscale' }) + }) + + it('adds the hint to the unreachable verdict for a MagicDNS endpoint', () => { + const verdict = classifyConnection({ + ...base, + reconnectAttempts: 12, + endpoint: 'ws://my-desktop.tailnet-1234.ts.net:6768' + }) + expect(verdict).toMatchObject({ + kind: 'unreachable', + reason: 'never-connected', + hint: 'check Tailscale' + }) + }) + + it('keeps plain labels for LAN endpoints', () => { + const warning = classifyConnection({ + ...base, + reconnectAttempts: 3, + endpoint: 'ws://192.168.1.50:6768' + }) + expect(warning.kind).toBe('warning') + expect('hint' in warning && warning.hint).toBeFalsy() + }) + + it('keeps plain labels when no endpoint is provided', () => { + const verdict = classifyConnection({ ...base, reconnectAttempts: 3 }) + expect(verdict.kind).toBe('warning') + expect('hint' in verdict && verdict.hint).toBeFalsy() + }) + + it('never hints on healthy states', () => { + const verdict = classifyConnection({ + state: 'connected', + reconnectAttempts: 0, + lastConnectedAt: 999_000, + endpoint: 'ws://100.65.9.106:6768', + nowMs: 1_000_000 + }) + expect(verdict).toEqual({ kind: 'normal', label: 'Connected' }) + }) +}) + +describe('verdictDisplayLabel', () => { + it('appends the hint to warning and unreachable labels', () => { + expect( + verdictDisplayLabel({ kind: 'warning', label: "Can't connect", hint: 'check Tailscale' }) + ).toBe("Can't connect — check Tailscale") + expect( + verdictDisplayLabel({ + kind: 'unreachable', + label: "Can't reach desktop", + reason: 'stale', + hint: 'check Tailscale' + }) + ).toBe("Can't reach desktop — check Tailscale") + }) + + it('returns the bare label without a hint', () => { + expect(verdictDisplayLabel({ kind: 'warning', label: "Can't connect" })).toBe("Can't connect") + expect(verdictDisplayLabel({ kind: 'normal', label: 'Connected' })).toBe('Connected') + }) +}) diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index 4ce81beffd2..d3b8251fc1b 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -1,3 +1,4 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' import type { ConnectionState } from './types' // Why: thresholds for escalating connection UX from neutral @@ -11,7 +12,8 @@ import type { ConnectionState } from './types' // reuse the 60s cap). Combined with the never-connected / // stale-since-last-connect heuristic below, this is the trigger to // surface a "re-pair?" affordance. MUST stay aligned with -// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS. +// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS (past which the loop slows +// to a 90s trickle instead of parking). // - STALE_SINCE_LAST_CONNECT_MS: 60s → if we WERE connected this // session but haven't been for ≥ 1 minute despite the retry loop // spinning, treat the same as never-connected. Catches the case @@ -20,10 +22,21 @@ const WARNING_ATTEMPTS = 3 const UNREACHABLE_ATTEMPTS = 12 const STALE_SINCE_LAST_CONNECT_MS = 60_000 +// Why: a repeatedly-unreachable 100.x/*.ts.net endpoint almost always means +// the phone's Tailscale tunnel is down or wedged (a known iOS failure mode +// that only a manual toggle fixes) — not that the desktop moved. Say so +// instead of leaving the user staring at a generic "Can't connect". +const TAILSCALE_HINT = 'check Tailscale' + export type ConnectionVerdict = | { kind: 'normal'; label: string } - | { kind: 'warning'; label: string } // "Can't connect" - | { kind: 'unreachable'; label: string; reason: 'never-connected' | 'stale' } + | { kind: 'warning'; label: string; hint?: string } // "Can't connect" + | { + kind: 'unreachable' + label: string + reason: 'never-connected' | 'stale' + hint?: string + } | { kind: 'auth-failed'; label: string } // Why: the rpc-client's lastConnectedAt is a one-shot timestamp; we have @@ -33,10 +46,14 @@ export function classifyConnection(args: { state: ConnectionState reconnectAttempts: number lastConnectedAt: number | null + // Optional pinned host endpoint — enables the Tailscale hint on + // warning/unreachable verdicts. Callers without it get plain labels. + endpoint?: string | null nowMs?: number }): ConnectionVerdict { const { state, reconnectAttempts, lastConnectedAt } = args const now = args.nowMs ?? Date.now() + const hint = isTailscaleEndpoint(args.endpoint) ? TAILSCALE_HINT : undefined if (state === 'auth-failed') { return { kind: 'auth-failed', label: 'Auth failed' } @@ -60,21 +77,32 @@ export function classifyConnection(args: { return { kind: 'unreachable', label: "Can't reach desktop", - reason: 'never-connected' + reason: 'never-connected', + hint } } if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) { return { kind: 'unreachable', label: "Can't reach desktop", - reason: 'stale' + reason: 'stale', + hint } } } if (reconnectAttempts >= WARNING_ATTEMPTS) { - return { kind: 'warning', label: "Can't connect" } + return { kind: 'warning', label: "Can't connect", hint } } return { kind: 'normal', label: 'Reconnecting…' } } + +// Why: single place that turns a verdict into display text so every screen +// renders the Tailscale hint the same way. +export function verdictDisplayLabel(verdict: ConnectionVerdict): string { + if ((verdict.kind === 'warning' || verdict.kind === 'unreachable') && verdict.hint) { + return `${verdict.label} — ${verdict.hint}` + } + return verdict.label +} diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts new file mode 100644 index 00000000000..e7ce662534b --- /dev/null +++ b/mobile/src/transport/connection-log-buffer.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { createConnectionLogStore } from './connection-log-buffer' +import type { ConnectionLogEntry } from './types' + +function entry(id: number): ConnectionLogEntry { + return { id: `log-${id}`, ts: 1_000 + id, level: 'info', message: `event ${id}` } +} + +describe('connection log buffer', () => { + it('keeps entries per host without cross-talk', () => { + const store = createConnectionLogStore() + store.append('host-a', entry(1)) + store.append('host-b', entry(2)) + + expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1']) + expect(store.get('host-b').map((e) => e.id)).toEqual(['log-2']) + }) + + it('drops the oldest entries past the cap', () => { + const store = createConnectionLogStore(3) + for (let i = 1; i <= 5; i++) { + store.append('host-a', entry(i)) + } + + expect(store.get('host-a').map((e) => e.id)).toEqual(['log-3', 'log-4', 'log-5']) + }) + + it('returns a stable snapshot reference until the next append', () => { + const store = createConnectionLogStore() + store.append('host-a', entry(1)) + + const first = store.get('host-a') + expect(store.get('host-a')).toBe(first) + + store.append('host-a', entry(2)) + expect(store.get('host-a')).not.toBe(first) + // Empty hosts must also be referentially stable (useSyncExternalStore). + expect(store.get('host-b')).toBe(store.get('host-b')) + }) + + it('notifies only the host being appended to and stops after unsubscribe', () => { + const store = createConnectionLogStore() + const onA = vi.fn() + const onB = vi.fn() + const unsubA = store.subscribe('host-a', onA) + store.subscribe('host-b', onB) + + store.append('host-a', entry(1)) + expect(onA).toHaveBeenCalledTimes(1) + expect(onB).not.toHaveBeenCalled() + + unsubA() + store.append('host-a', entry(2)) + expect(onA).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts new file mode 100644 index 00000000000..1b223ad8e1f --- /dev/null +++ b/mobile/src/transport/connection-log-buffer.ts @@ -0,0 +1,84 @@ +import type { ConnectionLogEntry } from './types' + +// Why: the rpc-client's onLog entries were only wired during pairing; for +// long-lived host connections everything went to console.log, invisible to +// users. This buffer retains the recent lifecycle events per host so a +// "Connection log" screen (and copy-diagnostics) can show why a connection +// is stuck without a debug build. Module-level so the log survives client +// swaps (forceReconnect) and provider remounts (hot reload); bounded so an +// all-night reconnect loop can't grow memory unbounded. +const MAX_ENTRIES_PER_HOST = 200 + +export type ConnectionLogStore = { + append: (hostId: string, entry: ConnectionLogEntry) => void + get: (hostId: string) => readonly ConnectionLogEntry[] + subscribe: (hostId: string, listener: () => void) => () => void +} + +export function createConnectionLogStore( + maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST +): ConnectionLogStore { + const entriesByHost = new Map() + const listenersByHost = new Map void>>() + // Why: useSyncExternalStore compares snapshots by reference — getSnapshot + // must return the SAME array until the data actually changes, or React + // loops re-rendering. Cache per host; invalidate on append. + const snapshotByHost = new Map() + const EMPTY: readonly ConnectionLogEntry[] = [] + + return { + append(hostId, entry) { + let entries = entriesByHost.get(hostId) + if (!entries) { + entries = [] + entriesByHost.set(hostId, entries) + } + entries.push(entry) + if (entries.length > maxEntriesPerHost) { + entries.splice(0, entries.length - maxEntriesPerHost) + } + snapshotByHost.delete(hostId) + const listeners = listenersByHost.get(hostId) + if (listeners) { + for (const listener of listeners) { + listener() + } + } + }, + + get(hostId) { + const cached = snapshotByHost.get(hostId) + if (cached) { + return cached + } + const entries = entriesByHost.get(hostId) + if (!entries || entries.length === 0) { + return EMPTY + } + const snapshot = Object.freeze([...entries]) + snapshotByHost.set(hostId, snapshot) + return snapshot + }, + + subscribe(hostId, listener) { + let listeners = listenersByHost.get(hostId) + if (!listeners) { + listeners = new Set() + listenersByHost.set(hostId, listeners) + } + listeners.add(listener) + return () => { + const set = listenersByHost.get(hostId) + if (!set) { + return + } + set.delete(listener) + if (set.size === 0) { + listenersByHost.delete(hostId) + } + } + } + } +} + +export const connectionLogStore = createConnectionLogStore() diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index e096ee659c9..b2adb18c4ce 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -618,28 +618,42 @@ describe('mobile rpc-client connection timeout', () => { return { client, socket } } - it('repro: a parked reconnect loop never retries on its own', async () => { + // Why 520_000ms: the 12 fast attempts cost Σ(RECONNECT_DELAYS) 360.5s of + // backoff plus 13 × 12s connect timeouts ≈ 516.5s, so 520s lands just + // past the give-up cap with the first trickle timer armed. + const PAST_GIVE_UP_CAP_MS = 520_000 + + it('keeps trickle-retrying after the give-up cap instead of parking', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') openAndAuthenticate(mockSockets[0]!) mockSockets[0]!.close() - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(PAST_GIVE_UP_CAP_MS) expect(client.getState()).toBe('reconnecting') - expect(client.getReconnectAttempt()).toBe(12) + expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(12) - // Stuck: arbitrary additional time produces no further attempts. + // A wedged VPN produces no revival nudge (issue #7824) — the loop must + // keep dialing on its own at the 90s trickle cadence. const socketsBefore = mockSockets.length - await vi.advanceTimersByTimeAsync(600_000) - expect(mockSockets.length).toBe(socketsBefore) + await vi.advanceTimersByTimeAsync(102_000) + expect(mockSockets.length).toBeGreaterThan(socketsBefore) + + // Once the tunnel heals, a trickle dial restores the session without + // any user action. 75s lands inside the next dial's 12s connect window + // (the prior dial failed mid-advance above, re-arming the 90s timer). + await vi.advanceTimersByTimeAsync(75_000) + openAndAuthenticate(mockSockets[mockSockets.length - 1]!) + expect(client.getState()).toBe('connected') + expect(client.getReconnectAttempt()).toBe(0) client.close() }) - it('restarts a parked reconnect loop on foreground', async () => { + it('restarts a backed-off reconnect loop on foreground without waiting out the trickle', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') openAndAuthenticate(mockSockets[0]!) mockSockets[0]!.close() - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(PAST_GIVE_UP_CAP_MS) expect(client.getReconnectAttempt()).toBe(12) const socketsBefore = mockSockets.length @@ -921,7 +935,7 @@ describe('mobile rpc-client connection timeout', () => { () => null, (error: Error) => error ) - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(520_000) expect(client.getState()).toBe('reconnecting') expect(client.getReconnectAttempt()).toBe(12) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 2cd484de4b8..b9d0c990261 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -103,16 +103,22 @@ export type RpcClient = { // time across all 12 attempts is ≈ 6 minutes before the give-up cap // fires (0.5+1+2+4+8+15+30+60+60+60+60+60 ≈ 360s). const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000] -// Why: cap auto-retry once we're clearly unreachable for a long time. +// Why: cap fast auto-retry once we're clearly unreachable for a long time. // With the tiered backoff above this is ≈ 6 minutes of continuous -// failure before we stop and surface the re-pair banner. The longer +// failure before the UI surfaces the re-pair banner. The longer // runway tolerates flaky AP-isolation routers and laptop sleep cycles // that briefly drop the LAN path. MUST stay aligned with // connection-health.ts UNREACHABLE_ATTEMPTS so the "unreachable" -// verdict matches the moment the loop actually pauses — if these -// drift the user sees "Reconnecting…" while the loop is silently -// parked. +// verdict matches the moment the loop slows to the trickle cadence. const GIVE_UP_AFTER_ATTEMPTS = 12 +// Why: past the cap the loop must never park permanently. A wedged +// Tailscale/VPN tunnel produces no AppState or network-type transition +// (still Wi-Fi, still "online"), so no revival nudge ever fires — users +// had to toggle Tailscale off/on just to force one. A slow trickle dial +// self-heals once the tunnel recovers while staying cheap: one TCP +// attempt per 90s, foreground-only (iOS/Android suspend JS timers in +// the background). +const TRICKLE_RECONNECT_DELAY_MS = 90_000 // Why: a single `unauthorized`/`e2ee_error` is not proof the pairing is dead. // Issue #5200: a tablet showed "Auth failed" and forced a needless re-pair // while the desktop still listed it as paired with a valid token — a transient @@ -280,9 +286,11 @@ export function connect( if (intentionallyClosed) { return Promise.reject(new Error('Client closed')) } - if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS && !reconnectTimer) { - // Why: after the retry cap there is no future state transition to - // release callers waiting before their per-request timeout starts. + if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { + // Why: past the retry cap the loop only trickles every 90s — callers + // must fail fast rather than hang on a host that's been unreachable + // for minutes. A trickle dial that succeeds flips state to 'connected' + // and later requests go through normally. return Promise.reject(new Error('Connection retry limit reached')) } return new Promise((resolve, reject) => { @@ -773,27 +781,35 @@ export function connect( } function scheduleReconnect() { - // Why: spinning reconnect forever drains battery and floods logs + // Why: spinning fast reconnects forever drains battery and floods logs // when the host is genuinely unreachable (wrong IP, port closed, - // host moved). Cap at GIVE_UP_AFTER_ATTEMPTS — the UI surfaces a - // "Can't reach desktop, re-pair?" banner at this point and the - // user can tap Retry (forceReconnect creates a fresh client, - // resetting the counter) or Re-pair. Without an explicit cap the - // worst-case is a phone left on the home screen burning a socket - // open every 4s indefinitely. - if (reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { - console.log('[net] reconnect-paused', { - attempt: reconnectAttempt, - reason: 'give-up-cap', - endpoint: redactedEndpoint(endpoint) - }) + // host moved). Past GIVE_UP_AFTER_ATTEMPTS the UI surfaces a + // "Can't reach desktop, re-pair?" banner and the loop drops to the + // 90s trickle cadence instead of parking — a permanently parked loop + // could only be revived by an AppState/network transition, which a + // wedged VPN tunnel never produces. + const pastGiveUpCap = reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS + let delay: number + if (pastGiveUpCap) { + // Why: the counter holds at the cap — connection-health thresholds and + // the "Can't reach desktop" verdict key off attempts >= 12, and a + // successful open resets it to 0 anyway. + delay = TRICKLE_RECONNECT_DELAY_MS rejectConnectWaiters('Connection retry limit reached') - return + } else { + delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! + reconnectAttempt++ } - const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! - reconnectAttempt++ - console.log('[net] scheduleReconnect', { delayMs: delay, attempt: reconnectAttempt }) - emitLog('info', `Reconnect scheduled in ${delay}ms`, `Attempt ${reconnectAttempt}`) + console.log('[net] scheduleReconnect', { + delayMs: delay, + attempt: reconnectAttempt, + trickle: pastGiveUpCap + }) + emitLog( + 'info', + `Reconnect scheduled in ${delay}ms`, + pastGiveUpCap ? `Attempt ${reconnectAttempt} (slow retry)` : `Attempt ${reconnectAttempt}` + ) reconnectTimer = setTimeout(() => { reconnectTimer = null openConnection() @@ -1176,10 +1192,10 @@ export function connect( return } if (state === 'reconnecting') { - // Why: while backgrounded the retry loop may have parked at the - // give-up cap or be sitting on a 60s backoff timer. Returning to - // the foreground is a strong user signal — restart with a fresh - // attempt budget immediately instead of requiring an app restart. + // Why: while backgrounded the retry loop may be sitting on a 60s + // backoff or 90s trickle timer. Returning to the foreground is a + // strong user signal — restart with a fresh attempt budget + // immediately instead of waiting out the timer. console.log('[net] foreground — restarting reconnect loop', { attempt: reconnectAttempt, hadTimer: !!reconnectTimer diff --git a/mobile/src/worktree/worktree-activation-result.test.ts b/mobile/src/worktree/worktree-activation-result.test.ts new file mode 100644 index 00000000000..11abc5e6970 --- /dev/null +++ b/mobile/src/worktree/worktree-activation-result.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { headlessActivationNeedsHostRenderer } from './worktree-activation-result' + +describe('headlessActivationNeedsHostRenderer', () => { + it('only reports the explicit sleeping-agent headless outcome', () => { + expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'unsupported-headless' })).toBe( + true + ) + expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'not-applicable' })).toBe(false) + expect(headlessActivationNeedsHostRenderer({ sleepingAgentWake: 'requested' })).toBe(false) + expect(headlessActivationNeedsHostRenderer(null)).toBe(false) + }) +}) diff --git a/mobile/src/worktree/worktree-activation-result.ts b/mobile/src/worktree/worktree-activation-result.ts new file mode 100644 index 00000000000..101fc2c6cd0 --- /dev/null +++ b/mobile/src/worktree/worktree-activation-result.ts @@ -0,0 +1,8 @@ +export function headlessActivationNeedsHostRenderer(result: unknown): boolean { + return Boolean( + result && + typeof result === 'object' && + 'sleepingAgentWake' in result && + result.sleepingAgentWake === 'unsupported-headless' + ) +} diff --git a/mobile/vitest.config.ts b/mobile/vitest.config.ts index ae56f5bdd2a..9e7e3ecc59d 100644 --- a/mobile/vitest.config.ts +++ b/mobile/vitest.config.ts @@ -1,25 +1,12 @@ import { defineConfig } from 'vitest/config' -const tsconfigRaw = JSON.stringify({ - compilerOptions: { - jsx: 'react-jsx', - module: 'esnext', - moduleResolution: 'bundler', - strict: true, - target: 'es2022' - } -}) +const vitestOxcConfig = { tsconfig: false } as never export default defineConfig({ root: import.meta.dirname, - esbuild: { - tsconfigRaw - }, - optimizeDeps: { - esbuildOptions: { - tsconfigRaw - } - }, + // Why: the app tsconfig intentionally excludes tests; Vite 8's OXC transform + // otherwise fails before Vitest can run the test modules. + oxc: vitestOxcConfig, test: { environment: 'node', include: ['src/**/*.test.ts'] diff --git a/native/notification-status-macos/main.swift b/native/notification-status-macos/main.swift new file mode 100644 index 00000000000..fa8c944ec4d --- /dev/null +++ b/native/notification-status-macos/main.swift @@ -0,0 +1,35 @@ +// Prints the app's macOS notification settings as JSON and exits. +// +// Why this exists: Electron exposes no API for UNUserNotificationCenter +// authorization, and scheduling silently succeeds even while macOS suppresses +// display, so the renderer cannot know whether the user actually receives +// notifications. This binary must run from inside the app bundle (NSBundle +// resolves the bundle by walking up from the executable path) and must be +// code-signed with the app's identifier — macOS keys notification records to +// the signing identifier, which is why the build embeds an Info.plist section +// with the target CFBundleIdentifier. +import Foundation +import UserNotifications + +let semaphore = DispatchSemaphore(value: 0) +var authorization = "unknown" +var alert = "unknown" +UNUserNotificationCenter.current().getNotificationSettings { settings in + switch settings.authorizationStatus { + case .authorized: authorization = "authorized" + case .provisional: authorization = "provisional" + case .ephemeral: authorization = "ephemeral" + case .denied: authorization = "denied" + case .notDetermined: authorization = "not-determined" + @unknown default: authorization = "unknown" + } + switch settings.alertSetting { + case .enabled: alert = "enabled" + case .disabled: alert = "disabled" + case .notSupported: alert = "not-supported" + @unknown default: alert = "unknown" + } + semaphore.signal() +} +_ = semaphore.wait(timeout: .now() + 3) +print("{\"authorization\":\"\(authorization)\",\"alert\":\"\(alert)\"}") diff --git a/package.json b/package.json index a793e5c51af..a24b1d1d669 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.126", + "version": "1.4.131-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -42,6 +42,7 @@ "dev:web": "vite --config vite.web.config.ts --host 127.0.0.1", "build:relay": "node config/scripts/build-relay.mjs", "build:computer-macos": "node config/scripts/build-computer-macos.mjs", + "build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs", "build:native": "node config/scripts/build-native-for-platform.mjs", "smoke:computer": "node config/scripts/computer-use-smoke.mjs", "verify:computer-native": "node config/scripts/verify-computer-native.mjs", @@ -68,8 +69,8 @@ "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", - "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", - "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", @@ -81,6 +82,7 @@ "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", + "win-update-e2e": "node tools/win-update-e2e/run.mjs", "test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts", diff --git a/previews/mobile-driver-overlay-preview.html b/previews/mobile-driver-overlay-preview.html deleted file mode 100644 index 8c300f3dcad..00000000000 --- a/previews/mobile-driver-overlay-preview.html +++ /dev/null @@ -1,523 +0,0 @@ - - - - - - Mobile Driver Overlay — Orca Preview - - - -
-

Mobile Driver Overlay — production layout preview

-
- - - -
-
- -
-
-
-
- bash — fix-phone-resizing -
- -
- - - - - - - - - - -
-
-
-
- - - - \ No newline at end of file diff --git a/prod-release-scan-1.4.131-rc2-output.md b/prod-release-scan-1.4.131-rc2-output.md new file mode 100644 index 00000000000..4e5b0188ccd --- /dev/null +++ b/prod-release-scan-1.4.131-rc2-output.md @@ -0,0 +1,152 @@ +# Prod Release Scan — output.md + +**Verdict:** ready with accepted P1 risk +**Span:** `v1.4.130` (`31378023`) .. `v1.4.131-rc.2` (`6964bfb23`) +**Planned release / RC:** `1.4.131-rc.2` (user asked for 1.4.132-rc2; that tag does not exist — scanned confirmed substitute) +**Included PRs:** 8 matched (+ `#7567` referenced only in `#7658` title, not a separate PR); unmatched commits: 5 +**Batches reviewed:** 6 (agent/mobile, codex/speech/WSL, UI defaults, xplat/remoting, perf, security) +**Security model:** `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"` (W6 + all workers) +**Orchestration:** 6 same-worktree tasks injected; `task-list`/`dispatch-show` verified + +--- + +## Issue table by PR / commit + +### Has issues + +| Ref | Title | Severity | Issue summary | +|-----|-------|----------|---------------| +| **#7906** | Wake slept agents when opening a worktree on mobile | **P1** (×3) | (1) Non-passive / manual-sleep wake on an **unmounted** worktree creates a tab with `activate:false` but never background-mounts → **no PTY / resume lost**. (2) Mounted manual-sleep can **double-resume** the same provider session (in-place wake + generic resume). (3) Edge-triggered wake can be **lost** if mobile opens during hibernation kill before `hibernatedWakePtyId` is armed. | +| **#7906** | (perf composition) | **P1** | Passive completed wake background-mounts the **entire** worktree → O(all saved tabs) PTY/xterm work, defeating unvisited-worktree mass-spawn guard. | +| **#7906** | (headless remoting) | **P1** | Wake is a no-op without a live renderer window; headless `orca serve --mobile-pairing` activates without waking slept agents. | +| **#7960** | Write in-Codex setting changes back to `~/.codex` | **P1** (×2–3) | (1) Missing `~/.codex/` parent → `ENOENT` on promote, then mirror **wipes** runtime setting (test pre-creates dir and misses this). (2) `writeFileAtomically` without `mode` widens **0600 → 0644** on promote (reproduced). (3) Host-only promotion path: **WSL Codex never promotes** (WSL prep skips `syncSystemConfigIntoManagedCodexHome`). | +| **#7658** | Recover WSL Codex usage RPC refresh | **P2** | Every WSL quota poll uses interactive login shell (`-ilc` + discovery chain); heavy rc cost on 15m poll / double on PTY fallback. | +| **#7977** | Disable experimental new worktree card style by default | **P2** | Fresh default is correctly off, but cohort **auto-enabled in v1.4.130** stays on via persisted `true` (no rollback migration). | +| **#7845** | Sidebar scrollbar only when overflow | **P2** (docs only) | Behavior OK; `docs/STYLEGUIDE.md` still says gutter is reserved — stale contract. | + +### No issues found (clean for this scan) + +| Ref | Title | Notes | +|-----|-------|-------| +| **#7860** | Separate pane identity from liveness (OMP tab flicker) | Identity vs liveness separation consistent across tab icon, PTY owner, web mirror; focused-pane owner re-owns Pi-compatible titles; no proven regression. | +| **#7932** | Chunk offline audio decoding (ONNX SIGTRAP) | 30s chunk bound + quiet split; worker-side decode; pure chunker tests pass; no malware/native dep delta. Residual: no packaged ONNX runtime on all platforms. | +| **#7933** | iOS native keyboard dictation in mobile terminal | Raw field echo; normalize only on send/mirror; no double-send proven. Mobile vitest not run (missing Expo deps in this worktree). | +| `775fa956c` | Remove mobile driver overlay preview | Delete-only HTML mock. | +| `8adfef4ff` | Update README downloads badge | `4.0m` → `4.1m` only. | +| `ff661a7ab` / `41edc493b` / `6964bfb23` | release: v1.4.131-rc.{0,1,2} | Version bumps in `package.json` only. | + +--- + +## Findings first + +### P0 — Release Blocking + +None. + +No malware, backdoor, supply-chain implant, lockfile/dependency implant, install-hook change, or updater/signing bypass in this span. + +### P1 — Releasable Only With Explicit Acceptance + +1. **#7906 Mobile wake — unmounted non-passive sleep never gets a PTY** + `wake-sleeping-agents-in-background.ts:43-56` only background-mounts for passive completed records; non-passive path creates inactive tab and clears the sleep record without a mounted `TerminalPane`. + *Evidence:* `wake-sleeping-agents-in-background.ts`, `resume-sleeping-agent-session.ts:93-118`, `Terminal.tsx:729-778`. + +2. **#7906 Mobile wake — double resume of same provider session** + Broadcast in-place wake then generic resume for non-passive mounted records before spawn clears the record. + *Evidence:* `wake-sleeping-agents-in-background.ts:37-56`, `pty-connection.ts:6247-6255`, `resume-sleeping-agent-session.ts:279-315`. + +3. **#7906 Mobile wake — race during hibernation kill** + Wake before `hibernatedWakePtyId` is armed is edge-triggered and not retried; passive path keeps record without launching. + *Evidence:* `pty-connection.ts:2090-2169`, `orca-runtime.ts` fire-and-forget notifier. + +4. **#7906 Perf — whole-worktree background mount** + One passive sleep record mounts every terminal tab in the worktree permanently. + *Evidence:* `wake-sleeping-agents-in-background.ts:46-54`, `background-terminal-worktree-visibility.ts`, `Terminal.tsx` mount model. + +5. **#7906 Headless mobile pairing — wake no-op** + No renderer ⇒ no wake; headless serve still returns activated. + *Evidence:* `orca-runtime.ts:12627-12633`, `orca-runtime.test.ts` “deliberate non-goal”, `mobile/scripts/start-emulator-pairing-runtime.mjs`. + +6. **#7960 Missing `~/.codex` directory loses first promoted setting** + Promote does not `mkdir` system home; `ENOENT` swallowed; mirror then clears runtime value. Test pre-creates `.codex` and misses the case. + *Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:6-14`, `config-settings-promotion.test.ts:190-191`. Reproduced: missing parent → `ENOENT`. + +7. **#7960 Permission widening on promote (privacy)** + Atomic write without mode: `0600` → `0644` under umask `0022` (reproduced). Full user config becomes world-readable to co-tenants when parent dirs are traversable. + *Evidence:* `config-settings-promotion.ts:191-192`, `fs-utils.ts:11-14`. + +8. **#7960 WSL never gets promotion** + WSL launch/rate-limit prep returns after `syncWslRuntimeForCurrentSelection` and never calls host mirror/promotion. Feature is host-only. + *Evidence:* `runtime-home-service.ts:139-150,225-235`. + +### P2 — Nice To Have + +- **#7658** Interactive login shell on every WSL rate-limit poll (`buildWslLoginShellCommand` / `-ilc`). +- **#7960** Unchanged baseline still rewritten twice per quota cycle; CRLF insert can mix EOL on new keys. +- **#7977** Auto-enabled experimental card cohort not rolled back. +- **#7845** STYLEGUIDE scrollbar gutter text out of date. + +--- + +## Clean matrix + +| Area | Status | +|------|--------| +| Security / malware / supply-chain | **Clean of malice.** Lockfile unchanged; package scripts unchanged; version-only package.json. **P1 residual:** mode widen on `~/.codex/config.toml` promote. | +| Performance / resource use | **P1** mobile whole-worktree mount; **P2** baseline double-write + WSL interactive poll cost. STT chunking is an improvement. | +| macOS / Linux / Windows / WSL / PowerShell | **P1** WSL promotion gap; **P2** WSL interactive poll; CRLF insert. No new hard-coded Mac shortcuts. Real Win/WSL runtime not executed. | +| Mobile / paired web / Electron | **P1** mobile wake correctness/composition gaps; #7860 / #7933 clean by source audit. | +| Local / SSH / remote / VM / container | Wake path failures apply to local and SSH worktrees once host renderer is involved. Headless serve gap explicit. | +| Git providers / shared contracts | Not materially touched. | +| Release / package / update path | Version bumps only; no workflow/signing/native rebuild changes. | + +--- + +## Verification + +| Check | Result | +|-------|--------| +| `git diff --check v1.4.130..v1.4.131-rc.2` | Passed | +| Required security grep on non-lockfile span | 2 hits — both TOML `.exec` parsers in #7960; inspected, benign | +| `package.json` / lockfile | Version field only; `pnpm-lock.yaml` no diff | +| Mode repro (`writeFileSync` without mode) | `before 600` → `after 644` | +| Missing parent repro | `ENOENT` | +| Worker unit tests (various focused suites) | Reported green by workers (promotion, STT chunker, rate-limit, wake helpers, pane owner, etc.) | +| Mobile Expo vitest / device / real WSL / ONNX packaged | **Not run** in this environment | + +--- + +## Orchestration provenance + +| Worker | Task | Terminal | Report | +|--------|------|----------|--------| +| W1 agent/mobile | `task_20d8fd2460cc` | `term_40e66a33-…` | `reports/w1-agent-mobile.md` | +| W2 codex/speech/WSL | `task_6e280ce1760e` | `term_59f3ead8-…` | `reports/w2-codex-speech-wsl.md` | +| W3 UI/defaults | `task_7140cb2014c9` | `term_4e274a95-…` | `reports/w3-ui-defaults.md` | +| W4 xplat/remoting | `task_424816ffe823` | `term_03c7aac8-…` | `reports/w4-xplat-remoting.md` | +| W5 perf | `task_1c5ac42d7659` | `term_24778551-…` | `reports/w5-perf.md` | +| W6 security | `task_bf5de6f4913a` | `term_ae19a68d-…` | `reports/w6-security.md` | + +All workers: `codex --model gpt-5.6-sol -c model_reasoning_effort="xhigh"`, same worktree, `--inject` dispatch. + +--- + +## Residual risk + +- No runtime on physical iOS/Android, Windows+WSL2, or packaged sherpa/ONNX for all platforms. +- Lockfile/binary deltas fully inspected: **none**. Install hooks unchanged. +- #7906 integration of IPC wake × mounted/unmounted panes × async PTY exit is under-tested relative to risk. +- #7960 Windows ACL inheritance and concurrent external `~/.codex` edits not runtime-tested; POSIX mode widen is proven. + +--- + +## Bottom line + +**No P0.** Multiple **P1**s cluster on **#7906 (mobile wake)** and **#7960 (Codex settings promotion)**. Ship only if product explicitly accepts those gaps, or fix before stable: + +1. Always background-mount (or otherwise attach) when creating a navigation-suppressed resume tab. +2. Deduplicate in-place hibernation wake vs generic resume for the same claim key. +3. `mkdir` system `~/.codex` before promote; pass `mode: 0o600` (or preserve existing mode) into `writeFileAtomically`. +4. Decide WSL + headless pairing product stance (implement promotion/wake or document as unsupported). + +Clean shippable areas for this span: **#7860**, **#7932**, **#7933**, release version commits, README badge, preview delete; **#7845** functionally clean with docs P2. diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 5ed9578685a..4ae4c962d59 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -92,32 +92,32 @@ describe('agent hooks CLI handler', () => { rmSync(userDataPath, { recursive: true, force: true }) }) - it('keeps the fresh-profile new card style default when creating offline settings', async () => { + it('keeps new card style off when creating offline settings for a fresh profile', async () => { await runAgentHooksOff(userDataPath) const persisted = readDataFile(userDataPath) - expect(persisted.settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(persisted.settings.experimentalNewWorktreeCardStyle).toBe(false) expect(persisted.settings.agentStatusHooksEnabled).toBe(false) }) - it('defaults missing new card style on while offline-updated onboarding is open', async () => { + it('keeps missing new card style off when updating offline settings', async () => { const existing = getDefaultPersistedState(userDataPath) delete existing.settings.experimentalNewWorktreeCardStyle writeDataFile(userDataPath, existing) await runAgentHooksOff(userDataPath) - expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(false) }) - it('preserves an existing explicit new card style opt-out when updating offline settings', async () => { + it('preserves an existing explicit new card style opt-in when updating offline settings', async () => { const existing = getDefaultPersistedState(userDataPath) - existing.settings.experimentalNewWorktreeCardStyle = false + existing.settings.experimentalNewWorktreeCardStyle = true writeDataFile(userDataPath, existing) await runAgentHooksOff(userDataPath) - expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(false) + expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) }) }) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 2419be2733f..2b83f5c15ea 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -31,16 +31,7 @@ function isRecord(value: unknown): value is Record { function readPersistedState(dataPath: string): PersistedState { if (!existsSync(dataPath)) { - const defaults = getDefaultPersistedState(homedir()) - return { - ...defaults, - settings: { - ...defaults.settings, - // Why: offline CLI can create the first profile before desktop load; - // match the Store fresh-install default instead of pinning old cards. - experimentalNewWorktreeCardStyle: true - } - } + return getDefaultPersistedState(homedir()) } try { const parsed = JSON.parse(readFileSync(dataPath, 'utf-8')) @@ -83,14 +74,9 @@ function readEnabledFromDisk(): boolean { function updateEnabledOnDisk(enabled: boolean): string { const dataPath = getDataPath() const state = readPersistedState(dataPath) - const experimentalNewWorktreeCardStyle = - state.settings?.experimentalNewWorktreeCardStyle ?? state.onboarding?.closedAt === null state.settings = { ...getDefaultPersistedState(homedir()).settings, ...state.settings, - // Why: offline CLI can run before Store.load(); mirror its open-onboarding - // default without overriding a saved user opt-out. - experimentalNewWorktreeCardStyle, agentStatusHooksEnabled: enabled } writePersistedState(dataPath, state) diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 12d4a64eef9..0ee5498bf85 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -434,6 +434,38 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledTimes(1) }) + it('resolves the invocation cwd from ORCA_CLI_CWD when no cwd is passed', async () => { + // Why: the SSH relay bridge runs the CLI on the Orca host with the remote + // shell's cwd carried in ORCA_CLI_CWD (#7716); cwd-based selectors must + // resolve against it, not the host process cwd. + process.env.ORCA_CLI_CWD = '/tmp/repo/feature/src' + try { + queueFixtures( + callMock, + worktreeListFixture([ + buildWorktree('/tmp/repo', 'main'), + buildWorktree('/tmp/repo/feature', 'feature/foo') + ]), + okFixture('req_1', { + worktree: { + id: 'repo::/tmp/repo/feature', + branch: 'feature/foo', + path: '/tmp/repo/feature' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['worktree', 'current', '--json']) + + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', { + worktree: 'id:repo::/tmp/repo/feature' + }) + } finally { + delete process.env.ORCA_CLI_CWD + } + }) + it.skipIf(process.platform === 'win32')( 'prepares and starts Claude Agent Teams in the current Orca terminal', async () => { diff --git a/src/cli/index.ts b/src/cli/index.ts index e2934812ff5..e9c12859063 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -25,7 +25,19 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { ) } -export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { +// Why: the SSH relay bridge executes this CLI on the Orca host while the +// caller's shell cwd lives on the remote machine (which cannot be chdir'd +// into). ORCA_CLI_CWD carries that remote cwd so cwd-based selectors like +// `--worktree active` resolve against the caller's directory. +function resolveInvocationCwd(): string { + const override = process.env.ORCA_CLI_CWD + return typeof override === 'string' && override.length > 0 ? override : process.cwd() +} + +export async function main( + argv = process.argv.slice(2), + cwd = resolveInvocationCwd() +): Promise { if (argv[0] === 'agent-teams-tmux') { await runAgentTeamsTmuxShim(argv.slice(1)) return diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 8a85128cdef..550af50bc42 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -8,6 +8,7 @@ vi.mock('electron', () => ({ })) import { CodexHookService } from '../codex/hook-service' +import { DroidHookService } from '../droid/hook-service' import { CursorHookService } from '../cursor/hook-service' import { CommandCodeHookService } from '../command-code/hook-service' import { GeminiHookService } from '../gemini/hook-service' @@ -20,6 +21,24 @@ import { HermesHookService } from '../hermes/hook-service' import { DevinHookService } from '../devin/hook-service' import { KimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +import { ampHookService } from '../amp/hook-service' +import { antigravityHookService } from '../antigravity/hook-service' +import { claudeHookService } from '../claude/hook-service' +import { codexHookService } from '../codex/hook-service' +import { copilotHookService } from '../copilot/hook-service' +import { cursorHookService } from '../cursor/hook-service' +import { droidHookService } from '../droid/hook-service' +import { commandCodeHookService } from '../command-code/hook-service' +import { geminiHookService } from '../gemini/hook-service' +import { devinHookService } from '../devin/hook-service' +import { grokHookService } from '../grok/hook-service' +import { hermesHookService } from '../hermes/hook-service' +import { kimiHookService } from '../kimi/hook-service' +import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls' +import { + installRemoteManagedAgentHooks, + REMOTE_MANAGED_HOOK_INSTALLER_AGENTS +} from './remote-managed-hook-installers' type FakeFs = { files: Map @@ -166,6 +185,10 @@ describe('remote hook service installers', () => { { path: '/home/dev/.orca/agent-hooks/devin-hook.sh', install: (sftp: SFTPWrapper) => new DevinHookService().installRemote(sftp, '/home/dev') + }, + { + path: '/home/dev/.orca/agent-hooks/droid-hook.sh', + install: (sftp: SFTPWrapper) => new DroidHookService().installRemote(sftp, '/home/dev') } ] @@ -582,6 +605,107 @@ describe('remote hook service installers', () => { expect(fs.modes.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toBe(0o755) }) + // Why: Droid (and Copilot) each shipped a working installRemote but were never + // registered in REMOTE_MANAGED_HOOK_INSTALLERS, so their status silently never + // appeared over SSH (issue #7253). Guard the whole bug class, not one agent: + // every locally-managed hook service that implements installRemote MUST be + // wired into the remote installer. + it('registers every managed agent that implements installRemote in the remote installer (issue #7253)', () => { + const servicesByAgent = new Map([ + ['claude', claudeHookService], + ['openclaude', openClaudeHookService], + ['codex', codexHookService], + ['gemini', geminiHookService], + ['antigravity', antigravityHookService], + ['amp', ampHookService], + ['cursor', cursorHookService], + ['droid', droidHookService], + ['command-code', commandCodeHookService], + ['grok', grokHookService], + ['copilot', copilotHookService], + ['hermes', hermesHookService], + ['devin', devinHookService], + ['kimi', kimiHookService] + ]) + + // Guard against a service silently missing from the map above as new agents land. + for (const [agent] of MANAGED_AGENT_HOOK_INSTALLERS) { + expect(servicesByAgent.has(agent)).toBe(true) + } + + const registered = new Set(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS) + const missing: string[] = [] + for (const [agent, service] of servicesByAgent) { + if (typeof service.installRemote === 'function' && !registered.has(agent)) { + missing.push(agent) + } + } + expect(missing).toEqual([]) + }) + + it('installs Droid and Copilot when running the aggregate remote installer (issue #7253)', async () => { + const { sftp } = createFakeSftp() + const results = await installRemoteManagedAgentHooks(sftp, '/home/dev') + const byAgent = new Map(results.map((r) => [r.agent, r.state])) + expect(byAgent.get('droid')).toBe('installed') + expect(byAgent.get('copilot')).toBe('installed') + }) + + it('installs remote Droid hooks into Factory settings.json (issue #7253)', async () => { + const { sftp, fs } = createFakeSftp() + + const status = await new DroidHookService().installRemote(sftp, '/home/dev') + + expect(status.state).toBe('installed') + expect(status.configPath).toBe('/home/dev/.factory/settings.json') + const config = JSON.parse(fs.files.get('/home/dev/.factory/settings.json')!) as { + hooks: Record + } + for (const eventName of [ + 'SessionStart', + 'UserPromptSubmit', + 'Stop', + 'SubagentStop', + 'PreToolUse', + 'PostToolUse', + 'PermissionRequest', + 'Notification' + ]) { + const definition = config.hooks[eventName]?.[0] + const command = definition?.hooks?.[0]?.command + expect(command).toContain('/home/dev/.orca/agent-hooks/droid-hook.sh') + expect(command).toMatch(/^if \[ -x /) + } + // Tool/permission events carry a `*` matcher; lifecycle events do not. + expect(config.hooks.PreToolUse?.[0]?.matcher).toBe('*') + expect(config.hooks.PostToolUse?.[0]?.matcher).toBe('*') + expect(config.hooks.PermissionRequest?.[0]?.matcher).toBe('*') + expect(config.hooks.Stop?.[0]?.matcher).toBeUndefined() + const script = fs.files.get('/home/dev/.orca/agent-hooks/droid-hook.sh') + expect(script).toContain('#!/bin/sh') + expect(script).toContain('/hook/droid') + expect(fs.modes.get('/home/dev/.orca/agent-hooks/droid-hook.sh')).toBe(0o755) + }) + + it('does not overwrite a malformed remote Factory settings.json', async () => { + const original = '{"hooks": }' + const { sftp, fs } = createFakeSftp({ + '/home/dev/.factory/settings.json': original + }) + + const status = await new DroidHookService().installRemote(sftp, '/home/dev') + + expect(status).toMatchObject({ + agent: 'droid', + state: 'error', + configPath: '/home/dev/.factory/settings.json', + managedHooksPresent: false, + detail: 'Could not parse remote Factory settings.json' + }) + expect(fs.files.get('/home/dev/.factory/settings.json')).toBe(original) + expect(fs.files.get('/home/dev/.orca/agent-hooks/droid-hook.sh')).toBeUndefined() + }) + it('installs remote Hermes plugin files and enables the plugin', async () => { const { sftp, fs } = createFakeSftp() diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 7913b863577..e68ec2a8dcb 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -7,7 +7,9 @@ import { geminiHookService } from '../gemini/hook-service' import { antigravityHookService } from '../antigravity/hook-service' import { cursorHookService } from '../cursor/hook-service' import { commandCodeHookService } from '../command-code/hook-service' +import { copilotHookService } from '../copilot/hook-service' import { devinHookService } from '../devin/hook-service' +import { droidHookService } from '../droid/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' @@ -27,12 +29,20 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)], ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], ['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)], + ['copilot', (sftp, remoteHome) => copilotHookService.installRemote(sftp, remoteHome)], ['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)], + ['droid', (sftp, remoteHome) => droidHookService.installRemote(sftp, remoteHome)], ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)], ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)], ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)] ] +/** Agents wired into the remote (SSH) hook installer. Exported so an invariant + * test can assert every locally-managed agent that implements `installRemote` + * is registered here — the omission that hid Droid/Copilot status over SSH. */ +export const REMOTE_MANAGED_HOOK_INSTALLER_AGENTS: readonly AgentHookInstallStatus['agent'][] = + REMOTE_MANAGED_HOOK_INSTALLERS.map(([agent]) => agent) + export async function installRemoteManagedAgentHooks( sftp: SFTPWrapper, remoteHome: string diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index f232b0adf81..c2b3bd344f0 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -5,17 +5,15 @@ import { parseCodexSessionContent } from './session-scanner-codex-parser' import { parseDevinSessionContent } from './session-scanner-devin-parser' import { parseDroidSessionContent } from './session-scanner-droid-parser' import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' -import { - parseClaudeSessionContent, - parseGeminiSessionContent -} from './session-scanner-primary-parsers' +import { parseClaudeSessionContent } from './session-scanner-primary-parsers' +import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' import { parseCopilotSessionContent, parseCursorSessionContent, parseHermesSessionContent } from './session-scanner-secondary-parsers' import type { FileWithMtime } from './session-scanner-types' -import { normalizePiSessionsDir } from './session-scanner-values' +import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' import type { RemoteParserOptions, @@ -36,13 +34,19 @@ export function remoteSessionSources( ): RemoteSessionSource[] { return [ ...remoteCodexSources(remoteHome, hostPlatform), - jsonlSource( - 'claude', - remoteHome, - hostPlatform, - ['.claude', 'projects'], - parseClaudeSessionContent - ), + { + ...jsonlSource( + 'claude', + remoteHome, + hostPlatform, + ['.claude', 'projects'], + parseClaudeSessionContent + ), + // The remote host owns the transcript disk, so the local readdir in the + // Claude parser is skipped; the walked listing supplies the sibling + // subagent counts instead. + collectSubagentSiblingCounts: true + }, source( 'gemini', remoteHome, @@ -83,6 +87,7 @@ export function remoteSessionSources( parseDevinSessionContent ), jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), + jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), jsonlSource( 'droid', remoteHome, @@ -203,6 +208,15 @@ function piParser( return parseMessageGraphSessionContent('pi', file, content, platform, options) } +function ompParser( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: RemoteParserOptions +): Promise { + return parseMessageGraphSessionContent('omp', file, content, platform, options) +} + function openClawParser( file: FileWithMtime, content: string, @@ -217,5 +231,9 @@ function remotePathSegments(path: string): string[] { } function remotePiSessionsSegments(): string[] { - return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean) + return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean) +} + +function remoteOmpSessionsSegments(): string[] { + return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean) } diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index d5a4b2f1acf..c65ef99aded 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -21,6 +21,9 @@ export type RemoteSessionSource = { rootDir: string extensions: readonly string[] filePredicate?: (path: string) => boolean + // Claude layout: count `/subagents/*.jsonl` siblings from the walked + // listing and drop them from candidates instead of indexing them as sessions. + collectSubagentSiblingCounts?: boolean parse: ( file: FileWithMtime, content: string, @@ -31,4 +34,5 @@ export type RemoteSessionSource = { export type RemoteSessionCandidate = { source: RemoteSessionSource file: FileWithMtime + subagentTranscriptCount?: number } diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 16173f7b736..3d7755bce77 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -201,6 +201,79 @@ describe('scanRemoteAiVaultSessions', () => { }) }) + it('counts remote sibling subagent transcripts for zero-turn Claude sessions', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.claude/projects/repo/lost-session.jsonl', + jsonLines([{ type: 'mode', mode: 'default', sessionId: 'lost-session' }]), + 50 + ) + provider.addFile( + '/home/ada/.claude/projects/repo/lost-session/subagents/agent-a.jsonl', + jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask A' } }]), + 51 + ) + provider.addFile( + '/home/ada/.claude/projects/repo/lost-session/subagents/agent-b.jsonl', + jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask B' } }]), + 52 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + // Subagent transcripts must not surface as standalone sessions; they only + // contribute recoverable signal to their zero-turn parent. + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'claude', + sessionId: 'lost-session', + messageCount: 0, + subagentTranscriptCount: 2, + filePath: '/home/ada/.claude/projects/repo/lost-session.jsonl' + }) + }) + + it('ignores remote subagent siblings for Claude sessions with real turns', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.claude/projects/repo/live-session.jsonl', + jsonLines([ + { + sessionId: 'live-session', + type: 'user', + message: { content: [{ type: 'text', text: 'Do the thing' }] } + } + ]), + 60 + ) + provider.addFile( + '/home/ada/.claude/projects/repo/live-session/subagents/agent-a.jsonl', + jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask' } }]), + 61 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + sessionId: 'live-session', + messageCount: 1, + subagentTranscriptCount: 0 + }) + }) + it('builds resume commands with the remote host platform', async () => { const provider = new MemoryRemoteProvider() provider.addFile( diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 1b7d66cb7b0..9e09cf29e70 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -11,6 +11,7 @@ import type { FileStat, IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' +import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import { remoteSessionSources } from './remote-session-scanner-sources' @@ -77,11 +78,15 @@ async function discoverRemoteSourceCandidates(args: { context: RemoteScannerContext issues: AiVaultScanIssue[] }): Promise { - const paths = await walkRemoteSessionFiles( + const walked = await walkRemoteSessionFiles( args.source, args.context.provider, args.context.hostPlatform ) + const partition = args.source.collectSubagentSiblingCounts + ? partitionSubagentTranscriptPaths(walked) + : null + const paths = partition ? partition.sessionFilePaths : walked const files = await mapRemoteScanConcurrently(paths, (path) => statRemoteFile( args.context.provider, @@ -93,7 +98,11 @@ async function discoverRemoteSourceCandidates(args: { ) return files .filter((file): file is FileWithMtime => Boolean(file)) - .map((file) => ({ source: args.source, file })) + .map((file) => ({ + source: args.source, + file, + subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0 + })) } async function walkRemoteSessionFiles( @@ -199,7 +208,14 @@ async function parseRemoteSessionCandidate( if (read.isBinary) { return null } - return candidate.source.parse(candidate.file, read.content, context) + const session = await candidate.source.parse(candidate.file, read.content, context) + // Mirror the local zero-turn-only rule: sibling subagent transcripts are + // recoverable signal only when the parent conversation persisted no turns. + const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0 + if (session && session.messageCount === 0 && subagentTranscriptCount > 0) { + return { ...session, subagentTranscriptCount } + } + return session } catch (err) { issues.push({ executionHostId: context.executionHostId, diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts new file mode 100644 index 00000000000..0044929c3c8 --- /dev/null +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' + +const mocks = vi.hoisted(() => ({ + callRuntimeEnvironment: vi.fn(), + listEnvironments: vi.fn() +})) + +vi.mock('../../shared/runtime-environment-store', () => ({ + listEnvironments: mocks.listEnvironments +})) + +vi.mock('../ipc/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: mocks.callRuntimeEnvironment +})) + +const { getSavedRuntimeAiVaultHostInfos, scanRuntimeAiVaultSessions } = + await import('./runtime-session-scanner') + +beforeEach(() => { + vi.clearAllMocks() + mocks.listEnvironments.mockReturnValue([]) + mocks.callRuntimeEnvironment.mockResolvedValue({ + ok: true, + result: result([session('runtime:env-1', 'session-1')]) + }) +}) + +describe('runtime AI Vault session scanner', () => { + it('lists saved runtime environments as runtime execution hosts', () => { + mocks.listEnvironments.mockReturnValue([ + { id: 'env-1', name: 'Server 1' }, + { id: 'env-2', name: 'Server 2' } + ]) + + expect(getSavedRuntimeAiVaultHostInfos('/user-data')).toEqual([ + { environmentId: 'env-1', executionHostId: 'runtime:env-1' }, + { environmentId: 'env-2', executionHostId: 'runtime:env-2' } + ]) + }) + + it('forwards scan options to the runtime transport', async () => { + await scanRuntimeAiVaultSessions( + '/user-data', + 'env-1', + { + limit: 25, + force: true, + scopePaths: ['/srv/app'] + }, + { timeoutMs: 3000 } + ) + + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/user-data', + 'env-1', + 'aiVault.listSessions', + { + limit: 25, + force: true, + scopePaths: ['/srv/app'], + executionHostId: 'runtime:env-1' + }, + 3000 + ) + }) + + it('stamps sessions and issues returned for a different execution host', async () => { + mocks.callRuntimeEnvironment.mockResolvedValueOnce({ + ok: true, + result: result( + [session('local', 'session-1')], + [ + { + executionHostId: 'ssh:dev-box', + agent: 'codex', + path: '/sessions/session-1.jsonl', + message: 'could not parse session' + } + ] + ) + }) + + const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {}) + + expect(scanResult.sessions).toEqual([ + expect.objectContaining({ + id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl', + executionHostId: 'runtime:env-1' + }) + ]) + expect(scanResult.issues).toEqual([ + expect.objectContaining({ + executionHostId: 'runtime:env-1', + agent: 'codex', + path: '/sessions/session-1.jsonl' + }) + ]) + }) + + it('stamps accepted sessions with the requested runtime host', async () => { + const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {}) + + expect(scanResult.sessions).toEqual([ + expect.objectContaining({ + id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl', + executionHostId: 'runtime:env-1' + }) + ]) + }) +}) + +function result( + sessions: AiVaultSession[], + issues: AiVaultListResult['issues'] = [] +): AiVaultListResult { + return { sessions, issues, scannedAt: '2026-07-04T00:00:00.000Z' } +} + +function session( + executionHostId: AiVaultSession['executionHostId'], + sessionId: string +): AiVaultSession { + return { + id: `${executionHostId}:codex:${sessionId}:/sessions/${sessionId}.jsonl`, + executionHostId, + executionHostPlatform: 'linux', + agent: 'codex', + sessionId, + title: sessionId, + cwd: '/srv/app', + branch: null, + model: null, + filePath: `/sessions/${sessionId}.jsonl`, + codexHome: null, + createdAt: null, + updatedAt: '2026-07-04T03:00:00.000Z', + modifiedAt: '2026-07-04T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: `codex resume ${sessionId}` + } +} diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts new file mode 100644 index 00000000000..2b75c758421 --- /dev/null +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -0,0 +1,180 @@ +import { z } from 'zod' +import { + AI_VAULT_AGENTS, + type AiVaultListArgs, + type AiVaultListResult, + type AiVaultSession +} from '../../shared/ai-vault-types' +import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host' +import { listEnvironments } from '../../shared/runtime-environment-store' +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' + +export type RuntimeAiVaultHostInfo = { + environmentId: string + executionHostId: `runtime:${string}` +} + +export type RuntimeAiVaultScanOptions = { + timeoutMs?: number +} + +const nodePlatformSchema = z.enum([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd' +] satisfies NodeJS.Platform[]) + +const aiVaultSessionPreviewMessageSchema = z.object({ + role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']), + text: z.string(), + timestamp: z.string().nullable() +}) + +const executionHostIdSchema = z.string().transform((value, ctx) => { + const normalized = normalizeExecutionHostId(value) + if (normalized) { + return normalized + } + ctx.addIssue({ + code: 'custom', + message: 'Invalid execution host id' + }) + return z.NEVER +}) + +const aiVaultListResultSchema = z.object({ + sessions: z.array( + z.object({ + id: z.string(), + executionHostId: executionHostIdSchema, + executionHostPlatform: nodePlatformSchema.nullable().optional(), + agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string(), + title: z.string(), + cwd: z.string().nullable(), + branch: z.string().nullable(), + model: z.string().nullable(), + filePath: z.string(), + codexHome: z.string().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), + modifiedAt: z.string(), + messageCount: z.number(), + totalTokens: z.number(), + previewMessages: z.array(aiVaultSessionPreviewMessageSchema), + // Default keeps remote hosts running an older build (no recoverable-signal + // fields) parseable; they simply report no recoverable-empty sessions. + queuedMessageCount: z.number().default(0), + subagentTranscriptCount: z.number().default(0), + resumeCommand: z.string() + }) + ), + issues: z.array( + z.object({ + executionHostId: executionHostIdSchema.optional(), + agent: z.enum(AI_VAULT_AGENTS), + path: z.string(), + message: z.string() + }) + ), + scannedAt: z.string() +}) + +export function getSavedRuntimeAiVaultHostInfos( + userDataPath: string +): readonly RuntimeAiVaultHostInfo[] { + return listEnvironments(userDataPath).map((environment) => ({ + environmentId: environment.id, + executionHostId: toRuntimeExecutionHostId(environment.id) + })) +} + +export async function scanRuntimeAiVaultSessions( + userDataPath: string, + environmentId: string, + args: AiVaultListArgs, + options: RuntimeAiVaultScanOptions = {} +): Promise { + const executionHostId = toRuntimeExecutionHostId(environmentId) + const response = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'aiVault.listSessions', + { + limit: args.limit, + force: args.force, + scopePaths: args.scopePaths, + executionHostId + }, + options.timeoutMs + ) + if (response.ok === true) { + const parsed = aiVaultListResultSchema.safeParse(response.result) + if (parsed.success) { + return withRuntimeExecutionHost(parsed.data, executionHostId) + } + return runtimeScanIssueResult({ + executionHostId, + environmentId, + message: `Invalid aiVault.listSessions response: ${ + parsed.error.issues[0]?.message ?? 'unexpected result shape' + }` + }) + } + return runtimeScanIssueResult({ + executionHostId, + environmentId, + message: response.error.message + }) +} + +function withRuntimeExecutionHost( + result: AiVaultListResult, + executionHostId: `runtime:${string}` +): AiVaultListResult { + return { + sessions: result.sessions.map((session) => retagRuntimeSession(session, executionHostId)), + issues: result.issues.map((issue) => ({ ...issue, executionHostId })), + scannedAt: result.scannedAt + } +} + +function retagRuntimeSession( + session: AiVaultSession, + executionHostId: `runtime:${string}` +): AiVaultSession { + // The paired server is the transport, but the parent owns which concrete + // runtime host was scanned; never trust returned host ids across the boundary. + return { + ...session, + executionHostId, + id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + } +} + +function runtimeScanIssueResult(args: { + executionHostId: `runtime:${string}` + environmentId: string + message: string +}): AiVaultListResult { + return { + sessions: [], + issues: [ + { + executionHostId: args.executionHostId, + agent: 'codex', + path: args.environmentId, + message: args.message + } + ], + scannedAt: new Date().toISOString() + } +} diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts new file mode 100644 index 00000000000..ea21dc590f6 --- /dev/null +++ b/src/main/ai-vault/session-list-results.ts @@ -0,0 +1,48 @@ +import type { + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { sessionSortTime } from './session-scanner-accumulator' + +export function aiVaultScanIssueResult(args: { + executionHostId?: ExecutionHostId + path: string + message: string +}): AiVaultListResult { + return { + sessions: [], + issues: [ + { + ...(args.executionHostId ? { executionHostId: args.executionHostId } : {}), + agent: 'codex', + path: args.path, + message: args.message + } + ], + scannedAt: new Date().toISOString() + } +} + +export function mergeAiVaultListResults( + results: readonly AiVaultListResult[], + rawLimit: number | undefined +): AiVaultListResult { + const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000 + const byId = new Map() + const issues: AiVaultScanIssue[] = [] + for (const result of results) { + for (const session of result.sessions) { + byId.set(session.id, session) + } + issues.push(...result.issues) + } + return { + sessions: [...byId.values()] + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit), + issues, + scannedAt: new Date().toISOString() + } +} diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index c31408a8496..5d7d1df939e 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -41,6 +41,8 @@ export function createAccumulator(args: { messageCount: 0, totalTokens: 0, previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, latestTimestampMs: 0 } } @@ -110,9 +112,12 @@ export function finalizeSession( messageCount: accumulator.messageCount, totalTokens: accumulator.totalTokens, previewMessages: accumulator.previewMessages, + queuedMessageCount: accumulator.queuedMessageCount, + subagentTranscriptCount: accumulator.subagentTranscriptCount, resumeCommand: buildAiVaultResumeCommand({ agent: accumulator.agent, sessionId, + resumeFilePath: accumulator.filePath, cwd: accumulator.cwd, platform, codexHome: options.codexHome diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 1114c1b8bb2..22c6cd46abc 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -6,7 +6,8 @@ import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-sc import { parseKimiSessionFile } from './session-scanner-kimi-parser' import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths' import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite' -import { parseClaudeSessionFile, parseGeminiSessionFile } from './session-scanner-primary-parsers' +import { parseClaudeSessionFile } from './session-scanner-primary-parsers' +import { parseGeminiSessionFile } from './session-scanner-gemini-parsers' import { parseCodexSessionFile } from './session-scanner-codex-parser' import { parseCopilotSessionFile, @@ -64,6 +65,8 @@ export async function parseAgentSessionFile( return parseMessageGraphSessionFile('openclaw', candidate.file, platform) case 'pi': return parseMessageGraphSessionFile('pi', candidate.file, platform) + case 'omp': + return parseMessageGraphSessionFile('omp', candidate.file, platform) case 'droid': return parseDroidSessionFile(candidate.file, platform) case 'devin': diff --git a/src/main/ai-vault/session-scanner-codex-workers.test.ts b/src/main/ai-vault/session-scanner-codex-workers.test.ts index 585e37b0203..8a6fb183f62 100644 --- a/src/main/ai-vault/session-scanner-codex-workers.test.ts +++ b/src/main/ai-vault/session-scanner-codex-workers.test.ts @@ -117,6 +117,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => { openclawStateDir: join(root, 'openclaw-state'), openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions'), diff --git a/src/main/ai-vault/session-scanner-gemini-parsers.ts b/src/main/ai-vault/session-scanner-gemini-parsers.ts new file mode 100644 index 00000000000..6f0b9fdc7c1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-gemini-parsers.ts @@ -0,0 +1,156 @@ +import { createReadStream } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { createInterface } from 'node:readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { + FileWithMtime, + ResumableParseFinalizeOptions, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' +import { + accumulatorFoldResumeState, + addPreviewContent, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import { + arrayValue, + asRecord, + extractContentText, + extractString, + parseJsonObject, + tokenTotal +} from './session-scanner-values' + +export async function parseGeminiSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + if (file.path.endsWith('.jsonl')) { + return parseGeminiJsonlSessionFile(file, platform) + } + + return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform) +} + +export async function parseGeminiSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform = process.platform, + options: ResumableParseFinalizeOptions = {} +): Promise { + if (file.path.endsWith('.jsonl')) { + return parseGeminiJsonlSessionLines({ + file, + lines: content.split(/\r?\n/), + platform, + options + }) + } + return parseGeminiJsonSessionContent(file, content, platform, options) +} + +function parseGeminiJsonSessionContent( + file: FileWithMtime, + content: string, + platform: NodeJS.Platform, + options: ResumableParseFinalizeOptions = {} +): AiVaultSession | null { + const record = asRecord(JSON.parse(content) as unknown) + if (!record) { + return null + } + const accumulator = createAccumulator({ + agent: 'gemini', + file, + sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path) + }) + updateTimeline(accumulator, extractString(record.startTime)) + updateTimeline(accumulator, extractString(record.lastUpdated)) + for (const message of arrayValue(record.messages)) { + consumeGeminiMessage(accumulator, asRecord(message)) + } + return finalizeSession(accumulator, platform, options) +} + +export async function parseGeminiJsonlSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform +): Promise { + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + return parseGeminiJsonlSessionLines({ file, lines, platform }) +} + +function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + const setRecord = asRecord(record.$set) + if (setRecord) { + updateTimeline(accumulator, extractString(setRecord.lastUpdated)) + return + } + const sessionId = extractString(record.sessionId) + if (sessionId) { + accumulator.sessionId = sessionId + } + updateTimeline(accumulator, extractString(record.startTime)) + updateTimeline(accumulator, extractString(record.lastUpdated)) + consumeGeminiMessage(accumulator, record) +} + +// Resumable only for the JSONL log format; Gemini's legacy single-JSON +// session documents are rewritten in place and must be re-read whole. +export function createGeminiJsonlSessionResumeState( + file: FileWithMtime +): ResumableSessionParseState { + return accumulatorFoldResumeState( + createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }), + consumeGeminiJsonlRecordLine + ) +} + +async function parseGeminiJsonlSessionLines(args: { + file: FileWithMtime + lines: AsyncIterable | Iterable + platform: NodeJS.Platform + options?: ResumableParseFinalizeOptions +}): Promise { + const state = createGeminiJsonlSessionResumeState(args.file) + for await (const line of args.lines) { + state.consumeLine(line) + } + return state.finalize(args.platform, args.options) +} + +export function consumeGeminiMessage( + accumulator: SessionAccumulator, + record: Record | null +): void { + if (!record) { + return + } + updateTimeline(accumulator, extractString(record.timestamp)) + if (record.type === 'user') { + accumulator.messageCount++ + accumulator.title ??= extractContentText(record.content) + addPreviewContent(accumulator, 'user', record.content, record.timestamp) + return + } + if (record.type === 'gemini') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', record.content, record.timestamp) + const model = extractString(record.model) + if (model) { + accumulator.model = model + } + accumulator.totalTokens += tokenTotal(record.tokens) + } +} diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 01d84ddfac0..04601eacde5 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -163,8 +163,12 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str return extractContentText(textParts) } +// Agents whose transcripts are append-only message-graph JSONL (session + +// model_change + message records). OMP is a Pi fork and shares the format. +export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp' + export async function parseMessageGraphSessionFile( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { @@ -176,7 +180,7 @@ export async function parseMessageGraphSessionFile( } export async function parseMessageGraphSessionContent( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, @@ -206,7 +210,10 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st return } if (record.type === 'model_change') { - accumulator.model = extractString(record.modelId) ?? accumulator.model + // Pi writes `modelId`; OMP writes `model`. Prefer either so an in-progress + // session shows its model before the first assistant reply lands. + accumulator.model = + extractString(record.modelId) ?? extractString(record.model) ?? accumulator.model return } if (record.type !== 'message') { @@ -227,7 +234,7 @@ function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: st } export function createMessageGraphSessionResumeState( - agent: 'openclaw' | 'pi', + agent: MessageGraphAgent, file: FileWithMtime ): ResumableSessionParseState { return accumulatorFoldResumeState( @@ -237,7 +244,7 @@ export function createMessageGraphSessionResumeState( } async function parseMessageGraphSessionLines(args: { - agent: 'openclaw' | 'pi' + agent: MessageGraphAgent file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts index 01219836a58..1945193e6c7 100644 --- a/src/main/ai-vault/session-scanner-incremental-fixtures.ts +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -188,6 +188,56 @@ export function piFixture(): IncrementalAgentFixture { } } +// OMP is a Pi fork sharing the message-graph format, but keys the model on +// `model` (not Pi's `modelId`); its own fixture exercises the registry's 'omp' +// branch and that model-key difference. (The session `title` is included for +// realism only — the parser derives the title from the first user message.) +export function ompFixture(): IncrementalAgentFixture { + return { + agent: 'omp', + fileName: '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000.jsonl', + seedLines: [ + JSON.stringify({ + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/repo/app', + title: 'OMP session title', + timestamp: '2026-05-01T10:00:00.000Z' + }), + JSON.stringify({ + type: 'model_change', + model: 'gpt-5.4-mini', + timestamp: '2026-05-01T10:00:01.000Z' + }), + JSON.stringify({ + type: 'message', + message: { role: 'user', content: [{ type: 'text', text: 'omp seed question' }] }, + timestamp: '2026-05-01T10:00:05.000Z' + }) + ], + appendLines: [ + JSON.stringify({ + type: 'message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'omp incremental answer' }], + model: 'gpt-5.4-mini', + usage: { input: 40, output: 20, totalTokens: 60 } + }, + timestamp: '2026-05-01T10:01:00.000Z' + }) + ], + truncatedLines: [ + JSON.stringify({ + type: 'session', + id: 'cccccccc-dddd-4eee-8fff-000000000000', + timestamp: '2026-05-01T10:00:00.000Z' + }) + ] + } +} + export function geminiJsonlFixture(): IncrementalAgentFixture { return { agent: 'gemini', @@ -235,6 +285,7 @@ export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] { droidFixture(), openclawFixture(), piFixture(), + ompFixture(), geminiJsonlFixture() ] } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts index d0eaa649818..2fb3df21613 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-coexistence.test.ts @@ -36,7 +36,8 @@ function isolatedScanRoots(root: string) { piSessionsDir: join(root, 'pi-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), - kimiSessionsDir: join(root, 'kimi-sessions') + kimiSessionsDir: join(root, 'kimi-sessions'), + ompSessionsDir: join(root, 'omp-sessions') } } diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index dcc7fc649aa..70d0d311d6b 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -5,14 +5,13 @@ import { parseAgentSessionFile } from './session-scanner-agent-parser' import { createCodexSessionResumeState } from './session-scanner-codex-parser' import { createDroidSessionResumeState } from './session-scanner-droid-parser' import { createMessageGraphSessionResumeState } from './session-scanner-graph-parsers' -import { - createClaudeSessionResumeState, - createGeminiJsonlSessionResumeState -} from './session-scanner-primary-parsers' +import { createClaudeSessionResumeState } from './session-scanner-primary-parsers' +import { createGeminiJsonlSessionResumeState } from './session-scanner-gemini-parsers' import { createCopilotSessionResumeState, createCursorSessionResumeState } from './session-scanner-secondary-parsers' +import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types' // Sized past the default recency cap (1000) plus the in-scope cap (2000) so a @@ -59,7 +58,8 @@ function resumableStateFactoryFor( case 'droid': return () => createDroidSessionResumeState(candidate.file) case 'openclaw': - case 'pi': { + case 'pi': + case 'omp': { const agent = candidate.agent return () => createMessageGraphSessionResumeState(agent, candidate.file) } @@ -67,7 +67,12 @@ function resumableStateFactoryFor( return candidate.file.path.endsWith('.jsonl') ? () => createGeminiJsonlSessionResumeState(candidate.file) : null - default: + case 'devin': + case 'grok': + case 'hermes': + case 'kimi': + case 'opencode': + case 'rovo': return null } } @@ -103,7 +108,7 @@ function storeEntry(path: string, entry: SessionParseCacheEntry): void { /** * Parse a session file, reusing prior work where the file is provably * unchanged (mtime+size) and, for append-only JSONL transcripts (Claude, - * Codex, Cursor, Copilot, Droid, OpenClaw/Pi, Gemini-JSONL), resuming the + * Codex, Cursor, Copilot, Droid, OpenClaw/Pi/OMP, Gemini-JSONL), resuming the * parse from the last consumed byte when the file only grew. This is what * keeps the renderer's ~5s forced rescans from re-reading gigabytes of * transcripts (STA-1278/STA-1417: main process pegging one core during @@ -126,6 +131,16 @@ export async function parseAgentSessionFileCached( if (stats) { stats.reused++ } + // A zero-turn transcript usually never changes again, but its sibling + // subagents/ dir can gain files after the parent's last write (a + // still-running subagent finishing). The mtime+size key can't see that, + // so refresh the cheap directory count on reuse. + if (entry.session && candidate.agent === 'claude' && entry.session.messageCount === 0) { + const subagentTranscriptCount = await countSubagentTranscripts(file.path) + if (subagentTranscriptCount !== entry.session.subagentTranscriptCount) { + entry.session = { ...entry.session, subagentTranscriptCount } + } + } storeEntry(file.path, entry) return entry.session } diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index e07329a3003..c72d0da8277 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -1,15 +1,13 @@ import { createReadStream } from 'node:fs' -import { readFile } from 'node:fs/promises' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' -import type { ExecutionHostId } from '../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import type { FileWithMtime, ResumableSessionParseState, SessionAccumulator } from './session-scanner-types' import { - accumulatorFoldResumeState, addPreviewContent, createAccumulator, finalizeSession, @@ -17,16 +15,14 @@ import { updateLatestLocation, updateTimeline } from './session-scanner-accumulator' +import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' import { - arrayValue, asRecord, claudeUsageTotal, - extractContentText, extractMessageText, extractString, normalizeTitleText, - parseJsonObject, - tokenTotal + parseJsonObject } from './session-scanner-values' type ParserSessionOptions = { @@ -102,6 +98,22 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s return } + if (record.type === 'queue-operation') { + // Enqueued prompts hold real content (e.g. queued subagent messages) that + // survives even when the conversation was never persisted — recoverable + // signal for an otherwise-empty session, but not a conversation turn. + // Count net of remove/dequeue: consumed or user-removed prompts are written + // as later queue-operation records and are no longer queued. Accepted gap: + // a dequeue/remove of an uncounted empty-content enqueue can undercount, + // which only hides the recoverable badge — it never fabricates one. + if (record.operation === 'enqueue' && (extractString(record.content)?.trim().length ?? 0) > 0) { + accumulator.queuedMessageCount++ + } else if (record.operation === 'remove' || record.operation === 'dequeue') { + accumulator.queuedMessageCount = Math.max(0, accumulator.queuedMessageCount - 1) + } + return + } + if (record.type === 'user') { accumulator.messageCount++ const title = extractMessageText(record.message) @@ -129,11 +141,11 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s } } -export function finalizeClaudeSessionParseState( +export async function finalizeClaudeSessionParseState( state: ClaudeSessionParseState, platform: NodeJS.Platform, options: ParserSessionOptions = {} -): AiVaultSession | null { +): Promise { // Finalize a snapshot: the live state (and its preview array) may keep // accumulating appended lines after this session object is handed out. const snapshot = cloneClaudeSessionParseState(state) @@ -141,6 +153,17 @@ export function finalizeClaudeSessionParseState( // session name (ai-title) should outrank the raw first prompt when present. snapshot.accumulator.fallbackTitle = snapshot.generatedTitle ?? snapshot.firstUserTitle ?? snapshot.metaTitle + // Only a zero-turn transcript needs its sibling subagent transcripts counted; + // for normal sessions the extra directory read is skipped. The sibling dir + // lives on the host that owns the transcript, so content fetched from a + // remote (SSH) host must not readdir this machine's disk. + const ownsTranscriptDisk = + !options.executionHostId || options.executionHostId === LOCAL_EXECUTION_HOST_ID + if (snapshot.accumulator.messageCount === 0 && ownsTranscriptDisk) { + snapshot.accumulator.subagentTranscriptCount = await countSubagentTranscripts( + snapshot.accumulator.filePath + ) + } return finalizeSession(snapshot.accumulator, platform, options) } @@ -198,133 +221,3 @@ async function parseClaudeSessionLines(args: { } return finalizeClaudeSessionParseState(state, args.platform, args.options) } - -export async function parseGeminiSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform = process.platform -): Promise { - if (file.path.endsWith('.jsonl')) { - return parseGeminiJsonlSessionFile(file, platform) - } - - return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform) -} - -export async function parseGeminiSessionContent( - file: FileWithMtime, - content: string, - platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} -): Promise { - if (file.path.endsWith('.jsonl')) { - return parseGeminiJsonlSessionLines({ - file, - lines: content.split(/\r?\n/), - platform, - options - }) - } - return parseGeminiJsonSessionContent(file, content, platform, options) -} - -function parseGeminiJsonSessionContent( - file: FileWithMtime, - content: string, - platform: NodeJS.Platform, - options: ParserSessionOptions = {} -): AiVaultSession | null { - const record = asRecord(JSON.parse(content) as unknown) - if (!record) { - return null - } - const accumulator = createAccumulator({ - agent: 'gemini', - file, - sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path) - }) - updateTimeline(accumulator, extractString(record.startTime)) - updateTimeline(accumulator, extractString(record.lastUpdated)) - for (const message of arrayValue(record.messages)) { - consumeGeminiMessage(accumulator, asRecord(message)) - } - return finalizeSession(accumulator, platform, options) -} - -export async function parseGeminiJsonlSessionFile( - file: FileWithMtime, - platform: NodeJS.Platform -): Promise { - const lines = createInterface({ - input: createReadStream(file.path, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - return parseGeminiJsonlSessionLines({ file, lines, platform }) -} - -function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: string): void { - const record = parseJsonObject(line) - if (!record) { - return - } - const setRecord = asRecord(record.$set) - if (setRecord) { - updateTimeline(accumulator, extractString(setRecord.lastUpdated)) - return - } - const sessionId = extractString(record.sessionId) - if (sessionId) { - accumulator.sessionId = sessionId - } - updateTimeline(accumulator, extractString(record.startTime)) - updateTimeline(accumulator, extractString(record.lastUpdated)) - consumeGeminiMessage(accumulator, record) -} - -// Resumable only for the JSONL log format; Gemini's legacy single-JSON -// session documents are rewritten in place and must be re-read whole. -export function createGeminiJsonlSessionResumeState( - file: FileWithMtime -): ResumableSessionParseState { - return accumulatorFoldResumeState( - createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }), - consumeGeminiJsonlRecordLine - ) -} - -async function parseGeminiJsonlSessionLines(args: { - file: FileWithMtime - lines: AsyncIterable | Iterable - platform: NodeJS.Platform - options?: ParserSessionOptions -}): Promise { - const state = createGeminiJsonlSessionResumeState(args.file) - for await (const line of args.lines) { - state.consumeLine(line) - } - return state.finalize(args.platform, args.options) -} - -export function consumeGeminiMessage( - accumulator: SessionAccumulator, - record: Record | null -): void { - if (!record) { - return - } - updateTimeline(accumulator, extractString(record.timestamp)) - if (record.type === 'user') { - accumulator.messageCount++ - accumulator.title ??= extractContentText(record.content) - addPreviewContent(accumulator, 'user', record.content, record.timestamp) - return - } - if (record.type === 'gemini') { - accumulator.messageCount++ - addPreviewContent(accumulator, 'assistant', record.content, record.timestamp) - const model = extractString(record.model) - if (model) { - accumulator.model = model - } - accumulator.totalTokens += tokenTotal(record.tokens) - } -} diff --git a/src/main/ai-vault/session-scanner-recoverable-empty.test.ts b/src/main/ai-vault/session-scanner-recoverable-empty.test.ts new file mode 100644 index 00000000000..21409fbc6d8 --- /dev/null +++ b/src/main/ai-vault/session-scanner-recoverable-empty.test.ts @@ -0,0 +1,245 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { isAiVaultSessionRecoverableEmpty } from '../../shared/ai-vault-types' +import { scanAiVaultSessions } from './session-scanner' +import { parseClaudeSessionContent } from './session-scanner-primary-parsers' +import { countSubagentTranscripts } from './session-scanner-subagent-transcripts' +import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +// Shape of the real ~/.claude/.../9176163a-*.jsonl artifact: CLI metadata plus +// four queued subagent messages, and zero user/assistant conversation turns. +function metadataOnlyTranscript(sessionId: string): unknown[] { + return [ + { + type: 'last-prompt', + lastPrompt: 'Run the review', + leafUuid: 'leaf-1', + sessionId + }, + { type: 'ai-title', aiTitle: 'Push failure recovery review', sessionId }, + { type: 'mode', mode: 'default', sessionId }, + { type: 'permission-mode', permissionMode: 'acceptEdits', sessionId }, + ...['contracts', 'environment', 'correctness', 'perf'].map((lens, index) => ({ + type: 'queue-operation', + operation: 'enqueue', + timestamp: `2026-07-08T16:3${index}:00.000Z`, + sessionId, + content: `Review complete` + })) + ] +} + +describe('recoverable-but-empty Claude sessions', () => { + it('surfaces a zero-turn session with queued messages and subagent transcripts', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-recoverable-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = '9176163a-2f89-431f-b202-32f04d61f124' + + await writeJsonlFile( + join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`), + metadataOnlyTranscript(sessionId) + ) + // Sibling subagent transcripts survive even though the parent conversation + // was never persisted; a .meta.json sidecar is not a transcript. + const subagentsDir = join(roots.claudeProjectsDir, 'project', sessionId, 'subagents') + await writeJsonlFile(join(subagentsDir, 'agent-arev-contracts-1.jsonl'), [ + { + type: 'user', + sessionId, + message: { role: 'user', content: 'Contracts review' } + } + ]) + await writeJsonlFile(join(subagentsDir, 'agent-arev-perf-2.jsonl'), [ + { + type: 'user', + sessionId, + message: { role: 'user', content: 'Perf review' } + } + ]) + await writeFile(join(subagentsDir, 'agent-arev-contracts-1.meta.json'), '{}') + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + const session = result.sessions.find((entry) => entry.sessionId === sessionId) + + expect(session).toBeDefined() + expect(session?.messageCount).toBe(0) + expect(session?.queuedMessageCount).toBe(4) + expect(session?.subagentTranscriptCount).toBe(2) + expect(isAiVaultSessionRecoverableEmpty(session!)).toBe(true) + }) + + it('reports no recoverable signal for a plain metadata-only session', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-plain-empty-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'plain-empty-session' + + await writeJsonlFile(join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`), [ + { type: 'mode', mode: 'default', sessionId }, + { type: 'permission-mode', permissionMode: 'default', sessionId } + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + const session = result.sessions.find((entry) => entry.sessionId === sessionId) + + expect(session).toBeDefined() + expect(session?.messageCount).toBe(0) + expect(session?.queuedMessageCount).toBe(0) + expect(session?.subagentTranscriptCount).toBe(0) + expect(isAiVaultSessionRecoverableEmpty(session!)).toBe(false) + }) + + it('does not count subagent transcripts for a session that has real turns', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nonempty-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'conversation-session' + + await writeJsonlFile(join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`), [ + { + type: 'user', + sessionId, + message: { role: 'user', content: 'Do the thing' } + } + ]) + await writeJsonlFile( + join(roots.claudeProjectsDir, 'project', sessionId, 'subagents', 'agent-x.jsonl'), + [ + { + type: 'user', + sessionId, + message: { role: 'user', content: 'Subtask' } + } + ] + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + const session = result.sessions.find((entry) => entry.sessionId === sessionId) + + expect(session?.messageCount).toBe(1) + // Skipped for sessions with content: the directory read only runs at zero turns. + expect(session?.subagentTranscriptCount).toBe(0) + }) + + it('counts queued messages net of remove and dequeue operations', async () => { + const sessionId = 'net-queue-session' + const content = [ + { type: 'mode', mode: 'default', sessionId }, + ...[1, 2, 3].map((n) => ({ + type: 'queue-operation', + operation: 'enqueue', + sessionId, + content: `queued prompt ${n}` + })), + // One prompt consumed, one removed by the user: only one is still queued. + { type: 'queue-operation', operation: 'dequeue', sessionId }, + { + type: 'queue-operation', + operation: 'remove', + sessionId, + content: null + } + ] + .map((line) => JSON.stringify(line)) + .join('\n') + const file = { + path: `/tmp/${sessionId}.jsonl`, + mtimeMs: 0, + modifiedAt: '2026-07-08T16:30:00.000Z' + } + + const session = await parseClaudeSessionContent(file, content, 'darwin', { + executionHostId: 'ssh:host' + }) + expect(session?.queuedMessageCount).toBe(1) + }) + + it('picks up subagent transcripts written after the parent transcript last changed', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-late-sub-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const sessionId = 'late-subagent-session' + + await writeJsonlFile(join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`), [ + { type: 'mode', mode: 'default', sessionId } + ]) + const first = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect( + first.sessions.find((entry) => entry.sessionId === sessionId)?.subagentTranscriptCount + ).toBe(0) + + // The parent file never changes again, but a still-running subagent lands + // its transcript afterwards; the cached parse must still surface it. + await writeJsonlFile( + join(roots.claudeProjectsDir, 'project', sessionId, 'subagents', 'agent-late.jsonl'), + [ + { + type: 'user', + sessionId, + message: { role: 'user', content: 'Late subtask' } + } + ] + ) + const second = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect( + second.sessions.find((entry) => entry.sessionId === sessionId)?.subagentTranscriptCount + ).toBe(1) + }) + + it('never counts local subagent transcripts for a remote-host transcript', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-remote-')) + tempRoots.push(root) + const sessionId = 'remote-session' + const transcriptPath = join(root, `${sessionId}.jsonl`) + // A local sibling dir exists, but the transcript content came from an SSH + // host — its real subagents live on that host, not on this disk. + await writeJsonlFile(join(root, sessionId, 'subagents', 'agent-x.jsonl'), [{ type: 'user' }]) + const content = metadataOnlyTranscript(sessionId) + .map((line) => JSON.stringify(line)) + .join('\n') + const file = { + path: transcriptPath, + mtimeMs: 0, + modifiedAt: '2026-07-08T16:30:00.000Z' + } + + const remote = await parseClaudeSessionContent(file, content, 'linux', { + executionHostId: 'ssh:host' + }) + expect(remote?.subagentTranscriptCount).toBe(0) + expect(remote?.queuedMessageCount).toBe(4) + + const local = await parseClaudeSessionContent(file, content, 'darwin') + expect(local?.subagentTranscriptCount).toBe(1) + }) +}) + +describe('countSubagentTranscripts', () => { + it('returns 0 when the sibling subagents directory is absent', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nosub-')) + tempRoots.push(root) + expect(await countSubagentTranscripts(join(root, 'session.jsonl'))).toBe(0) + }) + + it('counts only .jsonl transcripts, excluding meta sidecars', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-sub-count-')) + tempRoots.push(root) + const transcriptPath = join(root, 'session.jsonl') + const subagentsDir = join(root, 'session', 'subagents') + await writeJsonlFile(join(subagentsDir, 'agent-a.jsonl'), [{ type: 'user' }]) + await writeJsonlFile(join(subagentsDir, 'agent-b.jsonl'), [{ type: 'user' }]) + await writeFile(join(subagentsDir, 'agent-a.meta.json'), '{}') + + expect(await countSubagentTranscripts(transcriptPath)).toBe(2) + }) +}) diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index 12acdeab188..8c03ec72e42 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -6,7 +6,7 @@ import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discover import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi-sources' import { opencodeDiscoveries } from './session-scanner-opencode-sources' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' -import { normalizePiSessionsDir } from './session-scanner-values' +import { normalizeAgentSessionsDir } from './session-scanner-values' const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') @@ -25,8 +25,13 @@ const GROK_SESSIONS_DIR = join( const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') -const PI_SESSIONS_DIR = normalizePiSessionsDir( - process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions') +const PI_SESSIONS_DIR = normalizeAgentSessionsDir( + process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions'), + '.pi' +) +const OMP_SESSIONS_DIR = normalizeAgentSessionsDir( + process.env.OMP_CODING_AGENT_DIR?.trim() || join(homedir(), '.omp', 'agent', 'sessions'), + '.omp' ) // Why: Devin ATIF transcripts are stored under /transcripts. const DEVIN_TRANSCRIPTS_DIR = join( @@ -124,7 +129,8 @@ function standardDiscoveries( ...devinDiscoveries(options, wslHomeDirs, limit, issues), ...hermesDiscoveries(options, wslHomeDirs, limit, issues), ...rovoDiscoveries(options, wslHomeDirs, limit, issues), - ...piDiscoveries(options, wslHomeDirs, limit, issues) + ...piDiscoveries(options, wslHomeDirs, limit, issues), + ...ompDiscoveries(options, wslHomeDirs, limit, issues) ] } @@ -244,6 +250,21 @@ function piDiscoveries( ) } +function ompDiscoveries( + options: AiVaultScanOptions, + wslHomeDirs: readonly string[], + limit: number, + issues: AiVaultScanIssue[] +): Promise[] { + return sessionRootDirs(options.ompSessionsDir ?? OMP_SESSIONS_DIR, wslHomeDirs, [ + '.omp', + 'agent', + 'sessions' + ]).map((rootDir) => + discoverFiles({ rootDir, limit, agent: 'omp', issues, extensions: ['.jsonl'] }) + ) +} + function openClawDiscovery( options: AiVaultScanOptions, wslHomeDirs: readonly string[], diff --git a/src/main/ai-vault/session-scanner-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-subagent-transcripts.ts new file mode 100644 index 00000000000..303b9b968e1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-subagent-transcripts.ts @@ -0,0 +1,65 @@ +import { readdir } from 'node:fs/promises' +import { basename, dirname, extname, join } from 'node:path' + +// Claude writes subagent transcripts to a sibling directory named after the +// parent transcript file (…//.jsonl → …///subagents/). +// These survive intact even when the parent conversation persisted zero turns, +// so they are the recoverable signal that keeps such a session from being hidden. +export function subagentTranscriptsDirFor(transcriptFilePath: string): string { + const stem = basename(transcriptFilePath, extname(transcriptFilePath)) + return join(dirname(transcriptFilePath), stem, 'subagents') +} + +/** + * Count sibling subagent transcript files for a session's transcript. Returns 0 + * when the directory is absent (the common case), so callers can treat any + * positive count as recoverable content. Meta sidecars (`*.meta.json`) are not + * transcripts and are excluded. + */ +export async function countSubagentTranscripts(transcriptFilePath: string): Promise { + let entries: string[] + try { + entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath)) + } catch { + return 0 + } + return entries.filter((name) => name.endsWith('.jsonl')).length +} + +// Direct child of a subagents dir: `//subagents/.jsonl`. +// Greedy prefix means nested subagent trees attribute to their nearest parent, +// matching the local direct-children-only readdir semantics. +const SUBAGENT_DIRECT_CHILD_PATTERN = /^(.*)[\\/]subagents[\\/][^\\/]+\.jsonl$/i +const SUBAGENT_SUBTREE_PATTERN = /[\\/]subagents[\\/]/i + +/** + * Partition a recursively walked transcript listing into session candidates and + * per-parent sibling subagent transcript counts. Remote (SSH) scans cannot + * readdir the transcript's sibling directory, but their walk already enumerates + * subagent paths — counting from the listing costs no extra round-trips. + * Subagent transcripts share the parent sessionId and are not independently + * resumable, so they are excluded from candidates (mirrors the local discovery + * pruning in session-scanner-source-discovery.ts). + */ +export function partitionSubagentTranscriptPaths(paths: readonly string[]): { + sessionFilePaths: string[] + subagentTranscriptCounts: Map +} { + const sessionFilePaths: string[] = [] + const subagentTranscriptCounts = new Map() + for (const path of paths) { + if (!SUBAGENT_SUBTREE_PATTERN.test(path)) { + sessionFilePaths.push(path) + continue + } + const directChild = SUBAGENT_DIRECT_CHILD_PATTERN.exec(path) + if (directChild) { + const parentTranscriptPath = `${directChild[1]}.jsonl` + subagentTranscriptCounts.set( + parentTranscriptPath, + (subagentTranscriptCounts.get(parentTranscriptPath) ?? 0) + 1 + ) + } + } + return { sessionFilePaths, subagentTranscriptCounts } +} diff --git a/src/main/ai-vault/session-scanner-test-fixtures.ts b/src/main/ai-vault/session-scanner-test-fixtures.ts index b7c4dfd05cf..0a3b6c7c1a8 100644 --- a/src/main/ai-vault/session-scanner-test-fixtures.ts +++ b/src/main/ai-vault/session-scanner-test-fixtures.ts @@ -19,6 +19,7 @@ export function isolatedScanRoots(root: string) { openclawStateDir: join(root, 'openclaw-state'), openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), + ompSessionsDir: join(root, 'omp-sessions'), droidSessionsDir: join(root, 'droid-sessions'), droidProjectsDir: join(root, 'droid-projects'), kimiSessionsDir: join(root, 'kimi-sessions') diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 7c6c38a0be3..803554316c1 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -25,6 +25,7 @@ export type AiVaultScanOptions = { openclawStateDir?: string openclawLegacyStateDir?: string piSessionsDir?: string + ompSessionsDir?: string droidSessionsDir?: string droidProjectsDir?: string kimiSessionsDir?: string @@ -99,6 +100,9 @@ export type SessionAccumulator = { messageCount: number totalTokens: number previewMessages: AiVaultSessionPreviewMessage[] + // Recoverable signal for a zero-turn transcript (see AiVaultSession). + queuedMessageCount: number + subagentTranscriptCount: number latestTimestampMs: number } diff --git a/src/main/ai-vault/session-scanner-values.test.ts b/src/main/ai-vault/session-scanner-values.test.ts index 9c1de341d28..a9954ae8d26 100644 --- a/src/main/ai-vault/session-scanner-values.test.ts +++ b/src/main/ai-vault/session-scanner-values.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { extractPreviewContentText, + normalizeAgentSessionsDir, normalizePreviewText, normalizeTitleText } from './session-scanner-values' @@ -51,4 +52,20 @@ describe('AI Vault session scanner text values', () => { expect(result).toBe(`${'a'.repeat(216)}...`) }) + + it('expands Pi and OMP agent homes to their session directories', () => { + expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.pi/agent/sessions', '.pi')).toBe( + '/agents/.pi/agent/sessions' + ) + + expect(normalizeAgentSessionsDir('/agents/.omp', '.omp')).toBe('/agents/.omp/agent/sessions') + expect(normalizeAgentSessionsDir('/agents/.omp/agent', '.omp')).toBe( + '/agents/.omp/agent/sessions' + ) + expect(normalizeAgentSessionsDir('/agents/.omp/agent/sessions', '.omp')).toBe( + '/agents/.omp/agent/sessions' + ) + }) }) diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts index 9733175e360..39006bd68a4 100644 --- a/src/main/ai-vault/session-scanner-values.ts +++ b/src/main/ai-vault/session-scanner-values.ts @@ -126,10 +126,15 @@ export function findOpenCodeStorageRoot(filePath: string): string | null { return dirname(sessionRoot) } -export function normalizePiSessionsDir(rawValue: string): string { +// Pi and OMP (a Pi fork) both store transcripts under +// //agent/sessions; accept any prefix of that path. +export function normalizeAgentSessionsDir( + rawValue: string, + agentHomeDirName: '.pi' | '.omp' +): string { const trimmed = rawValue.trim() if (!trimmed) { - return join(homedir(), '.pi', 'agent', 'sessions') + return join(homedir(), agentHomeDirName, 'agent', 'sessions') } const normalized = trimmed.replace(/[\\/]+$/, '') const leaf = basename(normalized) @@ -139,7 +144,7 @@ export function normalizePiSessionsDir(rawValue: string): string { if (leaf === 'agent') { return join(normalized, 'sessions') } - if (leaf === '.pi') { + if (leaf === agentHomeDirName) { return join(normalized, 'agent', 'sessions') } return normalized diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 41b1b2b05ab..d4404341fc1 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types' +import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { scanAiVaultSessions } from './session-scanner' import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' @@ -584,6 +584,44 @@ describe('scanAiVaultSessions', () => { ]) ) + const ompSessionFile = join(roots.ompSessionsDir, 'omp-session.jsonl') + await mkdir(roots.ompSessionsDir, { recursive: true }) + await writeFile( + ompSessionFile, + jsonLines([ + { + type: 'session', + version: 3, + id: 'omp-session', + title: 'OMP session title', + timestamp: '2026-05-01T10:08:30.000Z', + cwd: '/tmp/omp' + }, + { + type: 'model_change', + model: 'gpt-5.4-mini', + timestamp: '2026-05-01T10:08:30.500Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:31.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'OMP title' }] } + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:32.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'OMP answer' }], + model: 'gpt-5.4-mini', + // totalTokens deliberately != input+output so the assertion proves + // the explicit-total field is read, not an input/output sum. + usage: { input: 10, output: 5, totalTokens: 160 } + } + } + ]) + ) + await mkdir(roots.devinTranscriptsDir, { recursive: true }) await writeFile( join(roots.devinTranscriptsDir, 'devin-session.json'), @@ -719,11 +757,48 @@ describe('scanAiVaultSessions', () => { "cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'" ) expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'") + // OMP resumes by absolute transcript path, not by internal session id. + expect(commandByAgent.get('omp')).toBe(`cd '/tmp/omp' && omp --resume '${ompSessionFile}'`) expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") expect(commandByAgent.get('kimi')).toBe( "cd '/tmp/kimi' && kimi --session 'session_kimi-session'" ) + + const ompSession = result.sessions.find((session) => session.agent === 'omp') + expect(ompSession?.model).toBe('gpt-5.4-mini') + expect(ompSession?.totalTokens).toBe(160) + }) + + it('captures an in-progress OMP model from model_change before any assistant reply', async () => { + // OMP writes the model on `model_change.model` (not Pi's `modelId`). With no + // assistant message yet, the model must still come through — proving the + // model_change fallback rather than assistant-message capture. + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-mc-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(roots.ompSessionsDir, { recursive: true }) + await writeFile( + join(roots.ompSessionsDir, 'omp-in-progress.jsonl'), + jsonLines([ + { + type: 'session', + id: 'omp-in-progress', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/tmp/omp' + }, + { type: 'model_change', model: 'omp-mc-only-model', timestamp: '2026-05-01T10:00:01.000Z' }, + { + type: 'message', + timestamp: '2026-05-01T10:00:02.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'first prompt' }] } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 5 }) + const session = result.sessions.find((s) => s.agent === 'omp') + expect(session?.model).toBe('omp-mc-only-model') }) it('strips newline-heavy Grok user_query envelopes without regex matching', async () => { @@ -769,42 +844,3 @@ describe('scanAiVaultSessions', () => { expect(usedGrokWrapperMatch).toBe(false) }) }) - -describe('buildAiVaultResumeCommand', () => { - it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => { - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: 'C:\\Users\\Ada Lovelace\\repo', - platform: 'win32' - }) - ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') - }) - - it('carries non-default Codex homes in copied resume commands', () => { - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: '/repo/app', - platform: 'darwin', - codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' - }) - ).toBe( - "cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'" - ) - - expect( - buildAiVaultResumeCommand({ - agent: 'codex', - sessionId: 'session-1', - cwd: 'C:\\Users\\Ada Lovelace\\repo', - platform: 'win32', - codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' - }) - ).toBe( - 'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""' - ) - }) -}) diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 121df82de6d..8fc3ea18b6b 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -157,6 +157,29 @@ describe('BrowserSessionRegistry persistence', () => { expect(fsState.present.has('/user-data/Partitions/orca-browser/Cookies')).toBe(true) }) + it('persists new browser session profiles under the active Orca profile directory', async () => { + const fsState = createFsState() + const profileMetaPath = '/user-data/profiles/local-work/browser-session-meta.json' + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.configureForOrcaProfile({ + orcaProfileId: 'local-work', + profileDirectory: '/user-data/profiles/local-work' + }) + const profile = browserSessionRegistry.createProfile('isolated', 'Work Browser') + + expect(profile).not.toBeNull() + expect(fsState.files.has(profileMetaPath)).toBe(true) + expect(fsState.files.has(META_PATH)).toBe(false) + expect(JSON.parse(fsState.files.get(profileMetaPath) ?? '{}').profiles[0]).toMatchObject({ + id: profile!.id, + partition: profile!.partition, + label: 'Work Browser' + }) + }) + it('merges partition-keyed pending entries without clobbering unrelated entries', async () => { const fsState = createFsState() seedMeta(fsState, { diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 363094775c7..9de0a884f23 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -28,6 +28,11 @@ vi.mock('./browser-manager', () => ({ import { browserSessionRegistry } from './browser-session-registry' import { setupClientHintsOverride } from './browser-session-ua' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' +import { + DEFAULT_LOCAL_ORCA_PROFILE_ID, + getOrcaProfileBrowserDefaultPartition, + getOrcaProfileBrowserSessionPartition +} from '../../shared/orca-profiles' describe('BrowserSessionRegistry', () => { beforeEach(() => { @@ -280,6 +285,30 @@ describe('BrowserSessionRegistry', () => { expect(webAuthnCallback).toHaveBeenCalledWith('credential-1') }) + it('uses profile-owned partitions for non-default Orca profiles', () => { + const orcaProfileId = 'local-work' + browserSessionRegistry.configureForOrcaProfile({ + orcaProfileId, + profileDirectory: '/profiles/local-work' + }) + + expect(browserSessionRegistry.getDefaultProfile().partition).toBe( + getOrcaProfileBrowserDefaultPartition(orcaProfileId) + ) + expect(browserSessionRegistry.isAllowedPartition(ORCA_BROWSER_PARTITION)).toBe(false) + + const profile = browserSessionRegistry.createProfile('isolated', 'Work Browser') + expect(profile).not.toBeNull() + expect(profile!.partition).toBe( + getOrcaProfileBrowserSessionPartition(orcaProfileId, profile!.id) + ) + + browserSessionRegistry.configureForOrcaProfile({ + orcaProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profileDirectory: '/profiles/local-default' + }) + }) + describe('setupClientHintsOverride', () => { it('overrides sec-ch-ua headers for Edge UA', () => { const onBeforeSendHeaders = vi.fn() diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 98b944de729..cc974ff7a1a 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -14,8 +14,14 @@ import { unlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' +import { + DEFAULT_LOCAL_ORCA_PROFILE_ID, + getOrcaProfileBrowserDefaultPartition, + getOrcaProfileBrowserPartitionSegment, + getOrcaProfileBrowserSessionPartition +} from '../../shared/orca-profiles' import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types' import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' @@ -36,6 +42,15 @@ type BrowserSessionMeta = { profiles: BrowserSessionProfile[] } +export type BrowserSessionRegistryProfileOptions = { + orcaProfileId: string + profileDirectory: string +} + +const BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' +const LEGACY_BROWSER_SESSION_PARTITION_RE = + /^persist:orca-browser-session-[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/ + // Why: the registry is the single source of truth for which Electron partitions // are valid. will-attach-webview consults it to decide whether a guest's // requested partition is allowed. This prevents a compromised renderer from @@ -43,13 +58,28 @@ type BrowserSessionMeta = { class BrowserSessionRegistry { private readonly profiles = new Map() + private activeOrcaProfileId = DEFAULT_LOCAL_ORCA_PROFILE_ID + private metadataPathOverride: string | null = null + private defaultPartition = ORCA_BROWSER_PARTITION constructor() { + this.resetDefaultProfile() + } + + configureForOrcaProfile(options: BrowserSessionRegistryProfileOptions): void { + this.activeOrcaProfileId = options.orcaProfileId + this.metadataPathOverride = join(options.profileDirectory, BROWSER_SESSION_META_FILE_NAME) + this.defaultPartition = getOrcaProfileBrowserDefaultPartition(options.orcaProfileId) + this.profiles.clear() + this.resetDefaultProfile() + } + + private resetDefaultProfile(): void { const persisted = this.loadPersistedSource() this.profiles.set('default', { id: 'default', scope: 'default', - partition: ORCA_BROWSER_PARTITION, + partition: this.defaultPartition, label: 'Default', source: persisted }) @@ -60,7 +90,9 @@ class BrowserSessionRegistry { // status. Cookies themselves persist in the Electron partition's SQLite DB, // but the registry is in-memory only. private get metadataPath(): string { - return join(app.getPath('userData'), 'browser-session-meta.json') + return ( + this.metadataPathOverride ?? join(app.getPath('userData'), BROWSER_SESSION_META_FILE_NAME) + ) } private loadPersistedSource(): BrowserSessionProfile['source'] { @@ -78,6 +110,7 @@ class BrowserSessionRegistry { try { const existing = this.loadPersistedMeta() const tmpPath = `${this.metadataPath}.tmp` + mkdirSync(dirname(this.metadataPath), { recursive: true }) writeFileSync(tmpPath, JSON.stringify({ ...existing, ...updates })) renameSync(tmpPath, this.metadataPath) } catch { @@ -108,8 +141,8 @@ class BrowserSessionRegistry { data && typeof data.userAgentByPartition === 'object' && data.userAgentByPartition ? { ...data.userAgentByPartition } : {} - if (legacyUserAgent && !userAgentByPartition[ORCA_BROWSER_PARTITION]) { - userAgentByPartition[ORCA_BROWSER_PARTITION] = legacyUserAgent + if (legacyUserAgent && !userAgentByPartition[this.defaultPartition]) { + userAgentByPartition[this.defaultPartition] = legacyUserAgent } const legacyPendingCookieDbPath = @@ -118,8 +151,8 @@ class BrowserSessionRegistry { data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports ? { ...data.pendingCookieImports } : {} - if (legacyPendingCookieDbPath && !pendingCookieImports[ORCA_BROWSER_PARTITION]) { - pendingCookieImports[ORCA_BROWSER_PARTITION] = legacyPendingCookieDbPath + if (legacyPendingCookieDbPath && !pendingCookieImports[this.defaultPartition]) { + pendingCookieImports[this.defaultPartition] = legacyPendingCookieDbPath } return { defaultSource: data?.defaultSource ?? null, @@ -168,10 +201,10 @@ class BrowserSessionRegistry { // hydrateFromPersisted skips the default partition and createProfile never // targets it. Without this, clipboard permissions and other guest policies // are denied by default in the default browser partition. - this.setupSessionPolicies(ORCA_BROWSER_PARTITION) + this.setupSessionPolicies(this.defaultPartition) const partitions = new Set([ - ORCA_BROWSER_PARTITION, + this.defaultPartition, ...this.listProfiles().map((p) => p.partition) ]) for (const partition of partitions) { @@ -208,9 +241,9 @@ class BrowserSessionRegistry { } // Why: replay writes to partition-derived file paths, so corrupted // metadata must pass the same validation as the webview allowlist. - const knownPartitions = new Set([ORCA_BROWSER_PARTITION]) + const knownPartitions = new Set([this.defaultPartition]) for (const profile of meta.profiles) { - if (BrowserSessionRegistry.isValidPersistedProfile(profile)) { + if (this.isValidPersistedProfile(profile)) { knownPartitions.add(profile.partition) } } @@ -270,7 +303,7 @@ class BrowserSessionRegistry { } this.persistMeta({ pendingCookieImports: remainingEntries, - pendingCookieDbPath: remainingEntries[ORCA_BROWSER_PARTITION] ?? null + pendingCookieDbPath: remainingEntries[this.defaultPartition] ?? null }) } catch { // best-effort — if this fails, CookieMonster loads the old DB @@ -282,7 +315,7 @@ class BrowserSessionRegistry { const pendingCookieImports = { ...meta.pendingCookieImports, [partition]: stagingDbPath } this.persistMeta({ pendingCookieImports, - pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null + pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null }) } @@ -296,7 +329,7 @@ class BrowserSessionRegistry { } this.persistMeta({ userAgentByPartition, - userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null + userAgent: userAgentByPartition[this.defaultPartition] ?? null }) } @@ -313,7 +346,7 @@ class BrowserSessionRegistry { } isAllowedPartition(partition: string): boolean { - if (partition === ORCA_BROWSER_PARTITION) { + if (partition === this.defaultPartition) { return true } return [...this.profiles.values()].some((p) => p.partition === partition) @@ -321,14 +354,17 @@ class BrowserSessionRegistry { resolvePartition(profileId: string | null | undefined): string { if (!profileId) { - return ORCA_BROWSER_PARTITION + return this.defaultPartition } - return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION + return this.profiles.get(profileId)?.partition ?? this.defaultPartition } resolveKnownPartition(profileId: string | null | undefined): string | null { if (!profileId) { - return ORCA_BROWSER_PARTITION + // Why: must track the active Orca profile's default partition, not the + // legacy constant, or non-default profiles would resolve local-default's + // cookie jar. + return this.defaultPartition } return this.profiles.get(profileId)?.partition ?? null } @@ -336,7 +372,7 @@ class BrowserSessionRegistry { createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null { // Why: only the constructor may create the default profile. Allowing the // renderer to pass scope:'default' would create a second profile sharing - // ORCA_BROWSER_PARTITION, causing confusion on delete (clearing storage + // the active default partition, causing confusion on delete (clearing storage // for the shared partition). if (scope === 'default') { return null @@ -345,7 +381,7 @@ class BrowserSessionRegistry { // Why: partition names are deterministic from the profile id so main can // reconstruct the allowlist on restart from persisted profile metadata // without needing a separate partition→profile mapping. - const partition = `persist:orca-browser-session-${id}` + const partition = getOrcaProfileBrowserSessionPartition(this.activeOrcaProfileId, id) const profile: BrowserSessionProfile = { id, scope, @@ -391,9 +427,9 @@ class BrowserSessionRegistry { delete userAgentByPartition[profile.partition] this.persistMeta({ pendingCookieImports, - pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null, + pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null, userAgentByPartition, - userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null + userAgent: userAgentByPartition[this.defaultPartition] ?? null }) // Why: clearing the partition's storage prevents orphaned cookies/cache from @@ -423,9 +459,9 @@ class BrowserSessionRegistry { } const meta = this.loadPersistedMeta() const pendingCookieImports = { ...meta.pendingCookieImports } - delete pendingCookieImports[ORCA_BROWSER_PARTITION] + delete pendingCookieImports[this.defaultPartition] const userAgentByPartition = { ...meta.userAgentByPartition } - delete userAgentByPartition[ORCA_BROWSER_PARTITION] + delete userAgentByPartition[this.defaultPartition] this.persistMeta({ defaultSource: null, userAgent: null, @@ -434,7 +470,7 @@ class BrowserSessionRegistry { pendingCookieImports }) - const sess = session.fromPartition(ORCA_BROWSER_PARTITION) + const sess = session.fromPartition(this.defaultPartition) await sess.clearStorageData({ storages: ['cookies'] }) return true } catch { @@ -449,9 +485,7 @@ class BrowserSessionRegistry { // tampered file could inject an arbitrary partition into the allowlist that // will-attach-webview trusts, so we validate the expected shape before // registering anything. - private static readonly PARTITION_RE = /^persist:orca-browser-session-[\da-f-]{36}$/ - - private static isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile { + private isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile { if (!profile || typeof profile !== 'object') { return false } @@ -462,17 +496,34 @@ class BrowserSessionRegistry { typeof candidate.id === 'string' && typeof candidate.partition === 'string' && typeof candidate.label === 'string' && - BrowserSessionRegistry.PARTITION_RE.test(candidate.partition) + this.isProfileOwnedSessionPartition(candidate.partition) ) } + private isProfileOwnedSessionPartition(partition: string): boolean { + if ( + this.activeOrcaProfileId === DEFAULT_LOCAL_ORCA_PROFILE_ID && + LEGACY_BROWSER_SESSION_PARTITION_RE.test(partition) + ) { + return true + } + + const segment = getOrcaProfileBrowserPartitionSegment(this.activeOrcaProfileId) + const prefix = `persist:orca-profile-${segment}-browser-session-` + if (!partition.startsWith(prefix)) { + return false + } + const profileId = partition.slice(prefix.length) + return /^[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/.test(profileId) + } + hydrateFromPersisted(profiles: BrowserSessionProfile[]): void { for (const profile of profiles) { - if (!BrowserSessionRegistry.isValidPersistedProfile(profile)) { + if (!this.isValidPersistedProfile(profile)) { continue } this.profiles.set(profile.id, profile) - if (profile.partition !== ORCA_BROWSER_PARTITION) { + if (profile.partition !== this.defaultPartition) { this.setupSessionPolicies(profile.partition) } } diff --git a/src/main/browser/browser-session-startup.test.ts b/src/main/browser/browser-session-startup.test.ts index e6f7860e325..720b0935fc9 100644 --- a/src/main/browser/browser-session-startup.test.ts +++ b/src/main/browser/browser-session-startup.test.ts @@ -1,20 +1,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' function installRegistryMock(): { + configureForOrcaProfileMock: ReturnType applyPendingCookieImportMock: ReturnType initializeBrowserSessionsFromPersistedStateMock: ReturnType } { + const configureForOrcaProfileMock = vi.fn() const applyPendingCookieImportMock = vi.fn() const initializeBrowserSessionsFromPersistedStateMock = vi.fn() vi.doMock('./browser-session-registry', () => ({ browserSessionRegistry: { + configureForOrcaProfile: configureForOrcaProfileMock, applyPendingCookieImport: applyPendingCookieImportMock, initializeBrowserSessionsFromPersistedState: initializeBrowserSessionsFromPersistedStateMock } })) return { + configureForOrcaProfileMock, applyPendingCookieImportMock, initializeBrowserSessionsFromPersistedStateMock } @@ -40,6 +44,31 @@ describe('initializeBrowserSessionsForApp', () => { ) }) + it('configures the active Orca profile before replaying browser sessions', async () => { + const { + configureForOrcaProfileMock, + applyPendingCookieImportMock, + initializeBrowserSessionsFromPersistedStateMock + } = installRegistryMock() + const { initializeBrowserSessionsForApp } = await import('./browser-session-startup') + + initializeBrowserSessionsForApp({ + orcaProfileId: 'local-work', + profileDirectory: '/profiles/local-work' + }) + + expect(configureForOrcaProfileMock).toHaveBeenCalledWith({ + orcaProfileId: 'local-work', + profileDirectory: '/profiles/local-work' + }) + expect(configureForOrcaProfileMock.mock.invocationCallOrder[0]).toBeLessThan( + applyPendingCookieImportMock.mock.invocationCallOrder[0] + ) + expect(applyPendingCookieImportMock.mock.invocationCallOrder[0]).toBeLessThan( + initializeBrowserSessionsFromPersistedStateMock.mock.invocationCallOrder[0] + ) + }) + it('initializes browser sessions once per app process', async () => { const { applyPendingCookieImportMock, initializeBrowserSessionsFromPersistedStateMock } = installRegistryMock() diff --git a/src/main/browser/browser-session-startup.ts b/src/main/browser/browser-session-startup.ts index c23867c08c1..949b3173f70 100644 --- a/src/main/browser/browser-session-startup.ts +++ b/src/main/browser/browser-session-startup.ts @@ -1,12 +1,19 @@ import { browserSessionRegistry } from './browser-session-registry' +import type { BrowserSessionRegistryProfileOptions } from './browser-session-registry' let initialized = false -export function initializeBrowserSessionsForApp(): void { +export function initializeBrowserSessionsForApp( + activeProfile?: BrowserSessionRegistryProfileOptions +): void { if (initialized) { return } + if (activeProfile) { + browserSessionRegistry.configureForOrcaProfile(activeProfile) + } + // Why: cookie replay must happen before the first session.fromPartition() // call, otherwise Chromium opens the stale live cookie DB before import. browserSessionRegistry.applyPendingCookieImport() diff --git a/src/main/browser/cdp-ws-proxy.test.ts b/src/main/browser/cdp-ws-proxy.test.ts index 60248d21b15..5c366eda727 100644 --- a/src/main/browser/cdp-ws-proxy.test.ts +++ b/src/main/browser/cdp-ws-proxy.test.ts @@ -89,6 +89,148 @@ describe('CdpWsProxy', () => { client.close() }) + it('routes Playwright nested browser and page sessions on the proxy path', async () => { + const client = await connect(endpoint) + + const primaryPageAttachResponse = await sendAndReceive(client, { + id: 31, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true } + }) + + expect(primaryPageAttachResponse).toEqual({ + id: 31, + result: { sessionId: 'orca-proxy-session' } + }) + + const attachResponse = await sendAndReceive(client, { + id: 32, + method: 'Target.attachToBrowserTarget', + params: {} + }) + + expect(attachResponse).toEqual({ + id: 32, + result: { sessionId: 'orca-proxy-browser-session' } + }) + expect(getSendCommandMethods(mock)).not.toContain('Target.attachToBrowserTarget') + + const pageAttachResponse = await sendAndReceive(client, { + id: 33, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true }, + sessionId: 'orca-proxy-browser-session' + }) + + expect(pageAttachResponse).toEqual({ + id: 33, + result: { sessionId: 'orca-proxy-session-2' }, + sessionId: 'orca-proxy-browser-session' + }) + + const sessionResponse = await sendAndReceive(client, { + id: 34, + method: 'Runtime.evaluate', + params: { expression: 'document.title' }, + sessionId: 'orca-proxy-session-2' + }) + + expect(sessionResponse).toEqual({ + id: 34, + result: {}, + sessionId: 'orca-proxy-session-2' + }) + expect(getSendCommandCalls(mock)).toContainEqual([ + 'Runtime.evaluate', + { expression: 'document.title' } + ]) + + const detachResponse = await sendAndReceive(client, { + id: 35, + method: 'Target.detachFromTarget', + params: { sessionId: 'orca-proxy-session-2' }, + sessionId: 'orca-proxy-browser-session' + }) + + expect(detachResponse).toEqual({ + id: 35, + result: {}, + sessionId: 'orca-proxy-browser-session' + }) + + const primaryPageResponse = await sendAndReceive(client, { + id: 36, + method: 'Runtime.evaluate', + params: { expression: 'document.URL' }, + sessionId: 'orca-proxy-session' + }) + + expect(primaryPageResponse).toEqual({ + id: 36, + result: {}, + sessionId: 'orca-proxy-session' + }) + + const browserSessionResponse = await sendAndReceive(client, { + id: 37, + method: 'Target.getTargets', + params: {}, + sessionId: 'orca-proxy-browser-session' + }) + + expect(browserSessionResponse).toMatchObject({ + id: 37, + sessionId: 'orca-proxy-browser-session' + }) + client.close() + }) + + it('preserves Target.attachToTarget and clears the synthetic session on Target.detachFromTarget', async () => { + const client = await connect(endpoint) + + const attachResponse = await sendAndReceive(client, { + id: 33, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true } + }) + + expect(attachResponse).toEqual({ + id: 33, + result: { sessionId: 'orca-proxy-session' } + }) + + const detachResponse = await sendAndReceive(client, { + id: 34, + method: 'Target.detachFromTarget', + params: { sessionId: 'orca-proxy-session' } + }) + + expect(detachResponse).toEqual({ id: 34, result: {} }) + + const rootEventPromise = new Promise>((resolve) => { + client.once('message', (data) => resolve(JSON.parse(data.toString()))) + }) + mock.emit('message', {}, 'Runtime.executionContextCreated', { context: { id: 1 } }) + + const rootEvent = await rootEventPromise + expect(rootEvent).toEqual({ + method: 'Runtime.executionContextCreated', + params: { context: { id: 1 } } + }) + + const reattachResponse = await sendAndReceive(client, { + id: 35, + method: 'Target.attachToTarget', + params: { targetId: 'orca-proxy-target', flatten: true } + }) + + expect(reattachResponse).toEqual({ + id: 35, + result: { sessionId: 'orca-proxy-session-2' } + }) + client.close() + }) + it('returns an error instead of crashing when a command arrives after tab destruction', async () => { const client = await connect(endpoint) mock.destroy() diff --git a/src/main/browser/cdp-ws-proxy.ts b/src/main/browser/cdp-ws-proxy.ts index 62e51342d26..99c1d554654 100644 --- a/src/main/browser/cdp-ws-proxy.ts +++ b/src/main/browser/cdp-ws-proxy.ts @@ -19,6 +19,7 @@ export class CdpWsProxy { private httpServer: Server | null = null private wss: WebSocketServer | null = null private client: WebSocket | null = null + private readonly responseSessionIdsByClient = new WeakMap>() private detachClientListeners: (() => void) | null = null private port = 0 private debuggerMessageHandler: ((...args: unknown[]) => void) | null = null @@ -27,6 +28,10 @@ export class CdpWsProxy { private attached = false // Why: agent-browser filters events by sessionId from Target.attachToTarget. private clientSessionId: string | undefined = undefined + private readonly clientSessionIds = new Set() + private readonly clientBrowserSessionIds = new Set() + private nextClientSessionOrdinal = 0 + private nextClientBrowserSessionOrdinal = 0 private readonly pdfStreams = new CdpPdfStreamStore() constructor(private readonly webContents: WebContents) {} @@ -59,7 +64,7 @@ export class CdpWsProxy { const onClose = (): void => { detach() if (this.client === ws) { - this.pdfStreams.clear() + this.clearClientState() this.client = null } } @@ -110,18 +115,45 @@ export class CdpWsProxy { this.detachClientListeners?.() this.detachClientListeners = null this.client = null - // Why: a pending focus belongs to the departing client; never replay it for the next one. - this.pendingDomFocusBySession.clear() - this.pdfStreams.clear() + this.clearClientState() + if (client) { + this.responseSessionIdsByClient.delete(client) + } client?.close() } + private clearClientState(): void { + // Why: session and focus state belongs to one websocket and must not cross client replacement. + this.pendingDomFocusBySession.clear() + this.pdfStreams.clear() + this.clientSessionId = undefined + this.clientSessionIds.clear() + this.clientBrowserSessionIds.clear() + this.nextClientSessionOrdinal = 0 + this.nextClientBrowserSessionOrdinal = 0 + } + private send(payload: unknown, client = this.client): void { + const responsePayload = client ? this.addResponseSessionId(payload, client) : payload if (client?.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(payload)) + client.send(JSON.stringify(responsePayload)) } } + private addResponseSessionId(payload: unknown, client: WebSocket): unknown { + if (typeof payload !== 'object' || payload === null) { + return payload + } + const clientId = (payload as { id?: unknown }).id + if (typeof clientId !== 'number') { + return payload + } + const responseSessionIds = this.responseSessionIdsByClient.get(client) + const sessionId = responseSessionIds?.get(clientId) + responseSessionIds?.delete(clientId) + return sessionId ? { ...payload, sessionId } : payload + } + private sendResult(clientId: number, result: unknown, client = this.client): void { this.send({ id: clientId, result }, client) } @@ -253,6 +285,13 @@ export class CdpWsProxy { return } const clientId = msg.id + const responseSessionIds = this.responseSessionIdsByClient.get(client) ?? new Map() + if (msg.sessionId) { + responseSessionIds.set(clientId, msg.sessionId) + } else { + responseSessionIds.delete(clientId) + } + this.responseSessionIdsByClient.set(client, responseSessionIds) if (msg.method === 'Target.getTargets') { this.sendResult(clientId, { targetInfos: [this.buildTargetInfo()] }, client) @@ -264,14 +303,30 @@ export class CdpWsProxy { } if (msg.method === 'Target.setDiscoverTargets' || msg.method === 'Target.detachFromTarget') { if (msg.method === 'Target.detachFromTarget') { - this.clientSessionId = undefined + const detachedSessionId = msg.params?.sessionId + if (typeof detachedSessionId === 'string') { + this.clientSessionIds.delete(detachedSessionId) + this.clientBrowserSessionIds.delete(detachedSessionId) + if (detachedSessionId === this.clientSessionId) { + this.clientSessionId = this.clientSessionIds.values().next().value + } + } } this.sendResult(clientId, {}, client) return } + if (msg.method === 'Target.attachToBrowserTarget') { + // Why: Playwright needs a distinct browser session before it attaches to the selected page. + const sessionId = this.nextSyntheticBrowserSessionId() + this.clientBrowserSessionIds.add(sessionId) + this.sendResult(clientId, { sessionId }, client) + return + } if (msg.method === 'Target.attachToTarget') { - this.clientSessionId = 'orca-proxy-session' - this.sendResult(clientId, { sessionId: this.clientSessionId }, client) + const sessionId = this.nextSyntheticPageSessionId() + this.clientSessionIds.add(sessionId) + this.clientSessionId ??= sessionId + this.sendResult(clientId, { sessionId }, client) return } if (msg.method === 'Browser.getVersion') { @@ -363,7 +418,24 @@ export class CdpWsProxy { } private resolveDebuggerSessionId(msgSessionId?: string): string | undefined { - return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined + const syntheticSession = + (msgSessionId && this.clientSessionIds.has(msgSessionId)) || + (msgSessionId && this.clientBrowserSessionIds.has(msgSessionId)) + return msgSessionId && !syntheticSession ? msgSessionId : undefined + } + + private nextSyntheticPageSessionId(): string { + this.nextClientSessionOrdinal += 1 + return this.nextClientSessionOrdinal === 1 + ? 'orca-proxy-session' + : `orca-proxy-session-${this.nextClientSessionOrdinal}` + } + + private nextSyntheticBrowserSessionId(): string { + this.nextClientBrowserSessionOrdinal += 1 + return this.nextClientBrowserSessionOrdinal === 1 + ? 'orca-proxy-browser-session' + : `orca-proxy-browser-session-${this.nextClientBrowserSessionOrdinal}` } private isActiveClient(client: WebSocket): boolean { diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9a6b8566f57..9447e81ef75 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -533,6 +533,124 @@ describe('CodexRuntimeHomeService', () => { } }) + it('promotes WSL in-Codex setting changes on the next Codex launch', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground: vi.fn(() => Promise.resolve()) + })) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + const wslSystemConfigPath = join(wslHome, '.codex', 'config.toml') + mkdirSync(join(wslHome, '.codex'), { recursive: true }) + writeFileSync(wslSystemConfigPath, 'model = "gpt-5"\n', 'utf-8') + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + // First launch seeds the runtime config and records the per-distro baseline. + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + const baselinePath = join(wslRuntimeHomePath, '.orca-config-settings-baseline.json') + expect(existsSync(baselinePath)).toBe(true) + + // A direct WSL Codex edit wins and is mirrored into Orca's runtime before + // the baseline advances, so later in-Orca changes remain promotable. + const runtimeConfigPath = join(wslRuntimeHomePath, 'config.toml') + writeFileSync(wslSystemConfigPath, 'model = "outside-edit"\n', 'utf-8') + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "outside-edit"\n') + expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"outside-edit\\""') + + // Codex now persists a /model change inside Orca's reconciled runtime. + writeFileSync( + runtimeConfigPath, + readFileSync(runtimeConfigPath, 'utf-8').replace('model = "outside-edit"', 'model = "o4"'), + 'utf-8' + ) + + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + expect(readFileSync(wslSystemConfigPath, 'utf-8')).toBe('model = "o4"\n') + // Baseline advances so the promoted value is not re-promoted forever. + expect(readFileSync(baselinePath, 'utf-8')).toContain('"model": "\\"o4\\""') + } finally { + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('../wsl') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('bridges WSL history from a configured per-distro source-home override', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground + })) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } }, + // Why: the override is a Linux path inside the distro, not /.codex. + codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ + distro: 'Ubuntu', + systemCodexHomePath: '/home/me/.config/codex', + managedCodexHomePath: wslRuntimeHomePath + }) + } finally { + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('../wsl') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('starts WSL session bridging for the distro used by the materialized runtime home', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) @@ -1842,7 +1960,7 @@ describe('CodexRuntimeHomeService', () => { } }) - it('uses the stable WSL runtime home for WSL system-default rate-limit fetches', async () => { + it('reads WSL system-default rate limits from the live system home without materializing', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) const wslHome = join(testState.userDataDir, 'wsl-home') @@ -1860,10 +1978,21 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home') + const syncWslRuntime = vi.spyOn( + service as unknown as { + syncWslRuntimeForCurrentSelection: (target: { + runtime: 'wsl' + wslDistro?: string | null + }) => string | null + }, + 'syncWslRuntimeForCurrentSelection' ) + const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + const expectedHome = join(wslHome, '.codex') + + expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome) + expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome) + expect(syncWslRuntime).not.toHaveBeenCalled() } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) @@ -1991,20 +2120,23 @@ describe('CodexRuntimeHomeService', () => { try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - const wslRuntimeHomePath = join( - wslHome, - '.local', - 'share', - 'orca', - 'codex-runtime-home', - 'home' - ) - expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( - wslRuntimeHomePath + systemCodexHomePath ) expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) - expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(systemDefaultAuth) + const externallyRefreshedAuth = createCodexAuthJson( + 'wsl@example.com', + 'acct-wsl', + 'system-refreshed', + 3_000 + ) + writeFileSync(join(systemCodexHomePath, 'auth.json'), externallyRefreshedAuth, 'utf-8') + expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + systemCodexHomePath + ) + expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe( + externallyRefreshedAuth + ) } finally { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 3d2b0670354..754a0f1113a 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -40,6 +40,10 @@ import { syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' +import { + resolveHostCodexSessionSourceHome, + resolveWslCodexSessionSourceHome +} from '../codex/codex-session-source-home' import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' import { prepareSystemConfigForFreshRuntimeMirror, @@ -135,9 +139,9 @@ export class CodexRuntimeHomeService { prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - const runtimeHomePath = - this.syncWslRuntimeForCurrentSelection(wslTarget) ?? - this.getWslSystemCodexHomePath(wslTarget) + const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) + this.syncWslConfigSettingsForLaunch(wslTarget, syncedRuntimeHomePath) + const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) return runtimeHomePath } @@ -146,7 +150,10 @@ export class CodexRuntimeHomeService { syncSystemConfigIntoManagedCodexHome() // Why: historical Codex sessions can be large; bridge them after launch // setup so starting a fresh Codex TUI never waits on a full tree walk. - void startSystemCodexSessionBridgeInBackground() + void startSystemCodexSessionBridgeInBackground( + {}, + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) return this.getRuntimeHomePath() } @@ -162,10 +169,11 @@ export class CodexRuntimeHomeService { if (!distro) { return } - const systemCodexHomePath = this.getWslSystemCodexHomePath({ - runtime: 'wsl', - wslDistro: distro - }) + // Why: history-only override lets custom-CODEX_HOME users bridge from their + // real home; falls back to /.codex, which auth/config still use. + const systemCodexHomePath = + resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) { return } @@ -214,13 +222,33 @@ export class CodexRuntimeHomeService { return home ? this.joinWslPath(home, '.codex') : null } + // Why: WSL needs the same promote-then-mirror transaction as host. It keeps + // in-Orca changes while reconciling a newer external ~/.codex edit before + // advancing the per-distro baseline. Only runs on a materialized runtime. + private syncWslConfigSettingsForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (!runtimeHomePath) { + return + } + const distro = + parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return + } + const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + if (!systemHomePath || systemHomePath === runtimeHomePath) { + return + } + syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath }) + } + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - return ( - this.syncWslRuntimeForCurrentSelection(wslTarget) ?? - this.getWslSystemCodexHomePath(wslTarget) - ) + const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) + return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() @@ -495,6 +523,32 @@ export class CodexRuntimeHomeService { return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null } + private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { + const distro = target.wslDistro?.trim() + if (distro) { + const settings = this.store.getSettings() + const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) + if (selectedAccountId === null) { + // Why: the system-default account changes outside Orca (login, logout, + // token refresh). Read its real home directly so a cached runtime copy + // cannot stay stale; filesystem probing in the fetcher is asynchronous. + return this.getWslSystemCodexHomePath(target) + } + const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) + if ( + cachedRuntimeHomePath && + this.lastSyncedWslAccountIdByDistro.has(distro) && + this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId + ) { + // Why: RateLimitService resolves provenance twice per poll. Account + // changes sync explicitly, so repeated resolution must stay path-only + // instead of blocking main on UNC reads and a wsl.exe migration probe. + return cachedRuntimeHomePath + } + } + return this.syncWslRuntimeForCurrentSelection(target) + } + private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 69fed98ea8d..abcafe1b438 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { GlobalSettings } from '../../shared/types' +import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' const testState = { userDataDir: '', @@ -766,20 +767,20 @@ describe('CodexAccountService config sync', () => { if (script.includes('readlink -f')) { return `${wslLinuxHomePath}\n` } + if (script.includes('command -v codex')) { + throw new Error('bash -ic does not inherit the distro login-shell PATH') + } + if (args.slice(2, 5).join(' ') === '-- sh -c') { + expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian')) + return '' + } mkdirSync(wslManagedHomePath, { recursive: true }) writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n') return '' }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Debian', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Debian', wslLinuxHomePath)) // Why: codex login runs inside WSL, so the rewritten path must be the // Linux-side ~/.codex, not a Windows UNC path. expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( @@ -876,8 +877,8 @@ describe('CodexAccountService config sync', () => { if (script.includes('readlink -f')) { return `${wslLinuxHomePath}\n` } - if (script.includes('command -v codex')) { - expect(args.slice(0, 5)).toEqual(['-d', 'Debian', '--exec', 'bash', '-ic']) + if (args.slice(2, 5).join(' ') === '-- sh -c') { + expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian')) throw new Error('codex missing') } mkdirSync(wslManagedHomePath, { recursive: true }) @@ -960,14 +961,7 @@ describe('CodexAccountService config sync', () => { }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Ubuntu', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath)) const child = new EventEmitter() as EventEmitter & { stdout: PassThrough stderr: PassThrough @@ -1075,14 +1069,7 @@ describe('CodexAccountService config sync', () => { }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Ubuntu', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath)) expect(readFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'utf-8')).toBe( 'account-1\n' ) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index c979725633b..2a0fb54bdc4 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -22,6 +22,11 @@ import type { RateLimitService } from '../rate-limits/service' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { buildEncodedWslBashCommand } from '../wsl-bash-command' +import { + buildWslCodexAvailabilityArgs, + buildWslCodexLoginArgs, + WSL_CODEX_AVAILABILITY_TIMEOUT_MS +} from './wsl-codex-command' import { getCodexSelectionTargetForAccount, getSelectedCodexAccountIdForTarget, @@ -805,15 +810,7 @@ export class CodexAccountService { const spawnConfig = wslInfo ? { command: 'wsl.exe', - // Why: nvm and similar WSL installs often initialize PATH from interactive shell config. - args: [ - '-d', - wslInfo.distro, - '--exec', - 'bash', - '-ic', - `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}; exec codex login` - ], + args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath), env: process.env, codexCommand: 'codex' } @@ -923,18 +920,10 @@ export class CodexAccountService { private assertWslCodexCliAvailable(wslInfo: { distro: string; linuxPath: string }): void { try { - execFileSync( - 'wsl.exe', - [ - '-d', - wslInfo.distro, - '--exec', - 'bash', - '-ic', - buildEncodedWslBashCommand('command -v codex >/dev/null 2>&1') - ], - { encoding: 'utf-8', timeout: 5000 } - ) + execFileSync('wsl.exe', buildWslCodexAvailabilityArgs(wslInfo.distro), { + encoding: 'utf-8', + timeout: WSL_CODEX_AVAILABILITY_TIMEOUT_MS + }) } catch (error) { throw new Error( `Codex CLI is not available in WSL ${wslInfo.distro}. Install Codex in that distro or switch Account location to Windows.`, diff --git a/src/main/codex-accounts/wsl-codex-command.test.ts b/src/main/codex-accounts/wsl-codex-command.test.ts new file mode 100644 index 00000000000..f696ddcde4f --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-command.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' + +describe('WSL Codex commands', () => { + it('checks the alias-neutral PATH from the distro login shell', () => { + const args = buildWslCodexAvailabilityArgs('Ubuntu24-Dev') + + expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu24-Dev', '--', 'sh', '-c']) + expect(args.at(-1)).toContain('getent passwd') + expect(args.at(-1)).toContain('_orca_lookup_command=') + expect(args.at(-1)).toContain('codex') + expect(args.at(-1)).not.toContain('bash -ic') + }) + + it('launches the resolved Codex executable with its quoted managed home', () => { + const args = buildWslCodexLoginArgs('Ubuntu', '/home/alice/managed-home') + const command = args.at(-1) + + expect(command).toContain('export CODEX_HOME=') + expect(command).toContain('/home/alice/managed-home') + expect(command).toContain('exec "\\$resolved" login') + }) +}) diff --git a/src/main/codex-accounts/wsl-codex-command.ts b/src/main/codex-accounts/wsl-codex-command.ts new file mode 100644 index 00000000000..02527c8ed23 --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-command.ts @@ -0,0 +1,43 @@ +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows, + quotePosixShell +} from '../../shared/wsl-login-shell-command' + +export const WSL_CODEX_AVAILABILITY_TIMEOUT_MS = 5_000 + +export function buildWslCodexAvailabilityArgs(distro: string): string[] { + const command = [buildCodexPathLookup(), '[ -n "$resolved" ]'].join('\n') + return buildWslCodexShellArgs(distro, command) +} + +export function buildWslCodexLoginArgs(distro: string, linuxHomePath: string): string[] { + const command = [ + buildCodexPathLookup(), + 'if [ -z "$resolved" ]; then', + " printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2", + ' exit 127', + 'fi', + `export CODEX_HOME=${quotePosixShell(linuxHomePath)}`, + 'exec "$resolved" login' + ].join('\n') + return buildWslCodexShellArgs(distro, command) +} + +function buildCodexPathLookup(): string { + return buildPosixCommandPathLookupScript({ kind: 'literal', value: 'codex' }) +} + +function buildWslCodexShellArgs(distro: string, command: string): string[] { + // Why: Codex must use the distro user's configured login shell, whose PATH + // can differ from a hard-coded non-login bash invocation. + return [ + '-d', + distro, + '--', + 'sh', + '-c', + escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) + ] +} diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 416ce9af78b..885e2030548 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -25,6 +25,7 @@ vi.mock('node:os', async () => { import { prepareSystemConfigForFreshRuntimeMirror, + resolveCodexConfigMirrorSourceDirectory, syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' @@ -402,6 +403,20 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { }) describe('prepareSystemConfigForFreshRuntimeMirror', () => { + it('uses the Linux-side directory for WSL UNC source homes', () => { + const sourceDir = resolveCodexConfigMirrorSourceDirectory( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + ) + + expect(sourceDir).toBe('/home/alice/.codex') + expect( + prepareSystemConfigForFreshRuntimeMirror( + 'model_instructions_file = "instructions.md"\n', + sourceDir + ) + ).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'") + }) + it('rewrites relative paths against a Linux-side home and strips hook trust', () => { const prepared = prepareSystemConfigForFreshRuntimeMirror( [ diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index ce9208e9b05..422b0e79280 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -3,6 +3,12 @@ import { dirname, join } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { + promoteCodexRuntimeSettingsToSystem, + snapshotCodexRuntimeSettingsBaseline, + type CodexSettingsPromotionHomes +} from './config-settings-promotion' import { createTomlLineScanState, getTomlTableHeader, @@ -10,25 +16,37 @@ import { updateTomlLineScanState } from './config-toml-line-scan' -function getRuntimeCodexConfigTomlPath(): string { - return join(getOrcaManagedCodexHomePath(), 'config.toml') -} - -function getSystemCodexConfigTomlPath(): string { - return join(getSystemCodexHomePath(), 'config.toml') -} - -export function syncSystemConfigIntoManagedCodexHome(): void { +export function syncSystemConfigIntoManagedCodexHome( + homes: CodexSettingsPromotionHomes = { + runtimeHomePath: getOrcaManagedCodexHomePath(), + systemHomePath: getSystemCodexHomePath() + } +): void { + // Why: the mirror overwrites runtime settings from ~/.codex, so changes the + // user made inside Orca-launched Codex (/model, /approvals) must be written + // back to ~/.codex first or this very pass silently reverts them. + if (!promoteCodexRuntimeSettingsToSystem(homes)) { + // Why: mirroring after a failed write-back would erase the runtime change; + // leave both runtime and its old baseline intact so the next launch retries. + return + } try { - syncSystemConfigIntoManagedCodexHomeUnsafe() + syncSystemConfigIntoManagedCodexHomeUnsafe(homes) } catch (error) { console.warn('[codex-config] Failed to mirror system Codex config:', error) + return } + // Why: the baseline advances only after a successful mirror; recording an + // unpromoted runtime change as Orca-written would strand it forever. + snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath) } -function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { - const systemConfigPath = getSystemCodexConfigTomlPath() - const runtimeConfigPath = getRuntimeCodexConfigTomlPath() +function syncSystemConfigIntoManagedCodexHomeUnsafe({ + runtimeHomePath, + systemHomePath +}: CodexSettingsPromotionHomes): void { + const systemConfigPath = join(systemHomePath, 'config.toml') + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') const systemConfigExists = existsSync(systemConfigPath) const runtimeConfigExists = existsSync(runtimeConfigPath) if (!systemConfigExists && !runtimeConfigExists) { @@ -36,18 +54,16 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { } const rawSystemConfig = systemConfigExists ? readFileSync(systemConfigPath, 'utf-8') : '' + const sourceConfigDir = resolveCodexConfigMirrorSourceDirectory(systemHomePath) if (!runtimeConfigExists) { writeFileAtomically( runtimeConfigPath, - prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, dirname(systemConfigPath)) + prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir) ) return } - const systemConfig = prepareSystemConfigForRuntimeMirror( - rawSystemConfig, - dirname(systemConfigPath) - ) + const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir) const runtimeConfig = readFileSync(runtimeConfigPath, 'utf-8') const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig) if (mergedConfig !== runtimeConfig) { @@ -55,6 +71,10 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { } } +export function resolveCodexConfigMirrorSourceDirectory(systemHomePath: string): string { + return parseWslUncPath(systemHomePath)?.linuxPath ?? dirname(join(systemHomePath, 'config.toml')) +} + function prepareSystemConfigForRuntimeMirror(config: string, systemConfigDir: string): string { return rewriteRelativePathConfigValues( normalizeDeprecatedCodexHookFeatureFlag(config), diff --git a/src/main/codex/codex-hook-identity.ts b/src/main/codex/codex-hook-identity.ts new file mode 100644 index 00000000000..cc78a9acb76 --- /dev/null +++ b/src/main/codex/codex-hook-identity.ts @@ -0,0 +1,72 @@ +import type { HookCommandConfig, HookDefinition } from '../agent-hooks/installer-utils' +import type { CodexEventLabel, CodexTrustEntry } from './config-toml-trust' + +// Why: Codex's trust hash key uses the snake_case event label (see +// codex-rs/hooks/src/lib.rs::hook_event_key_label) while hooks.json uses the +// PascalCase serde-rename. Keep the mapping in one shared module so the +// install, status, and write-back promotion paths cannot drift. +export const CODEX_HOOK_EVENT_LABEL: Record = { + SessionStart: 'session_start', + UserPromptSubmit: 'user_prompt_submit', + PreToolUse: 'pre_tool_use', + PermissionRequest: 'permission_request', + PostToolUse: 'post_tool_use', + Stop: 'stop', + PreCompact: 'pre_compact', + PostCompact: 'post_compact' +} + +export const CODEX_EVENT_NAME_BY_LABEL: Record = { + session_start: 'SessionStart', + user_prompt_submit: 'UserPromptSubmit', + pre_tool_use: 'PreToolUse', + permission_request: 'PermissionRequest', + post_tool_use: 'PostToolUse', + stop: 'Stop', + pre_compact: 'PreCompact', + post_compact: 'PostCompact' +} + +export function getCodexManagedScriptFileName(): string { + return process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' +} + +export function createCodexHookTrustEntry( + sourcePath: string, + eventName: string, + groupIndex: number, + handlerIndex: number, + definition: HookDefinition, + hook: HookCommandConfig +): CodexTrustEntry | null { + const eventLabel = CODEX_HOOK_EVENT_LABEL[eventName] + if (!eventLabel || !hook.command) { + return null + } + + return { + sourcePath, + eventLabel, + groupIndex, + handlerIndex, + command: hook.command, + ...(typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}), + ...(typeof hook.async === 'boolean' ? { async: hook.async } : {}), + ...(typeof definition.matcher === 'string' ? { matcher: definition.matcher } : {}), + ...(typeof hook.statusMessage === 'string' ? { statusMessage: hook.statusMessage } : {}) + } +} + +// Why: identity of a hook's content independent of file position, used to +// match the same hook across the system and runtime hooks.json layouts +// (dedupe and the prepended managed hook shift positions between them). +export function getCodexHookTrustSignature(entry: CodexTrustEntry): string { + return JSON.stringify({ + eventLabel: entry.eventLabel, + command: entry.command, + timeoutSec: Math.max(1, entry.timeoutSec ?? 600), + async: entry.async ?? false, + matcher: entry.matcher ?? null, + statusMessage: entry.statusMessage ?? null + }) +} diff --git a/src/main/codex/codex-session-bridge.test.ts b/src/main/codex/codex-session-bridge.test.ts index ae4e17be693..ed3c838ea7c 100644 --- a/src/main/codex/codex-session-bridge.test.ts +++ b/src/main/codex/codex-session-bridge.test.ts @@ -215,6 +215,51 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { ).toBe(false) }) + it('bridges from a custom source home override instead of ~/.codex', () => { + // Why: users with a custom CODEX_HOME point history discovery at that + // folder; the default ~/.codex must be ignored when an override is given. + const customSourceHome = join(fakeHomeDir, 'custom-codex') + const customSessionPath = join( + customSourceHome, + 'sessions', + '2026', + '05', + '26', + 'rollout-custom.jsonl' + ) + mkdirSync(dirname(customSessionPath), { recursive: true }) + writeFileSync(customSessionPath, '{"id":"custom"}\n', 'utf-8') + + // A session under the default ~/.codex should NOT be bridged when overridden. + const defaultSessionPath = join( + getSystemCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-default.jsonl' + ) + mkdirSync(dirname(defaultSessionPath), { recursive: true }) + writeFileSync(defaultSessionPath, '{"id":"default"}\n', 'utf-8') + + syncSystemCodexSessionsIntoManagedHome(customSourceHome) + + const bridgedCustomPath = join( + getRuntimeCodexHomePath(), + 'sessions', + '2026', + '05', + '26', + 'rollout-custom.jsonl' + ) + expect(readFileSync(bridgedCustomPath, 'utf-8')).toBe('{"id":"custom"}\n') + expect( + existsSync( + join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'rollout-default.jsonl') + ) + ).toBe(false) + }) + it('falls back to symlinks when hardlinks are unavailable', () => { fsMockState.failLink = true const systemSessionPath = join( diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts index a574228953e..e546ce9d8f8 100644 --- a/src/main/codex/codex-session-bridge.ts +++ b/src/main/codex/codex-session-bridge.ts @@ -42,9 +42,12 @@ let backgroundSessionBridgeTask: Promise | null = null /** * Synchronously mirrors system session files into the managed runtime home. + * + * `sourceCodexHomePath` overrides the default ~/.codex history source for users + * who run Codex with a custom CODEX_HOME; it only affects history discovery. */ -export function syncSystemCodexSessionsIntoManagedHome(): void { - const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions') +export function syncSystemCodexSessionsIntoManagedHome(sourceCodexHomePath?: string): void { + const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions') if (!existsSync(systemSessionsRoot)) { return } @@ -62,12 +65,13 @@ export function syncSystemCodexSessionsIntoManagedHome(): void { * background bridging without starting duplicate directory walks. */ export function startSystemCodexSessionBridgeInBackground( - options: CodexSessionBridgeIncrementalOptions = {} + options: CodexSessionBridgeIncrementalOptions = {}, + sourceCodexHomePath?: string ): Promise { if (backgroundSessionBridgeTask) { return backgroundSessionBridgeTask } - const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options) + const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options, sourceCodexHomePath) .catch((error: unknown) => { console.warn('[codex-session-bridge] Background session bridge failed:', error) }) @@ -88,9 +92,10 @@ export function startSystemCodexSessionBridgeInBackground( * bridge operation equivalent to the synchronous path. */ export async function syncSystemCodexSessionsIntoManagedHomeIncrementally( - options: CodexSessionBridgeIncrementalOptions = {} + options: CodexSessionBridgeIncrementalOptions = {}, + sourceCodexHomePath?: string ): Promise { - const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions') + const systemSessionsRoot = join(sourceCodexHomePath || getSystemCodexHomePath(), 'sessions') if (!existsSync(systemSessionsRoot)) { return { scannedFiles: 0, linkedFiles: 0 } } diff --git a/src/main/codex/codex-session-source-home.test.ts b/src/main/codex/codex-session-source-home.test.ts new file mode 100644 index 00000000000..fa29650c495 --- /dev/null +++ b/src/main/codex/codex-session-source-home.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + resolveHostCodexSessionSourceHome, + resolveWslCodexSessionSourceHome +} from './codex-session-source-home' + +describe('resolveHostCodexSessionSourceHome', () => { + it('returns undefined when no override is configured', () => { + expect(resolveHostCodexSessionSourceHome({})).toBeUndefined() + expect(resolveHostCodexSessionSourceHome({ codexSessionSourceHome: {} })).toBeUndefined() + }) + + it('returns undefined for blank/whitespace overrides so the default is kept', () => { + expect( + resolveHostCodexSessionSourceHome({ codexSessionSourceHome: { host: ' ' } }) + ).toBeUndefined() + }) + + it('returns the trimmed host override path', () => { + expect( + resolveHostCodexSessionSourceHome({ codexSessionSourceHome: { host: ' /custom/codex ' } }) + ).toBe('/custom/codex') + }) +}) + +describe('resolveWslCodexSessionSourceHome', () => { + it('returns undefined when no per-distro override exists', () => { + expect(resolveWslCodexSessionSourceHome({}, 'Ubuntu')).toBeUndefined() + expect( + resolveWslCodexSessionSourceHome( + { codexSessionSourceHome: { wsl: { Debian: '/home/me/.codex' } } }, + 'Ubuntu' + ) + ).toBeUndefined() + }) + + it('resolves a per-distro override for the matching distro', () => { + expect( + resolveWslCodexSessionSourceHome( + { codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } }, + 'Ubuntu' + ) + ).toBe('/home/me/.config/codex') + }) + + it('matches distro names case-insensitively, mirroring WSL', () => { + expect( + resolveWslCodexSessionSourceHome( + { codexSessionSourceHome: { wsl: { Ubuntu: '/home/me/.config/codex' } } }, + 'ubuntu' + ) + ).toBe('/home/me/.config/codex') + }) + + it('ignores blank per-distro overrides so the default is kept', () => { + expect( + resolveWslCodexSessionSourceHome( + { codexSessionSourceHome: { wsl: { Ubuntu: ' ' } } }, + 'Ubuntu' + ) + ).toBeUndefined() + }) + + it('does not leak the host override into WSL resolution', () => { + expect( + resolveWslCodexSessionSourceHome( + { codexSessionSourceHome: { host: '/custom/codex' } }, + 'Ubuntu' + ) + ).toBeUndefined() + }) +}) diff --git a/src/main/codex/codex-session-source-home.ts b/src/main/codex/codex-session-source-home.ts new file mode 100644 index 00000000000..0060051e924 --- /dev/null +++ b/src/main/codex/codex-session-source-home.ts @@ -0,0 +1,43 @@ +import type { GlobalSettings } from '../../shared/types' + +/** + * Resolves the user-configured Codex *session history* source home, if any. + * + * Why: Orca relocates CODEX_HOME to a managed home, then bridges history from + * the user's real Codex home so /resume finds it. That source defaults to + * ~/.codex, but users who run Codex with a custom CODEX_HOME need to point + * history discovery at that folder. This override affects history only; auth, + * config, and hooks continue to read from ~/.codex. + */ + +/** Host override; returns undefined to keep the default ~/.codex source. */ +export function resolveHostCodexSessionSourceHome( + settings: Pick +): string | undefined { + return normalizeSourceHome(settings.codexSessionSourceHome?.host) +} + +/** Per-distro WSL override; returns undefined to keep the default /.codex source. */ +export function resolveWslCodexSessionSourceHome( + settings: Pick, + distro: string +): string | undefined { + const perDistro = settings.codexSessionSourceHome?.wsl + if (!perDistro) { + return undefined + } + // Why: distro keys are matched case-insensitively so "Ubuntu" and "ubuntu" + // resolve the same override, mirroring how WSL treats distro names. + const normalizedDistro = distro.trim().toLowerCase() + for (const [key, value] of Object.entries(perDistro)) { + if (key.trim().toLowerCase() === normalizedDistro) { + return normalizeSourceHome(value) + } + } + return undefined +} + +function normalizeSourceHome(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts new file mode 100644 index 00000000000..e7878777bff --- /dev/null +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -0,0 +1,87 @@ +import { execFileSync } from 'node:child_process' +import { win32 as pathWin32 } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' + +export type CodexWslRuntimeHookTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type CodexWslRuntimeHookInstallPlan = { + configPath: string + tomlPath: string + scriptPath: string + commandScriptPath: string + trustConfigPath: string +} + +export type CanonicalizeWslLinuxPath = (distro: string, linuxPath: string) => string | null + +function trimTrailingSlash(value: string): string { + return value.length > 1 ? value.replace(/\/+$/, '') : value +} + +function toDefaultWslLinuxPath(windowsPath: string): string { + const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\](.*)$/) + if (!driveMatch) { + return windowsPath + } + return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}` +} + +function canonicalizeWslLinuxPath(distro: string, linuxPath: string): string | null { + if (process.platform !== 'win32') { + return linuxPath + } + try { + const canonicalPath = execFileSync( + 'wsl.exe', + ['-d', distro, '--', 'readlink', '-f', '--', linuxPath], + { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000 + } + ).trim() + return canonicalPath.startsWith('/') ? canonicalPath : null + } catch { + return null + } +} + +export function createCodexWslRuntimeHookInstallPlan( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget, + canonicalize: CanonicalizeWslLinuxPath = canonicalizeWslLinuxPath +): CodexWslRuntimeHookInstallPlan | null { + if (!runtimeHomePath) { + return null + } + + const wslInfo = parseWslUncPath(runtimeHomePath) + if (!wslInfo && target?.runtime !== 'wsl') { + return null + } + const distro = wslInfo?.distro || (target?.runtime === 'wsl' ? target.wslDistro?.trim() : null) + if (!distro) { + return null + } + + const logicalLinuxRuntimeHome = wslInfo?.linuxPath ?? toDefaultWslLinuxPath(runtimeHomePath) + if (!logicalLinuxRuntimeHome.startsWith('/')) { + return null + } + // Why: Codex canonicalizes hook sources inside WSL; resolving there keeps + // trust keys valid when HOME or the runtime directory crosses a symlink. + const linuxRuntimeHome = trimTrailingSlash( + canonicalize(distro, logicalLinuxRuntimeHome) ?? logicalLinuxRuntimeHome + ) + + return { + configPath: pathWin32.join(runtimeHomePath, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHomePath, 'config.toml'), + scriptPath: pathWin32.join(runtimeHomePath, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: `${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`, + trustConfigPath: `${linuxRuntimeHome}/hooks.json` + } +} diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts new file mode 100644 index 00000000000..f6ceabc362f --- /dev/null +++ b/src/main/codex/config-settings-promotion.test.ts @@ -0,0 +1,442 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import type * as CodexFsUtils from '../codex-accounts/fs-utils' + +const { homedirMock, promotionTestState } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>(), + promotionTestState: { failAtomicWrite: false } +})) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +vi.mock('../codex-accounts/fs-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileAtomically: (...args: Parameters) => { + if (promotionTestState.failAtomicWrite) { + throw new Error('injected atomic write failure') + } + return actual.writeFileAtomically(...args) + } + } +}) + +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { upsertTopLevelSettingsInContent } from './config-settings-promotion' + +let tmpHome: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-settings-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-settings-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(tmpHome) + promotionTestState.failAtomicWrite = false + // Why: promotion writes into homedir()/.codex — if the mock ever fails to + // intercept, these tests would rewrite the developer's real Codex config. + if (homedir() !== tmpHome) { + throw new Error('node:os homedir mock is not active; refusing to touch the real ~/.codex') + } +}) + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +function systemConfigPath(): string { + return join(tmpHome, '.codex', 'config.toml') +} + +function runtimeHomeDir(): string { + return join(userDataDir, 'codex-runtime-home', 'home') +} + +function runtimeConfigPath(): string { + return join(runtimeHomeDir(), 'config.toml') +} + +function baselinePath(): string { + return join(runtimeHomeDir(), '.orca-config-settings-baseline.json') +} + +function writeSystemConfig(content: string): void { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + writeFileSync(systemConfigPath(), content, 'utf-8') +} + +function readSystemConfig(): string { + return readFileSync(systemConfigPath(), 'utf-8') +} + +function readRuntimeConfig(): string { + return readFileSync(runtimeConfigPath(), 'utf-8') +} + +// Mimics how Codex (toml_edit) persists a /model or /approvals change: the +// top-level key line is rewritten in place, or created when absent. +function simulateCodexSettingWrite(key: string, rawValue: string): void { + mkdirSync(runtimeHomeDir(), { recursive: true }) + const existing = existsSync(runtimeConfigPath()) ? readFileSync(runtimeConfigPath(), 'utf-8') : '' + const linePattern = new RegExp(`^${key}[ \\t]*=.*$`, 'm') + const rendered = `${key} = ${rawValue}` + const next = linePattern.test(existing) + ? existing.replace(linePattern, rendered) + : `${rendered}\n${existing}` + writeFileSync(runtimeConfigPath(), next, 'utf-8') +} + +function simulateCodexSettingRemoval(key: string): void { + const existing = readFileSync(runtimeConfigPath(), 'utf-8') + const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm') + writeFileSync(runtimeConfigPath(), existing.replace(linePattern, ''), 'utf-8') +} + +describe('codex settings write-back promotion', () => { + it('promotes an in-Codex model change to ~/.codex and reaches a steady state', () => { + writeSystemConfig( + 'model = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n' + ) + syncSystemConfigIntoManagedCodexHome() + expect(existsSync(baselinePath())).toBe(true) + + simulateCodexSettingWrite('model', '"gpt-5.5-codex"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5.5-codex"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n' + ) + expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"') + + const settledSystem = readSystemConfig() + const settledRuntime = readRuntimeConfig() + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe(settledSystem) + expect(readRuntimeConfig()).toBe(settledRuntime) + }) + + it('promotes multiple approvals keys in one pass', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('approval_policy', '"never"') + simulateCodexSettingWrite('sandbox_mode', '"danger-full-access"') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system).toContain('approval_policy = "never"') + expect(system).toContain('sandbox_mode = "danger-full-access"') + expect(system).toContain('model = "gpt-5"') + }) + + it('does not promote on the first pass without a baseline, then promotes after one', () => { + writeSystemConfig('model = "gpt-5"\n') + mkdirSync(runtimeHomeDir(), { recursive: true }) + // Pre-upgrade state: runtime already diverged, but no baseline exists. + writeFileSync(runtimeConfigPath(), 'model = "user-changed-before-upgrade"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).toContain('model = "gpt-5"') + expect(existsSync(baselinePath())).toBe(true) + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "o4"\n') + }) + + it('treats a corrupt baseline as missing and rewrites it', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + writeFileSync(baselinePath(), 'not json', 'utf-8') + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 1 }) + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "o4"\n') + }) + + it('lets an outside ~/.codex edit win over a conflicting in-Codex change', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"in-codex-choice"') + writeSystemConfig('model = "outside-edit"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "outside-edit"\n') + expect(readRuntimeConfig()).toContain('model = "outside-edit"') + }) + + it('inserts a key ~/.codex lacks into the preamble without disturbing the rest', () => { + writeSystemConfig('# my codex config\nmodel = "gpt-5"\n\n[features]\nhooks = true\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('approval_policy', '"on-request"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + '# my codex config\nmodel = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n' + ) + }) + + it('creates ~/.codex/config.toml when a user without one changes a setting', () => { + // Why: no mkdir of ~/.codex here — a genuinely fresh host has neither the + // config nor its directory, and promotion must create both. + expect(existsSync(join(tmpHome, '.codex'))).toBe(false) + syncSystemConfigIntoManagedCodexHome() + expect(existsSync(baselinePath())).toBe(true) + + // Codex itself creates the runtime config.toml on the first /model write. + simulateCodexSettingWrite('model', '"gpt-5.5-codex"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5.5-codex"\n') + expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"') + }) + + it('does not promote a key deletion', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingRemoval('model') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).toContain('model = "gpt-5"') + }) + + it('ignores keys outside the allowlist', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('notify', '["custom-notifier"]') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + }) + + it('ignores allowlisted keys inside tables such as [profiles.*]', () => { + writeSystemConfig('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n') + syncSystemConfigIntoManagedCodexHome() + + const runtime = readRuntimeConfig() + writeFileSync( + runtimeConfigPath(), + runtime.replace('model = "profile-model"', 'model = "profile-changed"'), + 'utf-8' + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n') + }) + + it('never rewrites a multiline system value', () => { + writeSystemConfig('model = """\nodd\nmultiline\n"""\n') + syncSystemConfigIntoManagedCodexHome() + + writeFileSync(runtimeConfigPath(), 'model = "single"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = """\nodd\nmultiline\n"""\n') + }) + + it('preserves CRLF line endings when replacing a value', () => { + writeSystemConfig('model = "gpt-5"\r\napproval_policy = "on-request"\r\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('model = "o4"\r\n') + expect(readSystemConfig()).toContain('approval_policy = "on-request"\r\n') + }) + + it('promotes over a value that carried an inline comment', () => { + writeSystemConfig('model = "gpt-5" # my favorite\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "o4"\n') + }) + + it.skipIf(process.platform === 'win32')( + 'preserves a restrictive mode on the existing ~/.codex/config.toml', + () => { + writeSystemConfig('model = "gpt-5"\n') + chmodSync(systemConfigPath(), 0o600) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "o4"\n') + expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600) + } + ) + + it.skipIf(process.platform === 'win32')( + 'creates a new ~/.codex/config.toml with owner-only permissions', + () => { + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(statSync(systemConfigPath()).mode & 0o777).toBe(0o600) + // The created ~/.codex itself is owner-only — it will also hold + // auth.json once the user signs in. + expect(statSync(join(tmpHome, '.codex')).mode & 0o777).toBe(0o700) + } + ) + + it.skipIf(process.platform === 'win32')( + 'writes through a symlinked config.toml without replacing the link', + () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + const realConfigPath = join(tmpHome, 'dotfiles-config.toml') + writeFileSync(realConfigPath, 'model = "gpt-5"\n', 'utf-8') + symlinkSync(realConfigPath, systemConfigPath()) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(lstatSync(systemConfigPath()).isSymbolicLink()).toBe(true) + expect(readFileSync(realConfigPath, 'utf-8')).toBe('model = "o4"\n') + } + ) + + it.skipIf(process.platform === 'win32')( + 'preserves a dangling config.toml symlink and creates its target', + () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + const realConfigPath = join(tmpHome, 'dotfiles', 'config.toml') + symlinkSync(realConfigPath, systemConfigPath()) + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(lstatSync(systemConfigPath()).isSymbolicLink()).toBe(true) + expect(readFileSync(realConfigPath, 'utf-8')).toBe('model = "o4"\n') + } + ) + + it('inserts a missing key into a CRLF config with CRLF endings', () => { + writeSystemConfig('model = "gpt-5"\r\n\r\n[features]\r\nhooks = true\r\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('approval_policy', '"never"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5"\r\napproval_policy = "never"\r\n\r\n[features]\r\nhooks = true\r\n' + ) + }) + + it('does not rewrite an unchanged settings baseline', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + const past = new Date(Date.now() - 120_000) + utimesSync(baselinePath(), past, past) + syncSystemConfigIntoManagedCodexHome() + + expect(statSync(baselinePath()).mtimeMs).toBeLessThan(Date.now() - 60_000) + }) + + it('keeps the old baseline and retries after a transient promotion failure', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + const baselineBeforeFailure = readFileSync(baselinePath(), 'utf-8') + simulateCodexSettingWrite('model', '"o4"') + + promotionTestState.failAtomicWrite = true + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).toBe('model = "o4"\n') + expect(readFileSync(baselinePath(), 'utf-8')).toBe(baselineBeforeFailure) + + promotionTestState.failAtomicWrite = false + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "o4"\n') + }) +}) + +describe('upsertTopLevelSettingsInContent', () => { + it('writes into empty content', () => { + expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n') + }) + + it('inserts before the first table with a separating blank line', () => { + expect( + upsertTopLevelSettingsInContent('[features]\nhooks = true\n', new Map([['model', '"x"']])) + ).toBe('model = "x"\n\n[features]\nhooks = true\n') + }) + + it('appends to a preamble-only file without a trailing newline', () => { + expect( + upsertTopLevelSettingsInContent('approval_policy = "never"', new Map([['model', '"x"']])) + ).toBe('approval_policy = "never"\nmodel = "x"\n') + }) + + it('replaces the existing line in place', () => { + expect( + upsertTopLevelSettingsInContent( + '# keep\nmodel = "old"\n\n[t]\nk = 1\n', + new Map([['model', '"new"']]) + ) + ).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n') + }) + + it('inserts with CRLF endings into CRLF content', () => { + expect( + upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']])) + ).toBe('model = "x"\r\n\r\n[features]\r\nhooks = true\r\n') + }) + + it('appends with CRLF to a CRLF preamble-only file', () => { + expect( + upsertTopLevelSettingsInContent('approval_policy = "never"\r\n', new Map([['model', '"x"']])) + ).toBe('approval_policy = "never"\r\nmodel = "x"\r\n') + }) +}) diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts new file mode 100644 index 00000000000..7e8552bb9b6 --- /dev/null +++ b/src/main/codex/config-settings-promotion.ts @@ -0,0 +1,341 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + realpathSync, + statSync, + writeFileSync +} from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' + +// Why: the config mirror rewrites the runtime config.toml from ~/.codex on +// every launch (and on background rate-limit fetches), so settings the user +// changes inside Orca-launched Codex silently revert. Promotion diffs the +// runtime file against a baseline of what Orca last wrote — anything that +// differs is a change Codex persisted for the user and belongs in ~/.codex. + +// Why: only the user-preference scalars the Codex TUI itself persists +// (/model writes model + model_reasoning_effort, /approvals writes +// approval_policy + sandbox_mode). Every key added here gets written into the +// user's real ~/.codex/config.toml, so grow this list deliberately. +export const PROMOTED_CODEX_SETTING_KEYS = [ + 'model', + 'model_reasoning_effort', + 'approval_policy', + 'sandbox_mode' +] as const + +type TopLevelSettingValue = { + raw: string + // Why: a value that opens a multiline string/array cannot be replaced or + // copied line-by-line safely, so it is excluded from promotion entirely. + multiline: boolean +} + +type SettingsBaselineFile = { + version: 1 + settings: Record +} + +function getSettingsBaselinePath(runtimeHomePath: string): string { + return join(runtimeHomePath, '.orca-config-settings-baseline.json') +} + +function readSettingsBaseline(runtimeHomePath: string): Map | null { + const baselinePath = getSettingsBaselinePath(runtimeHomePath) + if (!existsSync(baselinePath)) { + return null + } + try { + const parsed: unknown = JSON.parse(readFileSync(baselinePath, 'utf-8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const settings = (parsed as SettingsBaselineFile).settings + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + return null + } + const result = new Map() + for (const [key, value] of Object.entries(settings)) { + if (typeof value === 'string') { + result.set(key, value) + } + } + return result + } catch { + return null + } +} + +// Why: only keys in the top-level preamble are scanned — Codex writes profile +// overrides into [profiles.*] tables, and rewriting nested tables surgically +// is not worth the risk for stage-1 promotion. +function readTopLevelSettingValues(configPath: string): Map { + const result = new Map() + if (!existsSync(configPath)) { + return result + } + const lines = readFileSync(configPath, 'utf-8').split('\n') + let state = createTomlLineScanState() + for (const line of lines) { + if (isTomlStructuralLine(state)) { + if (getTomlTableHeader(line)) { + break + } + const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line) + const key = match?.[1] + if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) { + const nextState = updateTomlLineScanState(state, line) + result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) }) + state = nextState + continue + } + } + state = updateTomlLineScanState(state, line) + } + return result +} + +/** + * Records the promotable top-level settings the runtime config.toml holds + * after a mirror, so the next promotion can tell "value Orca mirrored" apart + * from "value Codex wrote for the user". Call after a successful mirror only — + * advancing the baseline past an unpromoted change would strand it forever. + */ +export function snapshotCodexRuntimeSettingsBaseline( + runtimeHomePath = getOrcaManagedCodexHomePath() +): void { + try { + const runtimeTomlPath = join(runtimeHomePath, 'config.toml') + // Why: a missing runtime config still records an empty baseline — when + // Codex later creates the file for a user with no ~/.codex/config.toml, + // that first change must diff against "Orca left nothing" and promote. + const settings: Record = {} + for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) { + if (!value.multiline) { + settings[key] = value.raw + } + } + const file: SettingsBaselineFile = { version: 1, settings } + const baselinePath = getSettingsBaselinePath(runtimeHomePath) + const serialized = `${JSON.stringify(file, null, 2)}\n` + // Why: launch preparation can run repeatedly; skip byte-identical rewrites + // so an unchanged pass does no disk writes. + if (existsSync(baselinePath) && readFileSync(baselinePath, 'utf-8') === serialized) { + return + } + writeFileSync(baselinePath, serialized, { + encoding: 'utf-8', + mode: 0o600 + }) + } catch (error) { + console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error) + } +} + +export type CodexSettingsPromotionHomes = { + runtimeHomePath: string + systemHomePath: string +} + +function getHostPromotionHomes(): CodexSettingsPromotionHomes { + return { + runtimeHomePath: getOrcaManagedCodexHomePath(), + systemHomePath: getSystemCodexHomePath() + } +} + +/** + * Promotes setting changes the user made inside Orca-launched Codex (written + * by Codex into the runtime config.toml) into ~/.codex/config.toml. Runs + * before the config mirror so the promoted values survive the same mirror + * pass instead of reverting. WSL callers pass explicit per-distro homes; the + * default is the host runtime home and host ~/.codex. + */ +export function promoteCodexRuntimeSettingsToSystem(homes?: CodexSettingsPromotionHomes): boolean { + try { + promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes()) + return true + } catch (error) { + // Why: promotion is best-effort launch prep; callers preserve the runtime + // for retry, while a malformed file must not block Codex launch itself. + console.warn('[codex-settings-promotion] failed to promote runtime settings', error) + return false + } +} + +function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotionHomes): void { + const { runtimeHomePath, systemHomePath } = homes + const runtimeTomlPath = join(runtimeHomePath, 'config.toml') + const systemTomlPath = join(systemHomePath, 'config.toml') + if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) { + return + } + if (!existsSync(runtimeTomlPath)) { + return + } + // Why: without a baseline of what Orca last mirrored (first launch after + // upgrading to a build with promotion, or a corrupted snapshot), a stale + // runtime value is indistinguishable from a fresh in-Codex change. Skip + // this pass — the mirror writes the first baseline and promotion starts on + // the next one. + const baseline = readSettingsBaseline(runtimeHomePath) + if (!baseline) { + return + } + const runtimeValues = readTopLevelSettingValues(runtimeTomlPath) + const systemValues = readTopLevelSettingValues(systemTomlPath) + const updates = new Map() + for (const key of PROMOTED_CODEX_SETTING_KEYS) { + const runtime = runtimeValues.get(key) + if (!runtime || runtime.multiline) { + continue + } + if (runtime.raw === baseline.get(key)) { + // Orca mirrored this value and nothing touched it since — not a change. + continue + } + const system = systemValues.get(key) + if (system?.multiline) { + continue + } + // Why: ~/.codex stays source of truth — if the user also edited it there + // since the baseline, the outside edit wins over the in-Codex change. + if (system?.raw !== baseline.get(key)) { + continue + } + updates.set(key, runtime.raw) + } + if (updates.size === 0) { + return + } + // Why: a genuinely fresh host has no ~/.codex yet; without the directory + // the atomic write ENOENTs and the following mirror wipes the setting. + // Owner-only: the directory holds auth.json and the full user config. + mkdirSync(systemHomePath, { recursive: true, mode: 0o700 }) + const writeTarget = resolvePromotionWriteTarget(systemTomlPath) + // Why: a dangling dotfile-manager symlink can point into a directory tree + // that has not been materialized yet; preserve the link and create its real + // parent so the atomic temp file can be written beside the target. + mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 }) + const targetExists = existsSync(writeTarget.path) + const systemContent = targetExists ? readFileSync(writeTarget.path, 'utf-8') : '' + const nextContent = upsertTopLevelSettingsInContent(systemContent, updates) + if (targetExists && parseWslUncPath(writeTarget.path)) { + // Why: symlink metadata through the \\wsl$ 9P provider is not reliable + // enough for realpath/lstat detection, and an atomic rename would replace + // a WSL-side dotfile symlink with a plain file. Writing through the + // existing file preserves the Linux-side inode (and its mode). + writeFileSync(writeTarget.path, nextContent, 'utf-8') + return + } + writeFileAtomically(writeTarget.path, nextContent, { + mode: writeTarget.mode + }) +} + +// Why: promotion rewrites the user's real config.toml. Follow an existing +// symlink (dotfile managers) instead of replacing the link with a plain file, +// and carry the real file's mode forward — an atomic write without a mode +// would widen a user-restricted 0600 config to the process umask default. +// A new or unreadable target is created owner-only. +function resolvePromotionWriteTarget(systemTomlPath: string): { path: string; mode: number } { + try { + const realPath = realpathSync(systemTomlPath) + return { path: realPath, mode: statSync(realPath).mode & 0o777 } + } catch { + // Continue below: realpath also fails for a valid dangling dotfile link. + } + try { + if (lstatSync(systemTomlPath).isSymbolicLink()) { + const targetPath = resolveDanglingSymlinkTarget(systemTomlPath) + return { path: targetPath, mode: 0o600 } + } + } catch { + // Missing non-link targets are created owner-only at the requested path. + } + return { path: systemTomlPath, mode: 0o600 } +} + +function resolveDanglingSymlinkTarget(linkPath: string): string { + let currentPath = linkPath + const visited = new Set() + while (!visited.has(currentPath)) { + visited.add(currentPath) + try { + if (!lstatSync(currentPath).isSymbolicLink()) { + return currentPath + } + currentPath = resolve(dirname(currentPath), readlinkSync(currentPath)) + } catch { + return currentPath + } + } + // Why: replacing any link in a cycle would destroy dotfile-manager state; + // abort promotion and leave the runtime/baseline intact for manual repair. + throw new Error(`Codex config symlink cycle at ${linkPath}`) +} + +export function upsertTopLevelSettingsInContent( + content: string, + updates: Map +): string { + const lines = content.split('\n') + let state = createTomlLineScanState() + let preambleEnd = lines.length + const keyLineIndexes = new Map() + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (isTomlStructuralLine(state)) { + if (getTomlTableHeader(line)) { + preambleEnd = index + break + } + const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line) + if (match?.[1] && updates.has(match[1])) { + keyLineIndexes.set(match[1], index) + } + } + state = updateTomlLineScanState(state, line) + } + + // Why: CRLF configs keep a trailing \r after the split; new lines must use + // the file's existing endings or a Windows-owned config becomes mixed-EOL. + const usesCrlf = content.includes('\r\n') + const insertions: string[] = [] + for (const [key, raw] of updates) { + const existingIndex = keyLineIndexes.get(key) + const rendered = `${key} = ${raw}` + if (existingIndex !== undefined) { + lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered + } else { + insertions.push(usesCrlf ? `${rendered}\r` : rendered) + } + } + if (insertions.length > 0) { + let insertAt = preambleEnd + while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') { + insertAt -= 1 + } + if (insertAt === preambleEnd && preambleEnd < lines.length) { + insertions.push(usesCrlf ? '\r' : '') + } + lines.splice(insertAt, 0, ...insertions) + } + const result = lines.join('\n') + if (result.endsWith('\n') || result.length === 0) { + return result + } + return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}` +} diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index cdf44768135..54dc00bc83d 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -55,6 +55,13 @@ export type CodexTrustEntry = { matcher?: string /** Optional statusMessage field. */ statusMessage?: string + /** Verbatim hash to write instead of computing one. Used when carrying a + * Codex-written approval across files, so trust survives even if Codex's + * hash algorithm drifts from computeTrustedHash. Never fed into hashing. */ + trustedHash?: string + /** Explicit enabled state to write. When absent, a pre-existing + * `enabled = false` on the target block is preserved. */ + enabled?: boolean } export type CodexHookTrustState = { @@ -308,7 +315,8 @@ export function upsertHookTrustEntriesInContent( updated = upsertTrustBlocks( updated, getTrustKeyWriteVariants(computeTrustKey(entry)), - computeTrustedHash(entry) + entry.trustedHash ?? computeTrustedHash(entry), + entry.enabled ) } return updated @@ -420,7 +428,12 @@ export function escapeTomlString(value: string): string { .replaceAll('\t', '\\t') } -function upsertTrustBlocks(content: string, keys: readonly string[], hash: string): string { +function upsertTrustBlocks( + content: string, + keys: readonly string[], + hash: string, + explicitEnabled?: boolean +): string { const ranges = keys .flatMap((key) => findTrustBlockRanges(content, key)) .filter( @@ -431,7 +444,7 @@ function upsertTrustBlocks(content: string, keys: readonly string[], hash: strin ) .sort((a, b) => a.start - b.start) if (ranges.length === 0) { - const block = buildTrustBlocks(keys, hash, true) + const block = buildTrustBlocks(keys, hash, explicitEnabled ?? true) if (content.length === 0) { return `${block}\n` } @@ -446,13 +459,17 @@ function upsertTrustBlocks(content: string, keys: readonly string[], hash: strin // silently re-enabled by the next auto-install on app start. // If duplicate blocks already exist, treat any disabled copy as authoritative // while collapsing the malformed TOML back to one table. - const enabled = !ranges.some((range) => { - const existingBlock = content.slice(range.headerLineEnd, range.end) - const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec( - existingBlock - ) - return enabledMatch?.[1] === 'false' - }) + // An explicit enabled state (write-back promotion of an in-Orca /hooks + // toggle) overrides that preservation: it IS the user's latest decision. + const enabled = + explicitEnabled ?? + !ranges.some((range) => { + const existingBlock = content.slice(range.headerLineEnd, range.end) + const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec( + existingBlock + ) + return enabledMatch?.[1] === 'false' + }) const block = buildTrustBlocks(keys, hash, enabled) let cursor = 0 let deduped = '' diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts new file mode 100644 index 00000000000..20accbb5859 --- /dev/null +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -0,0 +1,296 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { dirname, join, win32 as pathWin32 } from 'node:path' + +import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' +import { + computeTrustKey, + computeTrustedHash, + readHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { + _internals, + createCodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookInstallPlan +} from './hook-service' + +type HooksConfig = { + hooks: Record +} + +const managedEvents = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PermissionRequest', + 'PostToolUse', + 'Stop' +] as const + +let tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots) { + rmSync(root, { recursive: true, force: true }) + } + tempRoots = [] +}) + +function createTestPlan(): CodexWslRuntimeHookInstallPlan { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-wsl-hooks-')) + tempRoots.push(root) + const linuxHome = '/home/alice/.local/share/orca/codex-runtime-home/home' + return { + configPath: join(root, 'hooks.json'), + tomlPath: join(root, 'config.toml'), + scriptPath: join(root, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: `${linuxHome}/.orca/agent-hooks/codex-hook.sh`, + trustConfigPath: `${linuxHome}/hooks.json` + } +} + +function getManagedTrustEntry( + plan: CodexWslRuntimeHookInstallPlan, + command: string +): CodexTrustEntry { + return { + sourcePath: plan.trustConfigPath, + eventLabel: 'user_prompt_submit', + groupIndex: 0, + handlerIndex: 0, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + } +} + +describe('Codex WSL runtime hook install', () => { + it('plans WSL hook files with Linux command and trust paths', () => { + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + + expect( + createCodexWslRuntimeHookInstallPlan(runtimeHome, undefined, (_distro, path) => path) + ).toEqual({ + configPath: pathWin32.join(runtimeHome, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHome, 'config.toml'), + scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: + '/home/alice/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/home/alice/.local/share/orca/codex-runtime-home/home/hooks.json' + }) + }) + + it('plans WSL hooks when the distro home is mounted on a Windows drive', () => { + const runtimeHome = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home' + + expect( + createCodexWslRuntimeHookInstallPlan( + runtimeHome, + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + (_distro, path) => path + ) + ).toEqual({ + configPath: pathWin32.join(runtimeHome, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHome, 'config.toml'), + scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: + '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/hooks.json' + }) + }) + + it('uses WSL-canonical paths for hook commands and trust keys', () => { + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu\\home\\alias\\.local\\share\\orca\\codex-runtime-home\\home' + const canonicalHome = '/home/alice/.local/share/orca/codex-runtime-home/home' + + const plan = createCodexWslRuntimeHookInstallPlan( + runtimeHome, + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + (distro, linuxPath) => { + expect(distro).toBe('Ubuntu') + expect(linuxPath).toBe('/home/alias/.local/share/orca/codex-runtime-home/home') + return canonicalHome + } + ) + + expect(plan?.commandScriptPath).toBe(`${canonicalHome}/.orca/agent-hooks/codex-hook.sh`) + expect(plan?.trustConfigPath).toBe(`${canonicalHome}/hooks.json`) + expect(plan?.configPath).toBe(pathWin32.join(runtimeHome, 'hooks.json')) + }) + + it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', () => { + const script = _internals.getManagedScript('posix') + expect(script).toContain('load_hook_endpoint()') + expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)') + expect(script).toContain('post_codex_hook()') + expect(script).toContain('is_wsl_runtime()') + expect(script).toContain('WSL_DISTRO_NAME') + expect(script).toContain('windows_curl=$(command -v curl.exe 2>/dev/null || true)') + expect(script).toContain('--data-urlencode "payload@-"') + expect(script).toContain('if post_codex_hook curl >/dev/null 2>&1; then') + expect(script).toContain('post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true') + }) + + it.skipIf(process.platform === 'win32')( + 'refreshes stale hook coordinates from a Windows endpoint file', + () => { + const plan = createTestPlan() + const root = dirname(plan.configPath) + const endpointPath = join(root, 'endpoint.cmd') + const binDir = join(root, 'bin') + const curlPath = join(binDir, 'curl') + const capturePath = join(root, 'curl-args.txt') + mkdirSync(binDir, { recursive: true }) + writeFileSync( + endpointPath, + [ + 'set ORCA_AGENT_HOOK_PORT=43210', + 'set ORCA_AGENT_HOOK_TOKEN=fresh-token', + 'set ORCA_AGENT_HOOK_ENV=development', + 'set ORCA_AGENT_HOOK_VERSION=1', + '' + ].join('\r\n'), + 'utf-8' + ) + writeFileSync( + curlPath, + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ORCA_TEST_CAPTURE"\ncat >> "$ORCA_TEST_CAPTURE"\n', + 'utf-8' + ) + chmodSync(curlPath, 0o755) + mkdirSync(dirname(plan.scriptPath), { recursive: true }) + writeFileSync(plan.scriptPath, _internals.getManagedScript('posix'), 'utf-8') + + const result = spawnSync('/bin/sh', [plan.scriptPath], { + encoding: 'utf-8', + input: '{"hook_event_name":"UserPromptSubmit","prompt":"restart"}', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + ORCA_AGENT_HOOK_ENDPOINT: endpointPath, + ORCA_AGENT_HOOK_PORT: '1', + ORCA_AGENT_HOOK_TOKEN: 'stale-token', + ORCA_PANE_KEY: 'pane-1', + ORCA_TEST_CAPTURE: capturePath + } + }) + + expect(result.status).toBe(0) + const posted = readFileSync(capturePath, 'utf-8') + expect(posted).toContain('http://127.0.0.1:43210/hook/codex') + expect(posted).toContain('X-Orca-Agent-Hook-Token: fresh-token') + expect(posted).not.toContain('stale-token') + } + ) + + it.skipIf(process.platform === 'win32')( + 'uses the Windows curl discovered from the WSL PATH after loopback fails', + () => { + const plan = createTestPlan() + const root = dirname(plan.configPath) + const binDir = join(root, 'bin') + const capturePath = join(root, 'windows-curl-args.txt') + mkdirSync(binDir, { recursive: true }) + writeFileSync(join(binDir, 'curl'), '#!/bin/sh\nexit 7\n', 'utf-8') + writeFileSync( + join(binDir, 'curl.exe'), + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ORCA_TEST_CAPTURE"\ncat >> "$ORCA_TEST_CAPTURE"\n', + 'utf-8' + ) + chmodSync(join(binDir, 'curl'), 0o755) + chmodSync(join(binDir, 'curl.exe'), 0o755) + mkdirSync(dirname(plan.scriptPath), { recursive: true }) + writeFileSync(plan.scriptPath, _internals.getManagedScript('posix'), 'utf-8') + + const result = spawnSync('/bin/sh', [plan.scriptPath], { + encoding: 'utf-8', + input: '{"hook_event_name":"Stop"}', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + WSL_DISTRO_NAME: 'Ubuntu', + ORCA_AGENT_HOOK_ENDPOINT: '', + ORCA_AGENT_HOOK_PORT: '43210', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'pane-1', + ORCA_TEST_CAPTURE: capturePath + } + }) + + expect(result.status).toBe(0) + const posted = readFileSync(capturePath, 'utf-8') + expect(posted).toContain('http://127.0.0.1:43210/hook/codex') + expect(posted).toContain('X-Orca-Agent-Hook-Token: token') + } + ) + + it('installs trusted WSL hooks and removes only Orca entries when disabled', () => { + const plan = createTestPlan() + const userCommand = '/bin/sh /home/alice/user-hook.sh' + writeFileSync( + plan.configPath, + `${JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: userCommand }] }], + PreCompact: [ + { + hooks: [ + { + type: 'command', + command: + "if [ -x '/old/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/old/.orca/agent-hooks/codex-hook.sh'; fi" + } + ] + } + ] + } + })}\n`, + 'utf-8' + ) + const unrelatedTrustKey = '/home/alice/custom/hooks.json:stop:0:0' + writeFileSync( + plan.tomlPath, + `[hooks.state."${unrelatedTrustKey}"]\nenabled = false\ntrusted_hash = "sha256:user"\n`, + 'utf-8' + ) + + expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + + const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig + expect(Object.keys(installed.hooks).sort()).toEqual([...managedEvents].sort()) + const managedCommand = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command + expect(managedCommand).toBe( + `if [ -r '${plan.commandScriptPath}' ]; then /bin/sh '${plan.commandScriptPath}'; fi` + ) + expect(installed.hooks.UserPromptSubmit[1]?.hooks?.[0]?.command).toBe(userCommand) + expect(readFileSync(plan.scriptPath, 'utf-8')).toContain('command -v curl.exe') + + const managedTrustEntry = getManagedTrustEntry(plan, managedCommand!) + const trustEntries = readHookTrustEntries(plan.tomlPath) + expect(trustEntries.get(computeTrustKey(managedTrustEntry))).toEqual({ + enabled: true, + trustedHash: computeTrustedHash(managedTrustEntry) + }) + expect(trustEntries.get(unrelatedTrustKey)).toEqual({ + enabled: false, + trustedHash: 'sha256:user' + }) + + expect(_internals.refreshWslRuntimeUserHooks(plan).state).toBe('not_installed') + + const refreshed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig + expect(refreshed.hooks).toEqual({ + UserPromptSubmit: [{ hooks: [{ type: 'command', command: userCommand }] }] + }) + const refreshedTrustEntries = readHookTrustEntries(plan.tomlPath) + expect(refreshedTrustEntries.has(computeTrustKey(managedTrustEntry))).toBe(false) + expect(refreshedTrustEntries.get(unrelatedTrustKey)).toEqual({ + enabled: false, + trustedHash: 'sha256:user' + }) + }) +}) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 0b01d65554f..31e552df55c 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -16,7 +16,6 @@ import { wrapWindowsCmdHookCommand, writeHooksJson, writeManagedScript, - type HookCommandConfig, type HookDefinition } from '../agent-hooks/installer-utils' import { @@ -44,6 +43,21 @@ import { } from './config-toml-trust' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { + createCodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookTarget +} from './codex-wsl-hook-install-plan' +import { + CODEX_HOOK_EVENT_LABEL, + createCodexHookTrustEntry, + getCodexHookTrustSignature, + getCodexManagedScriptFileName +} from './codex-hook-identity' +import { + promoteCodexRuntimeHookApprovalsToSystem, + snapshotCodexRuntimeHookTrustProvenance +} from './hook-trust-promotion' // Why: PreToolUse/PostToolUse give the dashboard a live readout of the // in-flight tool (name + input preview) between UserPromptSubmit and Stop. @@ -73,29 +87,21 @@ function getCodexConfigTomlPath(): string { return join(getOrcaManagedCodexHomePath(), 'config.toml') } -// Why: Codex's hash key uses the snake_case event label (see -// codex-rs/hooks/src/lib.rs::hook_event_key_label). Our hooks.json uses the -// PascalCase serde-rename. Map between them at one place so the trust-write -// path can't drift from the install path. +// Why: the managed-event subset of the shared PascalCase→label map; the +// full mapping lives in codex-hook-identity.ts so promotion can't drift. const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> = { - SessionStart: 'session_start', - UserPromptSubmit: 'user_prompt_submit', - PreToolUse: 'pre_tool_use', - PermissionRequest: 'permission_request', - PostToolUse: 'post_tool_use', - Stop: 'stop' + SessionStart: CODEX_HOOK_EVENT_LABEL.SessionStart!, + UserPromptSubmit: CODEX_HOOK_EVENT_LABEL.UserPromptSubmit!, + PreToolUse: CODEX_HOOK_EVENT_LABEL.PreToolUse!, + PermissionRequest: CODEX_HOOK_EVENT_LABEL.PermissionRequest!, + PostToolUse: CODEX_HOOK_EVENT_LABEL.PostToolUse!, + Stop: CODEX_HOOK_EVENT_LABEL.Stop! } const CODEX_MANAGED_EVENT_LABELS = new Set( CODEX_EVENTS.map((eventName) => CODEX_EVENT_LABEL[eventName]) ) -const CODEX_HOOK_EVENT_LABEL: Record = { - ...CODEX_EVENT_LABEL, - PreCompact: 'pre_compact', - PostCompact: 'post_compact' -} - const CODEX_PLUGIN_ONLY_HOOK_PLACEHOLDERS = [ '${CLAUDE_PLUGIN_ROOT}', '${CLAUDE_PLUGIN_DATA}', @@ -112,12 +118,8 @@ type MirroredRuntimeUserHookTrustEntry = { enabled: boolean } -function getManagedScriptFileName(): string { - return process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' -} - function getManagedScriptPath(): string { - return getSharedManagedScriptPath(getManagedScriptFileName()) + return getSharedManagedScriptPath(getCodexManagedScriptFileName()) } function getManagedCommand(scriptPath: string): string { @@ -126,6 +128,16 @@ function getManagedCommand(scriptPath: string): string { : wrapPosixHookCommand(scriptPath) } +export { createCodexWslRuntimeHookInstallPlan } +export type { CodexWslRuntimeHookInstallPlan } + +function wrapReadablePosixHookCommand(scriptPath: string): string { + const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'` + // Why: WSL runtime hooks are written from Windows through UNC, where the + // executable bit is not reliable. /bin/sh only needs the script to be readable. + return `if [ -r ${quoted} ]; then /bin/sh ${quoted}; fi` +} + function getSystemConfigPath(): string { return join(getSystemCodexHomePath(), 'hooks.json') } @@ -151,7 +163,7 @@ function collectManagedTrustEntries( if (!isManagedCommand(hook.command)) { return } - const entry = createHookTrustEntry( + const entry = createCodexHookTrustEntry( sourcePath, eventName, groupIndex, @@ -167,32 +179,6 @@ function collectManagedTrustEntries( return entries } -function createHookTrustEntry( - sourcePath: string, - eventName: string, - groupIndex: number, - handlerIndex: number, - definition: HookDefinition, - hook: HookCommandConfig -): CodexTrustEntry | null { - const eventLabel = CODEX_HOOK_EVENT_LABEL[eventName] - if (!eventLabel || !hook.command) { - return null - } - - return { - sourcePath, - eventLabel, - groupIndex, - handlerIndex, - command: hook.command, - ...(typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}), - ...(typeof hook.async === 'boolean' ? { async: hook.async } : {}), - ...(typeof definition.matcher === 'string' ? { matcher: definition.matcher } : {}), - ...(typeof hook.statusMessage === 'string' ? { statusMessage: hook.statusMessage } : {}) - } -} - function removeMatchingTrustEntries(configPath: string, entries: readonly CodexTrustEntry[]): void { if (entries.length === 0) { return @@ -218,7 +204,7 @@ function removeStaleRuntimeHookTrustEntries( const expectedHashes = new Map( expectedEntries.map((entry) => [ normalizeHookTrustKeyForLookup(computeTrustKey(entry)), - computeTrustedHash(entry) + entry.trustedHash ?? computeTrustedHash(entry) ]) ) const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath) @@ -238,17 +224,6 @@ function removeStaleRuntimeHookTrustEntries( } } -function getTrustSignature(entry: CodexTrustEntry): string { - return JSON.stringify({ - eventLabel: entry.eventLabel, - command: entry.command, - timeoutSec: Math.max(1, entry.timeoutSec ?? 600), - async: entry.async ?? false, - matcher: entry.matcher ?? null, - statusMessage: entry.statusMessage ?? null - }) -} - function commandUsesCodexPluginOnlyPlaceholder(command: string | undefined): boolean { return ( typeof command === 'string' && @@ -316,12 +291,17 @@ function getRuntimeHooksWithSystemUserHooks( } } +type TrustedSystemHookSignatureState = { + enabled: boolean + trustedHash: string +} + function getTrustedSystemUserHookSignatures( systemConfigPath: string, systemHooks: Record, isManagedCommand: (command: string | undefined) => boolean -): Map { - const signatures = new Map() +): Map { + const signatures = new Map() let trustEntries: Map try { trustEntries = readHookTrustEntries(getSystemCodexConfigTomlPath()) @@ -342,7 +322,7 @@ function getTrustedSystemUserHookSignatures( if (isManagedCommand(hook.command)) { return } - const entry = createHookTrustEntry( + const entry = createCodexHookTrustEntry( systemConfigPath, eventName, groupIndex, @@ -353,20 +333,15 @@ function getTrustedSystemUserHookSignatures( if (!entry) { return } - const expectedHash = computeTrustedHash(entry) - const state = trustEntries.get(computeTrustKey(entry)) - const enabled = - state?.trustedHash === expectedHash - ? state.enabled !== false - : trustedHashesByEvent.get(entry.eventLabel)?.get(expectedHash) - if (enabled === undefined) { + const state = resolveTrustedSystemHookState(entry, trustEntries, trustedHashesByEvent) + if (!state) { return } - const signature = getTrustSignature(entry) + const signature = getCodexHookTrustSignature(entry) // Why: runtime deduping collapses identical system hook definitions; // if any duplicate remains enabled, keep the mirrored hook enabled. - if (enabled || !signatures.has(signature)) { - signatures.set(signature, enabled) + if (state.enabled || !signatures.has(signature)) { + signatures.set(signature, state) } }) }) @@ -374,6 +349,31 @@ function getTrustedSystemUserHookSignatures( return signatures } +function resolveTrustedSystemHookState( + entry: CodexTrustEntry, + trustEntries: ReadonlyMap, + trustedHashesByEvent: ReadonlyMap> +): TrustedSystemHookSignatureState | null { + const expectedHash = computeTrustedHash(entry) + const state = trustEntries.get(computeTrustKey(entry)) + if (state?.trustedHash === expectedHash) { + return { enabled: state.enabled !== false, trustedHash: expectedHash } + } + const reorderedEnabled = trustedHashesByEvent.get(entry.eventLabel)?.get(expectedHash) + if (reorderedEnabled !== undefined) { + return { enabled: reorderedEnabled, trustedHash: expectedHash } + } + if (state?.trustedHash) { + // Why: carry a key-matched system hash verbatim instead of dropping it as + // stale. Codex is the authority on its own hash algorithm; recomputing + // here is what turned #7110-style hash drift into an endless re-approval + // loop. If the hash is genuinely stale (edited hook), Codex prompts — + // exactly what a plain ~/.codex session would do. + return { enabled: state.enabled !== false, trustedHash: state.trustedHash } + } + return null +} + function getTrustedSystemHookHashesByEvent( systemConfigPath: string, trustEntries: ReadonlyMap @@ -406,7 +406,7 @@ function getTrustedSystemHookHashesByEvent( function collectMirroredRuntimeUserHookTrustEntries( runtimeConfigPath: string, runtimeHooks: Record, - trustedSystemHookSignatures: ReadonlyMap, + trustedSystemHookSignatures: ReadonlyMap, isManagedCommand: (command: string | undefined) => boolean ): MirroredRuntimeUserHookTrustEntry[] { if (trustedSystemHookSignatures.size === 0) { @@ -424,7 +424,7 @@ function collectMirroredRuntimeUserHookTrustEntries( if (isManagedCommand(hook.command)) { return } - const entry = createHookTrustEntry( + const entry = createCodexHookTrustEntry( runtimeConfigPath, eventName, groupIndex, @@ -435,10 +435,13 @@ function collectMirroredRuntimeUserHookTrustEntries( if (!entry) { return } - const signature = getTrustSignature(entry) - const enabled = trustedSystemHookSignatures.get(signature) - if (enabled !== undefined) { - entries.push({ entry, enabled }) + const signature = getCodexHookTrustSignature(entry) + const state = trustedSystemHookSignatures.get(signature) + if (state !== undefined) { + entries.push({ + entry: { ...entry, trustedHash: state.trustedHash }, + enabled: state.enabled + }) } }) }) @@ -533,7 +536,7 @@ function cleanupLegacySystemManagedHooks(): void { return } - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) const nextHooks = { ...config.hooks } const trustEntries: CodexTrustEntry[] = [] let removedManagedHook = false @@ -674,6 +677,52 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { } } +function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstallPlan): void { + try { + const existingEntries = readHookTrustEntries(plan.tomlPath) + const command = wrapReadablePosixHookCommand(plan.commandScriptPath) + const managedEventLabels = new Set( + CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) + ) + const canonicalConfigPath = getCodexCanonicalTrustPath(plan.trustConfigPath) + const ourKeys: string[] = [] + for (const [key, state] of existingEntries) { + const parts = parseTrustKey(key) + if (parts === null) { + continue + } + if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) { + continue + } + if (!managedEventLabels.has(parts.eventLabel)) { + continue + } + const expectedEntry: CodexTrustEntry = { + sourcePath: plan.trustConfigPath, + eventLabel: parts.eventLabel, + groupIndex: parts.groupIndex, + handlerIndex: parts.handlerIndex, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + } + const recognizedHashes = new Set([ + computeTrustedHash(expectedEntry), + computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) + ]) + if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { + ourKeys.push(key) + } + } + if (ourKeys.length > 0) { + removeHookTrustEntries(plan.tomlPath, ourKeys) + } + } catch (error) { + // Why: removing disabled WSL status hooks should be best-effort like the + // host cleanup path; stale trust is inert once hooks.json no longer points at us. + console.warn('[codex-hook-service] failed to clean WSL trust entries', error) + } +} + function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ @@ -698,8 +747,30 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: see claude/hook-service.ts for rationale. Sourcing refreshes // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps // reporting after a restart. + 'load_hook_endpoint() {', + ' endpoint_path="$1"', + ' case "$endpoint_path" in', + ' *.cmd)', + // Why: Windows passes endpoint.cmd into WSL through WSLENV path translation. + // Parse only Orca's known assignments; cmd.exe `set` lines are not shell syntax. + ' endpoint_cr=$(printf "\\r")', + ' while IFS= read -r endpoint_line || [ -n "$endpoint_line" ]; do', + ' endpoint_line=${endpoint_line%"$endpoint_cr"}', + ' case "$endpoint_line" in', + ' "set ORCA_AGENT_HOOK_PORT="*) ORCA_AGENT_HOOK_PORT=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;', + ' esac', + ' done < "$endpoint_path"', + ' ;;', + ' *)', + ' . "$endpoint_path" 2>/dev/null || :', + ' ;;', + ' esac', + '}', 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + ' load_hook_endpoint "$ORCA_AGENT_HOOK_ENDPOINT"', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', @@ -708,6 +779,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$payload" ]; then', ' exit 0', 'fi', + 'post_codex_hook() {', + ' curl_bin="$1"', + ' connect_timeout="${2:-0.5}"', + ' max_time="${3:-1.5}"', // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. @@ -715,23 +790,164 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', + ' --connect-timeout "$connect_timeout" --max-time "$max_time" \\', + ' --noproxy "127.0.0.1" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload@-"', + '}', + 'is_wsl_runtime() {', + ' [ -n "$WSL_DISTRO_NAME" ] && return 0', + ' grep -qiE "microsoft|wsl" /proc/sys/kernel/osrelease /proc/version 2>/dev/null', + '}', + 'if post_codex_hook curl >/dev/null 2>&1; then', + ' exit 0', + 'fi', + 'if is_wsl_runtime; then', + ' windows_curl=$(command -v curl.exe 2>/dev/null || true)', + ' if [ -n "$windows_curl" ] && [ -x "$windows_curl" ]; then', + ' post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', + ' fi', + 'fi', 'exit 0', '' ].join('\n') } +function installManagedHooksIntoWslRuntime( + plan: CodexWslRuntimeHookInstallPlan +): AgentHookInstallStatus { + const config = readHooksJson(plan.configPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') + const command = wrapReadablePosixHookCommand(plan.commandScriptPath) + const nextHooks = { ...config.hooks } + const managedEvents = new Set(CODEX_EVENTS) + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + const trustEntries: CodexTrustEntry[] = [] + for (const eventName of CODEX_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + hooks: [buildManagedCommandHook(command)] + } + nextHooks[eventName] = [definition, ...cleaned] + trustEntries.push({ + sourcePath: plan.trustConfigPath, + eventLabel: CODEX_EVENT_LABEL[eventName], + groupIndex: 0, + handlerIndex: 0, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) + } + + config.hooks = nextHooks + writeManagedScript(plan.scriptPath, getManagedScript('posix')) + writeCodexHooksJson(plan.configPath, nextHooks) + try { + // Why: WSL runtime homes may carry user hook approvals we did not rebuild + // here; only upsert Orca's entries instead of sweeping the whole source. + upsertHookTrustEntries(plan.tomlPath, trustEntries) + } catch (error) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: true, + detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` + } + } + + return { + agent: 'codex', + state: 'installed', + configPath: plan.configPath, + managedHooksPresent: true, + detail: null + } +} + +function refreshWslRuntimeUserHooks(plan: CodexWslRuntimeHookInstallPlan): AgentHookInstallStatus { + const config = readHooksJson(plan.configPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') + const nextHooks = { ...config.hooks } + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + writeCodexHooksJson(plan.configPath, nextHooks) + removeWslRuntimeManagedHookTrustEntries(plan) + return { + agent: 'codex', + state: 'not_installed', + configPath: plan.configPath, + managedHooksPresent: false, + detail: null + } +} + export class CodexHookService { + installForRuntimeHome( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget + ): AgentHookInstallStatus | null { + const wslPlan = createCodexWslRuntimeHookInstallPlan(runtimeHomePath, target) + return wslPlan ? installManagedHooksIntoWslRuntime(wslPlan) : null + } + + refreshRuntimeUserHooksForRuntimeHome( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget + ): AgentHookInstallStatus | null { + const wslPlan = createCodexWslRuntimeHookInstallPlan(runtimeHomePath, target) + return wslPlan ? refreshWslRuntimeUserHooks(wslPlan) : null + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() @@ -853,6 +1069,11 @@ export class CodexHookService { install(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() + // Why: must run before this install rewrites hooks.json/config.toml — + // approvals the user made inside Orca-launched Codex are keyed to the + // previous launch's runtime layout, and stale-trust cleanup below would + // delete them once the system config stops backing them. + promoteCodexRuntimeHookApprovalsToSystem() const config = readHooksJson(configPath) if (!config) { return { @@ -868,7 +1089,7 @@ export class CodexHookService { // install sweeps stale entries left by older builds or a different // Electron userData path (dev vs. prod). Without this, repeated installs // accumulate duplicate hook entries pointing at defunct scripts. - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) const command = getManagedCommand(scriptPath) const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) const nextHooks = hookPlan.hooks @@ -953,6 +1174,7 @@ export class CodexHookService { detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` } } + snapshotCodexRuntimeHookTrustProvenance() try { cleanupLegacySystemManagedHooks() cleanupLegacyCodexProfileHooks() @@ -1060,6 +1282,9 @@ export class CodexHookService { refreshRuntimeUserHooks(): AgentHookInstallStatus { const configPath = getConfigPath() + // Why: same as install() — capture in-Orca approvals before this refresh + // rewrites the runtime files they are keyed against. + promoteCodexRuntimeHookApprovalsToSystem() const config = readHooksJson(configPath) if (!config) { // Why: disabled launch prep used to call remove(); preserve its legacy @@ -1074,7 +1299,7 @@ export class CodexHookService { } } - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand) config.hooks = hookPlan.hooks writeCodexHooksJson(configPath, hookPlan.hooks) @@ -1099,6 +1324,7 @@ export class CodexHookService { detail: `User hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` } } + snapshotCodexRuntimeHookTrustProvenance() cleanupLegacyManagedHookRepresentations() return this.getStatus() @@ -1124,7 +1350,7 @@ export class CodexHookService { const nextHooks = { ...config.hooks } // Why: same broad matcher as install(), so remove() also cleans up stale // entries from older builds even if the current scriptPath has moved. - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) for (const [eventName, definitions] of Object.entries(nextHooks)) { if (!Array.isArray(definitions)) { // Why: a malformed hooks.json entry (non-array value for an event name) @@ -1157,3 +1383,9 @@ export class CodexHookService { } export const codexHookService = new CodexHookService() + +export const _internals = { + getManagedScript, + installManagedHooksIntoWslRuntime, + refreshWslRuntimeUserHooks +} diff --git a/src/main/codex/hook-trust-promotion.test.ts b/src/main/codex/hook-trust-promotion.test.ts new file mode 100644 index 00000000000..b497591db63 --- /dev/null +++ b/src/main/codex/hook-trust-promotion.test.ts @@ -0,0 +1,399 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { + computeTrustKey, + computeTrustedHash, + readHookTrustEntries, + upsertHookTrustEntriesInContent, + type CodexTrustEntry +} from './config-toml-trust' + +const { getPathMock, homedirMock } = vi.hoisted(() => ({ + getPathMock: vi.fn<(name: string) => string>(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +import { CodexHookService } from './hook-service' + +let tmpHome: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(tmpHome) + getPathMock.mockImplementation((name: string) => { + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app.getPath(${name})`) + }) +}) + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +const USER_HOOK_COMMAND = 'echo my-personal-hook' + +function systemCodexDir(): string { + return join(tmpHome, '.codex') +} + +function runtimeHomeDir(): string { + return join(userDataDir, 'codex-runtime-home', 'home') +} + +function writeSystemUserHook(commands: string[] = [USER_HOOK_COMMAND]): void { + mkdirSync(systemCodexDir(), { recursive: true }) + writeFileSync( + join(systemCodexDir(), 'hooks.json'), + JSON.stringify({ + hooks: { + Stop: commands.map((command) => ({ hooks: [{ type: 'command', command }] })) + } + }) + ) +} + +// Simulates the Codex TUI persisting a /hooks approval: Codex writes a trust +// entry keyed to the file that declares the hook, using its own hash. The +// production hash algorithm is reproduced by computeTrustedHash. +function simulateCodexApproval(entry: CodexTrustEntry, options?: { hash?: string }): void { + const tomlPath = join(runtimeHomeDir(), 'config.toml') + const existing = existsSync(tomlPath) ? readFileSync(tomlPath, 'utf-8') : '' + const approvalEntry = options?.hash ? { ...entry, trustedHash: options.hash } : entry + writeFileSync(tomlPath, upsertHookTrustEntriesInContent(existing, [approvalEntry])) +} + +function runtimeUserStopEntry(): CodexTrustEntry { + // install() prepends the managed status hook on Stop, so the mirrored user + // hook lands at groupIndex 1. + return { + sourcePath: join(runtimeHomeDir(), 'hooks.json'), + eventLabel: 'stop', + groupIndex: 1, + handlerIndex: 0, + command: USER_HOOK_COMMAND + } +} + +function systemUserStopEntry(groupIndex = 0): CodexTrustEntry { + return { + sourcePath: join(systemCodexDir(), 'hooks.json'), + eventLabel: 'stop', + groupIndex, + handlerIndex: 0, + command: USER_HOOK_COMMAND + } +} + +function readSystemToml(): string { + const tomlPath = join(systemCodexDir(), 'config.toml') + return existsSync(tomlPath) ? readFileSync(tomlPath, 'utf-8') : '' +} + +describe('codex hook trust write-back promotion', () => { + it('keeps an in-Orca approval of a user hook across launches and promotes it to ~/.codex', () => { + writeSystemUserHook() + const service = new CodexHookService() + service.install() + + // The mirrored user hook has no system trust yet, so no runtime trust + // entry exists for it — Codex would show it as pending review. + const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml') + const approvalKey = computeTrustKey(runtimeUserStopEntry()) + expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)).toBeUndefined() + + simulateCodexApproval(runtimeUserStopEntry()) + const approvedHash = computeTrustedHash(runtimeUserStopEntry()) + + service.install() + + // Approval survives the relaunch instead of being wiped as stale… + expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.trustedHash).toBe(approvedHash) + // …and is promoted into the user's real config keyed to their hooks.json. + const systemState = readHookTrustEntries(join(systemCodexDir(), 'config.toml')).get( + computeTrustKey(systemUserStopEntry()) + ) + expect(systemState?.trustedHash).toBe(approvedHash) + expect(systemState?.enabled).not.toBe(false) + + // Steady state: another launch with no external changes rewrites nothing. + const systemTomlAfterPromotion = readSystemToml() + const runtimeTomlAfterPromotion = readFileSync(runtimeTomlPath, 'utf-8') + service.install() + expect(readSystemToml()).toBe(systemTomlAfterPromotion) + expect(readFileSync(runtimeTomlPath, 'utf-8')).toBe(runtimeTomlAfterPromotion) + }) + + it('never promotes trust for the Orca-managed status hook into ~/.codex', () => { + writeSystemUserHook() + const service = new CodexHookService() + service.install() + + // Simulate Codex rewriting the managed Stop hook's trust entry (as an + // approval after hash drift would). + const runtimeHooksPath = join(runtimeHomeDir(), 'hooks.json') + const managedCommand = ( + JSON.parse(readFileSync(runtimeHooksPath, 'utf-8')) as { + hooks: { Stop: { hooks: { command: string }[] }[] } + } + ).hooks.Stop[0]!.hooks[0]!.command + simulateCodexApproval( + { + sourcePath: runtimeHooksPath, + eventLabel: 'stop', + groupIndex: 0, + handlerIndex: 0, + command: managedCommand + }, + { hash: 'sha256:codex-corrected-managed-hash' } + ) + + service.install() + + expect(readSystemToml()).not.toContain(managedCommand) + expect(readSystemToml()).not.toContain('codex-corrected-managed-hash') + }) + + it('does not resurrect trust the user revoked in ~/.codex/config.toml', () => { + writeSystemUserHook() + // Pre-trust the hook in the system config, as a terminal Codex session would. + const systemTomlPath = join(systemCodexDir(), 'config.toml') + writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()])) + + const service = new CodexHookService() + service.install() + + const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml') + const approvalKey = computeTrustKey(runtimeUserStopEntry()) + expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)).toBeDefined() + + // User revokes in the system config; the runtime copy must not win. + writeFileSync(systemTomlPath, '') + service.install() + + expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)).toBeUndefined() + expect(readSystemToml()).not.toContain('[hooks.state.') + }) + + it('promotes an in-Orca disable of a mirrored user hook back to the system config', () => { + writeSystemUserHook() + const systemTomlPath = join(systemCodexDir(), 'config.toml') + writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()])) + + const service = new CodexHookService() + service.install() + + // User disables the hook via /hooks inside Orca-launched Codex. + const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml') + const runtimeToml = readFileSync(runtimeTomlPath, 'utf-8') + const approvalKey = computeTrustKey(runtimeUserStopEntry()) + expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.enabled).not.toBe(false) + writeFileSync( + runtimeTomlPath, + upsertHookTrustEntriesInContent(runtimeToml, [{ ...runtimeUserStopEntry(), enabled: false }]) + ) + + service.install() + + const systemState = readHookTrustEntries(systemTomlPath).get( + computeTrustKey(systemUserStopEntry()) + ) + expect(systemState?.enabled).toBe(false) + // The mirrored runtime entry reflects the disable on the next launch too. + expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.enabled).toBe(false) + }) + + it('carries a Codex-written hash verbatim when it differs from the reproduced hash', () => { + // Simulates Codex changing its trust hash algorithm: the approval hash in + // the runtime config no longer matches computeTrustedHash's output. + writeSystemUserHook() + const service = new CodexHookService() + service.install() + + const driftedHash = 'sha256:codex-next-gen-hash-orca-cannot-reproduce' + simulateCodexApproval(runtimeUserStopEntry(), { hash: driftedHash }) + + service.install() + + const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml') + const approvalKey = computeTrustKey(runtimeUserStopEntry()) + expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.trustedHash).toBe(driftedHash) + expect( + readHookTrustEntries(join(systemCodexDir(), 'config.toml')).get( + computeTrustKey(systemUserStopEntry()) + )?.trustedHash + ).toBe(driftedHash) + + // And the launch after that still keeps it. + service.install() + expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.trustedHash).toBe(driftedHash) + }) + + it('does not touch ~/.codex on the first launch after upgrading (no provenance yet)', () => { + // Simulates an existing install: runtime home fully materialized by a + // build without provenance snapshots, managed hooks only. + const service = new CodexHookService() + service.install() + rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true }) + + service.install() + + expect(readSystemToml()).toBe('') + expect(existsSync(join(systemCodexDir(), 'config.toml'))).toBe(false) + }) + + it('re-promoting mirrored trust without provenance is a no-op on ~/.codex', () => { + // Existing install with a system-trusted user hook, upgraded to this + // build: the mirrored runtime entry has no provenance, so promotion must + // sit out this launch and leave the system config byte-identical. + writeSystemUserHook() + const systemTomlPath = join(systemCodexDir(), 'config.toml') + writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()])) + const service = new CodexHookService() + service.install() + rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true }) + const systemTomlBefore = readSystemToml() + + service.install() + + expect(readSystemToml()).toBe(systemTomlBefore) + }) + + it('does not resurrect trust revoked in ~/.codex before the first provenance snapshot', () => { + // Old build mirrored a system-trusted hook into the runtime home; the + // user then revoked it in ~/.codex/config.toml and upgraded to this + // build. The stale runtime mirror must not be mistaken for an approval. + writeSystemUserHook() + const systemTomlPath = join(systemCodexDir(), 'config.toml') + writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()])) + const service = new CodexHookService() + service.install() + rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true }) + writeFileSync(systemTomlPath, '') + + service.install() + + expect(readSystemToml()).not.toContain('[hooks.state.') + expect( + readHookTrustEntries(join(runtimeHomeDir(), 'config.toml')).get( + computeTrustKey(runtimeUserStopEntry()) + ) + ).toBeUndefined() + }) + + it('does not flip a hook the user disabled in ~/.codex back to enabled after upgrading', () => { + // Old build mirrored the hook enabled=true; the user then set + // enabled = false in ~/.codex/config.toml and upgraded to this build. + writeSystemUserHook() + const systemTomlPath = join(systemCodexDir(), 'config.toml') + writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()])) + const service = new CodexHookService() + service.install() + rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true }) + writeFileSync( + systemTomlPath, + upsertHookTrustEntriesInContent('', [{ ...systemUserStopEntry(), enabled: false }]) + ) + + service.install() + + expect( + readHookTrustEntries(systemTomlPath).get(computeTrustKey(systemUserStopEntry()))?.enabled + ).toBe(false) + expect( + readHookTrustEntries(join(runtimeHomeDir(), 'config.toml')).get( + computeTrustKey(runtimeUserStopEntry()) + )?.enabled + ).toBe(false) + }) + + it('promotes one approval to every identical system hook collapsed by deduping', () => { + writeSystemUserHook([USER_HOOK_COMMAND, USER_HOOK_COMMAND]) + const service = new CodexHookService() + service.install() + + simulateCodexApproval(runtimeUserStopEntry()) + service.install() + + const systemTrust = readHookTrustEntries(join(systemCodexDir(), 'config.toml')) + const approvedHash = computeTrustedHash(runtimeUserStopEntry()) + expect(systemTrust.get(computeTrustKey(systemUserStopEntry(0)))?.trustedHash).toBe(approvedHash) + expect(systemTrust.get(computeTrustKey(systemUserStopEntry(1)))?.trustedHash).toBe(approvedHash) + }) + + it('skips promotion when the approved hook no longer exists in ~/.codex/hooks.json', () => { + writeSystemUserHook() + const service = new CodexHookService() + service.install() + + simulateCodexApproval(runtimeUserStopEntry()) + // User deletes the hook from their system hooks.json before relaunching. + writeFileSync(join(systemCodexDir(), 'hooks.json'), JSON.stringify({ hooks: {} })) + + service.install() + + expect(readSystemToml()).not.toContain('[hooks.state.') + // The runtime copy of the deleted hook (and its approval) is cleaned up. + const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml') + expect( + readHookTrustEntries(runtimeTomlPath).get(computeTrustKey(runtimeUserStopEntry())) + ).toBeUndefined() + }) + + it('promotes approvals recorded while status hooks are disabled (refresh path)', () => { + writeSystemUserHook() + const service = new CodexHookService() + service.refreshRuntimeUserHooks() + + // Without the managed status hook, the mirrored user hook sits at group 0. + const refreshedRuntimeEntry: CodexTrustEntry = { + ...runtimeUserStopEntry(), + groupIndex: 0 + } + simulateCodexApproval(refreshedRuntimeEntry) + const approvedHash = computeTrustedHash(refreshedRuntimeEntry) + + service.refreshRuntimeUserHooks() + + expect( + readHookTrustEntries(join(systemCodexDir(), 'config.toml')).get( + computeTrustKey(systemUserStopEntry()) + )?.trustedHash + ).toBe(approvedHash) + expect( + readHookTrustEntries(join(runtimeHomeDir(), 'config.toml')).get( + computeTrustKey(refreshedRuntimeEntry) + )?.trustedHash + ).toBe(approvedHash) + }) +}) diff --git a/src/main/codex/hook-trust-promotion.ts b/src/main/codex/hook-trust-promotion.ts new file mode 100644 index 00000000000..36a570a662f --- /dev/null +++ b/src/main/codex/hook-trust-promotion.ts @@ -0,0 +1,257 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { + createManagedCommandMatcher, + readHooksJson, + type HookDefinition +} from '../agent-hooks/installer-utils' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { + getCodexCanonicalTrustPath, + normalizeHookTrustKeyForLookup, + parseTrustKey, + readHookTrustEntries, + upsertHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { + CODEX_EVENT_NAME_BY_LABEL, + createCodexHookTrustEntry, + getCodexHookTrustSignature, + getCodexManagedScriptFileName +} from './codex-hook-identity' + +// Why: ~/.codex/config.toml is the single source of truth for user-hook +// trust, but the Codex TUI can only write approvals into the runtime +// CODEX_HOME it was launched with. Without promoting those approvals back, +// removeStaleRuntimeHookTrustEntries deletes them on the next launch and the +// user re-approves the same hooks forever. + +type HookTrustProvenanceEntry = { + trustedHash?: string + enabled?: boolean +} + +type HookTrustProvenanceFile = { + version: 1 + entries: Record +} + +function getProvenancePath(runtimeHomePath: string): string { + return join(runtimeHomePath, '.orca-hook-trust-provenance.json') +} + +function readHookTrustProvenance( + runtimeHomePath: string +): Map | null { + const provenancePath = getProvenancePath(runtimeHomePath) + if (!existsSync(provenancePath)) { + return null + } + try { + const parsed: unknown = JSON.parse(readFileSync(provenancePath, 'utf-8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const entries = (parsed as HookTrustProvenanceFile).entries + if (!entries || typeof entries !== 'object' || Array.isArray(entries)) { + return null + } + const result = new Map() + for (const [key, value] of Object.entries(entries)) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + result.set(key, { + ...(typeof value.trustedHash === 'string' ? { trustedHash: value.trustedHash } : {}), + ...(typeof value.enabled === 'boolean' ? { enabled: value.enabled } : {}) + }) + } + } + return result + } catch { + return null + } +} + +/** + * Records the runtime config.toml trust state Orca leaves behind after an + * install/refresh, so the next launch can tell "entry Orca wrote" apart from + * "entry Codex wrote after a user approval". Call after all trust writes. + */ +export function snapshotCodexRuntimeHookTrustProvenance(): void { + try { + const runtimeHomePath = getOrcaManagedCodexHomePath() + const runtimeHooksPath = join(runtimeHomePath, 'hooks.json') + const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath) + const entries: Record = {} + for (const [key, state] of readHookTrustEntries(join(runtimeHomePath, 'config.toml'))) { + const parsed = parseTrustKey(key) + if (!parsed || getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalRuntimeHooksPath) { + continue + } + entries[normalizeHookTrustKeyForLookup(key)] = { + ...(state.trustedHash !== undefined ? { trustedHash: state.trustedHash } : {}), + ...(state.enabled !== undefined ? { enabled: state.enabled } : {}) + } + } + const file: HookTrustProvenanceFile = { version: 1, entries } + writeFileSync(getProvenancePath(runtimeHomePath), `${JSON.stringify(file, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) + } catch (error) { + // Best effort — a missing snapshot only means the next launch re-promotes + // entries that already match the system config, which upsert no-ops. + console.warn('[codex-hook-promotion] failed to snapshot hook trust provenance', error) + } +} + +/** + * Promotes hook approvals the user made inside Orca-launched Codex (written + * by Codex into the runtime config.toml) into ~/.codex/config.toml, keyed to + * the user's own hooks.json. Runs before the config mirror so the promoted + * trust is mirrored back on the same launch. + */ +export function promoteCodexRuntimeHookApprovalsToSystem(): void { + try { + promoteCodexRuntimeHookApprovalsToSystemUnsafe() + } catch (error) { + // Why: promotion is best-effort launch prep; a malformed runtime file + // must not block hook install or the Codex launch itself. + console.warn('[codex-hook-promotion] failed to promote runtime hook approvals', error) + } +} + +function promoteCodexRuntimeHookApprovalsToSystemUnsafe(): void { + const runtimeHomePath = getOrcaManagedCodexHomePath() + const systemHomePath = getSystemCodexHomePath() + const runtimeHooksPath = join(runtimeHomePath, 'hooks.json') + const systemHooksPath = join(systemHomePath, 'hooks.json') + const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath) + if (canonicalRuntimeHooksPath === getCodexCanonicalTrustPath(systemHooksPath)) { + return + } + const runtimeTomlPath = join(runtimeHomePath, 'config.toml') + if (!existsSync(runtimeTomlPath)) { + return + } + // Why: without a snapshot of what Orca last wrote (first launch after + // upgrading to a build with promotion, or a corrupted snapshot), a mirrored + // copy of since-revoked system trust is indistinguishable from a genuine + // in-Orca approval. Promoting would resurrect trust the user revoked in + // ~/.codex, so skip this launch — install() writes the first snapshot and + // promotion starts on the next one. + const provenance = readHookTrustProvenance(runtimeHomePath) + if (!provenance) { + return + } + const runtimeTrust = readHookTrustEntries(runtimeTomlPath) + if (runtimeTrust.size === 0) { + return + } + // Why: promotion inspects the hooks.json layout Codex actually approved + // against — the one still on disk from the previous launch — so it must run + // before install() rewrites the runtime hooks.json. + const runtimeConfig = readHooksJson(runtimeHooksPath) + const systemConfig = readHooksJson(systemHooksPath) + if (!runtimeConfig?.hooks || !systemConfig?.hooks) { + return + } + const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) + + const promotions: CodexTrustEntry[] = [] + for (const [key, state] of runtimeTrust) { + if (!state.trustedHash) { + continue + } + const parsed = parseTrustKey(key) + if (!parsed || getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalRuntimeHooksPath) { + continue + } + const previous = provenance.get(normalizeHookTrustKeyForLookup(key)) + if ( + previous && + previous.trustedHash === state.trustedHash && + (previous.enabled ?? true) === (state.enabled ?? true) + ) { + // Orca wrote this entry and nothing touched it since — not an approval. + continue + } + const eventName = CODEX_EVENT_NAME_BY_LABEL[parsed.eventLabel] + const runtimeDefinitions = runtimeConfig.hooks[eventName] + const definition = Array.isArray(runtimeDefinitions) + ? runtimeDefinitions[parsed.groupIndex] + : undefined + const hook = Array.isArray(definition?.hooks) + ? definition.hooks[parsed.handlerIndex] + : undefined + // Why: never write trust for Orca's managed status hook into the user's + // real config — mutating ~/.codex for Orca's own hooks is exactly what the + // runtime CODEX_HOME isolation exists to prevent. + if (!definition || !hook?.command || isManagedCommand(hook.command)) { + continue + } + const runtimeEntry = createCodexHookTrustEntry( + runtimeHooksPath, + eventName, + parsed.groupIndex, + parsed.handlerIndex, + definition, + hook + ) + if (!runtimeEntry) { + continue + } + collectSystemPromotionTargets( + promotions, + systemHooksPath, + systemConfig.hooks, + eventName, + getCodexHookTrustSignature(runtimeEntry), + isManagedCommand, + state.trustedHash, + state.enabled ?? true + ) + } + if (promotions.length > 0) { + upsertHookTrustEntries(join(systemHomePath, 'config.toml'), promotions) + } +} + +// Why: the runtime layout differs from the system one (managed hook +// prepended, duplicates collapsed), so approvals map back by hook content — +// and one runtime hook can map to several identical system entries. +function collectSystemPromotionTargets( + promotions: CodexTrustEntry[], + systemHooksPath: string, + systemHooks: Record, + eventName: string, + signature: string, + isManagedCommand: (command: string | undefined) => boolean, + trustedHash: string, + enabled: boolean +): void { + const systemDefinitions = systemHooks[eventName] + if (!Array.isArray(systemDefinitions)) { + return + } + systemDefinitions.forEach((systemDefinition, groupIndex) => { + const hooks = Array.isArray(systemDefinition.hooks) ? systemDefinition.hooks : [] + hooks.forEach((systemHook, handlerIndex) => { + if (isManagedCommand(systemHook.command)) { + return + } + const systemEntry = createCodexHookTrustEntry( + systemHooksPath, + eventName, + groupIndex, + handlerIndex, + systemDefinition, + systemHook + ) + if (!systemEntry || getCodexHookTrustSignature(systemEntry) !== signature) { + return + } + promotions.push({ ...systemEntry, trustedHash, enabled }) + }) + }) +} diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts index f2ac7a7ade5..e24f1c8b3fe 100644 --- a/src/main/daemon/daemon-entry.test.ts +++ b/src/main/daemon/daemon-entry.test.ts @@ -29,4 +29,29 @@ describe('daemon-entry parseArgs', () => { it('throws with no args', () => { expect(() => parseArgs([])).toThrow('Usage:') }) + + it('omits logFilePath when --log-file is absent (adopted old daemons)', () => { + const result = parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token']) + expect(result).not.toHaveProperty('logFilePath') + }) + + it('parses --log-file when present', () => { + const result = parseArgs([ + '--socket', + '/tmp/t.sock', + '--token', + '/tmp/t.token', + '--log-file', + '/tmp/daemon.log' + ]) + expect(result).toEqual({ + socketPath: '/tmp/t.sock', + tokenPath: '/tmp/t.token', + logFilePath: '/tmp/daemon.log' + }) + }) + + it('still requires --socket and --token when --log-file is given', () => { + expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:') + }) }) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index aeffb7aa2a5..9dfc97532fa 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -10,10 +10,20 @@ import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' import { warmWindowsConptyOnce } from './windows-conpty-warmup' import { warmPwshAvailabilityCache } from '../pwsh' +import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log' +import { PROTOCOL_VERSION } from './types' -export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } { +export type ParsedDaemonArgs = { + socketPath: string + tokenPath: string + /** Optional — absent for adopted old daemons and tests, which log nothing. */ + logFilePath?: string +} + +export function parseArgs(argv: string[]): ParsedDaemonArgs { let socketPath = '' let tokenPath = '' + let logFilePath = '' for (let i = 0; i < argv.length; i++) { if (argv[i] === '--socket' && argv[i + 1]) { @@ -22,18 +32,31 @@ export function parseArgs(argv: string[]): { socketPath: string; tokenPath: stri } else if (argv[i] === '--token' && argv[i + 1]) { tokenPath = argv[i + 1] i++ + } else if (argv[i] === '--log-file' && argv[i + 1]) { + logFilePath = argv[i + 1] + i++ } } if (!socketPath || !tokenPath) { - throw new Error('Usage: daemon-entry --socket --token ') + throw new Error('Usage: daemon-entry --socket --token [--log-file ]') } - return { socketPath, tokenPath } + return logFilePath ? { socketPath, tokenPath, logFilePath } : { socketPath, tokenPath } } async function main(): Promise { - const { socketPath, tokenPath } = parseArgs(process.argv.slice(2)) + // Why: the parent captures daemon startup stderr then destroys its end of the + // pipe once the daemon is ready. A later write here (e.g. the uncaughtException + // console.error below) would then hit a broken pipe and emit 'error' on + // process.stderr — with no listener that becomes an unhandled error that kills + // an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only. + process.stderr.on('error', () => {}) + + const { socketPath, tokenPath, logFilePath } = parseArgs(process.argv.slice(2)) + // Fail-open: a broken log path must never block daemon startup. + const daemonLog = logFilePath ? createDaemonFileLog(logFilePath) : createNoopDaemonFileLog() + daemonLog.log('startup', { protocolVersion: PROTOCOL_VERSION, socketPath }) void warmPwshAvailabilityCache() // Why: node-pty can throw a C++ Napi::Error that escapes all JS try/catch @@ -55,29 +78,54 @@ async function main(): Promise { msg.includes('EBADF') || msg.includes('ENXIO')) if (isNativeError) { + daemonLog.log('uncaught-exception-suppressed', { name: err?.name, message: msg }) console.error('[daemon] Native PTY exception (suppressed):', err) return } + daemonLog.log('uncaught-exception-fatal', { name: err?.name, message: msg }) console.error('[daemon] Uncaught exception (fatal):', err) throw err }) let daemon: DaemonHandle | null = null + let shuttingDown = false + // Bound the wait so a wedged native shutdown can't leave the daemon running + // forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates). + const SHUTDOWN_TIMEOUT_MS = 5000 - const shutdown = async (): Promise => { - if (daemon) { - await daemon.shutdown() - daemon = null + const shutdown = async (reason: string): Promise => { + // SIGTERM and SIGINT can both fire; guard against a double daemon.shutdown(). + if (shuttingDown) { + return + } + shuttingDown = true + daemonLog.log('shutdown', { reason }) + try { + if (daemon) { + await Promise.race([ + daemon.shutdown(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('shutdown timeout')), SHUTDOWN_TIMEOUT_MS) + ) + ]) + daemon = null + } + } catch (err) { + // Never let a rejected shutdown() escape as an unhandled rejection and skip exit. + daemonLog.log('shutdown-error', { message: (err as Error)?.message }) + } finally { + daemonLog.close() + process.exit(0) } - process.exit(0) } - process.on('SIGTERM', () => void shutdown()) - process.on('SIGINT', () => void shutdown()) + process.on('SIGTERM', () => void shutdown('SIGTERM')) + process.on('SIGINT', () => void shutdown('SIGINT')) daemon = await startDaemon({ socketPath, tokenPath, + log: daemonLog, spawnSubprocess: (opts) => createPtySubprocess(opts) }) @@ -85,6 +133,7 @@ async function main(): Promise { if (process.send) { process.send({ type: 'ready' }) } + daemonLog.log('ready') warmWindowsConptyOnce() } diff --git a/src/main/daemon/daemon-file-log.test.ts b/src/main/daemon/daemon-file-log.test.ts new file mode 100644 index 00000000000..90677b9dab8 --- /dev/null +++ b/src/main/daemon/daemon-file-log.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-file-log-')) +}) +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function readLines(filePath: string): Record[] { + return readFileSync(filePath, 'utf8') + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as Record) +} + +describe('createDaemonFileLog', () => { + it('appends NDJSON lines with src/ts/pid/event and terse details', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath) + log.log('startup', { protocolVersion: 18 }) + log.log('session-created', { sessionId: 'abc', pid: 42 }) + + const lines = readLines(filePath) + expect(lines).toHaveLength(2) + expect(lines[0]).toMatchObject({ src: 'daemon', event: 'startup', protocolVersion: 18 }) + expect(typeof lines[0].ts).toBe('string') + expect(lines[0].pid).toBe(process.pid) + expect(lines[1]).toMatchObject({ event: 'session-created', sessionId: 'abc', pid: 42 }) + }) + + it('rotates at the byte cap and keeps only the configured rotated files', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath, { maxBytes: 150, maxRotatedFiles: 2 }) + for (let i = 0; i < 40; i++) { + log.log('tick', { i }) + } + + expect(existsSync(filePath)).toBe(true) + expect(existsSync(`${filePath}.1`)).toBe(true) + expect(existsSync(`${filePath}.2`)).toBe(true) + // Only 2 rotated files are retained — the oldest is dropped, not kept. + expect(existsSync(`${filePath}.3`)).toBe(false) + + // The active file holds the most recent line. + const active = readLines(filePath) + expect(active.at(-1)).toMatchObject({ event: 'tick', i: 39 }) + }) + + it('is fail-open when the log directory cannot be created', () => { + // Make the parent a file so mkdir of the logs subdir fails (ENOTDIR). + const blocker = join(dir, 'blocker') + writeFileSync(blocker, 'x') + const filePath = join(blocker, 'logs', 'daemon.log') + + const log = createDaemonFileLog(filePath) + expect(() => log.log('startup')).not.toThrow() + expect(() => log.close()).not.toThrow() + expect(existsSync(filePath)).toBe(false) + }) + + it('never throws from log() even for non-serializable details', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath) + const circular: Record = {} + circular.self = circular + expect(() => log.log('weird', circular)).not.toThrow() + // The bad line is dropped; a later good line still lands. + log.log('ok') + const lines = readLines(filePath) + expect(lines.map((l) => l.event)).toEqual(['ok']) + }) + + it('close() writes a terminal marker and stops further writes', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath) + log.log('startup') + log.close() + log.log('after-close') + + const events = readLines(filePath).map((l) => l.event) + expect(events).toEqual(['startup', 'daemon-log-closed']) + }) +}) + +describe('createNoopDaemonFileLog', () => { + it('accepts log/close calls without touching the filesystem', () => { + const log = createNoopDaemonFileLog() + expect(() => { + log.log('startup', { x: 1 }) + log.close() + }).not.toThrow() + }) +}) diff --git a/src/main/daemon/daemon-file-log.ts b/src/main/daemon/daemon-file-log.ts new file mode 100644 index 00000000000..01090aedf9e --- /dev/null +++ b/src/main/daemon/daemon-file-log.ts @@ -0,0 +1,137 @@ +// Append-only NDJSON logger for the detached daemon process. The daemon runs +// out-of-process with stdio 'ignore', so console output goes nowhere; this +// writes lifecycle events to a rotated file under the app's logs directory so +// they land in diagnostic bundles (windows-terminal-update-survival-plan.md +// §Phase 0). Never log terminal input/output content or tokens. +// +// Two hard constraints: +// 1. FAIL-OPEN. Any error (EACCES, ENOSPC, bad path) disables logging and is +// swallowed — logging must never throw into daemon lifecycle logic or +// affect startup/shutdown. +// 2. Best-effort durability. Each line is a single synchronous appendFileSync +// so a process death mid-write can lose at most the last (partial) line; +// NDJSON readers skip a truncated trailing line. + +import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs' +import { dirname } from 'node:path' + +const DEFAULT_MAX_BYTES = 5 * 1024 * 1024 // 5 MB +const DEFAULT_MAX_ROTATED_FILES = 2 // daemon.log + daemon.log.1 + daemon.log.2 +const PRIVATE_FILE_MODE = 0o600 + +/** Total files in the rotated daemon-log family (active + rotated). The bundle + * collector passes this to `listRotatedFiles` so it reads every rotated file. */ +export const DAEMON_LOG_MAX_FILES = DEFAULT_MAX_ROTATED_FILES + 1 + +export type DaemonFileLog = { + /** Append one lifecycle event. Terse fields only — never user data. */ + log(event: string, details?: Record): void + /** Best-effort marker that no further writes are expected. */ + close(): void +} + +export type DaemonFileLogOptions = { + readonly maxBytes?: number + readonly maxRotatedFiles?: number +} + +/** No-op logger used when the daemon was launched without `--log-file` (adopted + * old daemons, tests). Keeps every call site unconditional. */ +export function createNoopDaemonFileLog(): DaemonFileLog { + return { + log() {}, + close() {} + } +} + +export function createDaemonFileLog( + filePath: string, + opts: DaemonFileLogOptions = {} +): DaemonFileLog { + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES + const maxRotatedFiles = opts.maxRotatedFiles ?? DEFAULT_MAX_ROTATED_FILES + + let disabled = false + let currentBytes = 0 + + function disable(): void { + disabled = true + } + + try { + mkdirSync(dirname(filePath), { recursive: true }) + currentBytes = existsSync(filePath) ? statSync(filePath).size : 0 + } catch { + // Unwritable path — stay fail-open; the first log() no-ops via `disabled`. + disable() + } + + // Cascade rename base → .1 → .2, dropping the oldest, then reset the active + // file. Any failure disables logging rather than risking a partial-rotation + // loop that keeps throwing on every subsequent line. + function rotate(): void { + // With no rotated slots there is nothing to cascade; return without the + // `currentBytes = 0` reset below, which would otherwise falsely report the + // still-growing active file as empty and defeat the overflow check forever. + if (maxRotatedFiles < 1) { + return + } + try { + for (let i = maxRotatedFiles; i >= 1; i--) { + const src = i === 1 ? filePath : `${filePath}.${i - 1}` + const dst = `${filePath}.${i}` + if (!existsSync(src)) { + continue + } + if (existsSync(dst)) { + unlinkSync(dst) + } + renameSync(src, dst) + } + currentBytes = 0 + } catch { + disable() + } + } + + function log(event: string, details: Record = {}): void { + if (disabled) { + return + } + let line: string + try { + line = `${JSON.stringify({ + src: 'daemon', + ts: new Date().toISOString(), + pid: process.pid, + event, + ...details + })}\n` + } catch { + // Non-serializable detail (circular ref) — drop the line, never crash. + return + } + const lineBytes = Buffer.byteLength(line, 'utf8') + if (currentBytes > 0 && currentBytes + lineBytes > maxBytes) { + rotate() + if (disabled) { + return + } + } + try { + appendFileSync(filePath, line, { mode: PRIVATE_FILE_MODE }) + currentBytes += lineBytes + } catch { + disable() + } + } + + return { + log, + close(): void { + // Best-effort marker; append is synchronous so there is nothing to flush. + log('daemon-log-closed') + disabled = true + } + } +} diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts new file mode 100644 index 00000000000..1d3ab98db90 --- /dev/null +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -0,0 +1,270 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import os from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mutable Electron app stub, hoisted so the vi.mock factory closes over it. +const { electronApp } = vi.hoisted(() => ({ + electronApp: { + isPackaged: true, + userDataPath: '', + version: '9.9.9', + getPath: (): string => electronApp.userDataPath, + getVersion: (): string => electronApp.version + } +})) + +vi.mock('electron', () => ({ app: electronApp })) + +import { + buildDaemonHostManifest, + collectPinnedDaemonVersions, + getRelocatedDaemonHost, + materializeRelocatedDaemonHost, + pruneOldDaemonHosts +} from './daemon-host-relocation' + +let tempDir: string +let installDir: string +let userDataDir: string +let localAppDataDir: string +const originalPlatform = process.platform +const originalExecPath = process.execPath +const originalResourcesPath = process.resourcesPath +const originalLocalAppData = process.env.LOCALAPPDATA + +function setProcessProp(key: string, value: unknown): void { + Object.defineProperty(process, key, { value, configurable: true, writable: true }) +} + +// Build a win-unpacked fixture: exe + blobs + DLLs at root, daemon bundle and +// node-pty under resources, mirroring the packaged layout the copy expects. +function buildInstallFixture(root: string): void { + mkdirSync(root, { recursive: true }) + writeFileSync(join(root, 'Orca.exe'), 'exe-bytes') + for (const name of ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']) { + writeFileSync(join(root, name), name) + } + writeFileSync(join(root, 'ffmpeg.dll'), 'dll') + writeFileSync(join(root, 'libEGL.dll'), 'dll') + const mainDir = join(root, 'resources', 'app.asar.unpacked', 'out', 'main') + mkdirSync(join(mainDir, 'chunks'), { recursive: true }) + writeFileSync(join(mainDir, 'daemon-entry.js'), 'entry') + writeFileSync(join(mainDir, 'chunks', 'a.js'), 'chunk') + writeFileSync(join(root, 'resources', 'app.asar.unpacked', 'out', 'package.json'), '{}') + const nativeDir = join(root, 'resources', 'node_modules', 'node-pty', 'build', 'Release') + mkdirSync(nativeDir, { recursive: true }) + writeFileSync(join(nativeDir, 'conpty.node'), 'native') + writeFileSync(join(nativeDir, 'conpty.pdb'), 'debug-symbols') + mkdirSync(join(nativeDir, 'conpty'), { recursive: true }) + writeFileSync(join(nativeDir, 'conpty', 'conpty.dll'), 'conpty-dll') + // Both win32 prebuilds exist in the packaged tree (build-time prune keeps the + // `win32-` prefix); the copy filter keeps the host arch's and drops the other. + const prebuildsRoot = join(root, 'resources', 'node_modules', 'node-pty', 'prebuilds') + for (const arch of ['win32-x64', 'win32-arm64']) { + mkdirSync(join(prebuildsRoot, arch), { recursive: true }) + writeFileSync(join(prebuildsRoot, arch, 'pty.node'), `${arch}-prebuild`) + } +} + +// The win32 prebuild dir the running host arch loads vs. the one that is pruned. +const HOST_PREBUILD = `win32-${process.arch}` +const OTHER_PREBUILD = HOST_PREBUILD === 'win32-arm64' ? 'win32-x64' : 'win32-arm64' + +beforeEach(() => { + tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-relocation-')) + installDir = join(tempDir, 'app') + userDataDir = join(tempDir, 'userData') + mkdirSync(userDataDir, { recursive: true }) + localAppDataDir = join(tempDir, 'localAppData') + mkdirSync(localAppDataDir, { recursive: true }) + process.env.LOCALAPPDATA = localAppDataDir + buildInstallFixture(installDir) + electronApp.isPackaged = true + electronApp.userDataPath = userDataDir + electronApp.version = '9.9.9' + setProcessProp('platform', 'win32') + setProcessProp('execPath', join(installDir, 'Orca.exe')) + setProcessProp('resourcesPath', join(installDir, 'resources')) +}) + +afterEach(() => { + setProcessProp('platform', originalPlatform) + setProcessProp('execPath', originalExecPath) + setProcessProp('resourcesPath', originalResourcesPath) + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA + } else { + process.env.LOCALAPPDATA = originalLocalAppData + } + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch { + // Best-effort + } +}) + +describe('buildDaemonHostManifest', () => { + it('mirrors the win-unpacked layout: exe + data blobs + resources tree, no GPU DLLs', () => { + const appDir = 'C:\\app' + const ops = buildDaemonHostManifest({ + appDir, + execPath: 'C:\\app\\Orca.exe', + resourcesPath: 'C:\\app\\resources', + entrySourcePath: 'C:\\app\\resources\\app.asar.unpacked\\out\\main\\daemon-entry.js', + entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js' + }) + const byDest = new Map(ops.map((op) => [op.destRel, op])) + // The host exe is renamed to a distinct image name (NOT the source basename) + // so the NSIS updater's name-based `taskkill /IM Orca.exe` can't kill it. + expect(byDest.get('orca-terminal-daemon.exe')?.kind).toBe('file') + expect(byDest.has('Orca.exe')).toBe(false) + const exeOp = ops.find((op) => op.sourcePath === 'C:\\app\\Orca.exe') + expect(exeOp?.destRel).not.toBe('Orca.exe') + // V8/ICU data blobs are read by the Electron bootstrap and kept. + expect(byDest.has('icudtl.dat')).toBe(true) + // GPU/graphics DLLs are never loaded by the windowless host, so not copied. + expect(byDest.has('ffmpeg.dll')).toBe(false) + expect(byDest.has('libEGL.dll')).toBe(false) + // Daemon bundle + node-pty mirrored at their real resources-relative paths. + expect(byDest.get('resources/app.asar.unpacked/out/main/daemon-entry.js')?.kind).toBe('file') + expect(byDest.get('resources/app.asar.unpacked/out/main/chunks')?.kind).toBe('dir') + // node-pty is copied with a filter dropping .pdb + non-host-arch prebuilds. + const nodePtyOp = byDest.get('resources/node_modules/node-pty') + expect(nodePtyOp?.kind).toBe('dir') + expect(nodePtyOp?.filter?.('node-pty/build/Release/conpty.node')).toBe(true) + expect(nodePtyOp?.filter?.('node-pty/build/Release/conpty.pdb')).toBe(false) + expect(nodePtyOp?.filter?.(`node-pty/prebuilds/${HOST_PREBUILD}/pty.node`)).toBe(true) + expect(nodePtyOp?.filter?.(`node-pty/prebuilds/${OTHER_PREBUILD}/pty.node`)).toBe(false) + }) +}) + +describe('materializeRelocatedDaemonHost', () => { + it('copies the tree, writes the marker, and returns mirrored fork paths', () => { + const result = materializeRelocatedDaemonHost() + expect(result).not.toBeNull() + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe')) + expect(result?.entryPath).toBe( + join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') + ) + expect(existsSync(result!.execPath)).toBe(true) + expect(existsSync(result!.entryPath)).toBe(true) + // node-pty native + conpty runtime copied at the require-resolvable path. + expect( + existsSync( + join(dest, 'resources', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.node') + ) + ).toBe(true) + expect( + existsSync(join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'chunks', 'a.js')) + ).toBe(true) + // Trim: GPU DLLs, .pdb debug symbols, and non-host-arch prebuilds excluded; + // the host arch's prebuild is retained so node-pty resolves its native addon. + expect(existsSync(join(dest, 'ffmpeg.dll'))).toBe(false) + expect(existsSync(join(dest, 'libEGL.dll'))).toBe(false) + expect( + existsSync( + join(dest, 'resources', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.pdb') + ) + ).toBe(false) + const prebuildsDest = join(dest, 'resources', 'node_modules', 'node-pty', 'prebuilds') + expect(existsSync(join(prebuildsDest, HOST_PREBUILD, 'pty.node'))).toBe(true) + expect(existsSync(join(prebuildsDest, OTHER_PREBUILD, 'pty.node'))).toBe(false) + // Marker records the version + entry rel path, written into the published dir. + const marker = JSON.parse(readFileSync(join(dest, '.materialized.json'), 'utf8')) + expect(marker.version).toBe('9.9.9') + expect(marker.entryRelPath).toBe('resources/app.asar.unpacked/out/main/daemon-entry.js') + }) + + it('is idempotent: a valid marker short-circuits without recopying', () => { + materializeRelocatedDaemonHost() + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + // A recopy would rm the dest; a sentinel inside it must survive the 2nd call. + const sentinel = join(dest, 'sentinel.txt') + writeFileSync(sentinel, 'keep') + const result = materializeRelocatedDaemonHost() + expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe')) + expect(existsSync(sentinel)).toBe(true) + }) + + it('fails open on a missing required input, leaving no dest or staging dir', () => { + rmSync(join(installDir, 'resources', 'node_modules', 'node-pty'), { + recursive: true, + force: true + }) + const result = materializeRelocatedDaemonHost() + expect(result).toBeNull() + const hostRoot = join(localAppDataDir, 'Orca', 'daemon-host') + // Neither the published dest nor any leftover staging dir remains. + const remaining = existsSync(hostRoot) ? readdirSync(hostRoot) : [] + expect(remaining).toEqual([]) + }) + + it('returns null off win32', () => { + setProcessProp('platform', 'darwin') + expect(materializeRelocatedDaemonHost()).toBeNull() + expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) + }) +}) + +describe('getRelocatedDaemonHost', () => { + it('returns null when the marker version does not match the current version', () => { + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + mkdirSync(dirname(join(dest, 'x')), { recursive: true }) + writeFileSync(join(dest, 'Orca.exe'), 'exe') + mkdirSync(join(dest, 'resources', 'app.asar.unpacked', 'out', 'main'), { recursive: true }) + writeFileSync( + join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js'), + 'e' + ) + writeFileSync( + join(dest, '.materialized.json'), + JSON.stringify({ + version: '8.8.8', + completedAt: '', + entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js' + }) + ) + expect(getRelocatedDaemonHost()).toBeNull() + }) +}) + +describe('pruneOldDaemonHosts', () => { + it('removes unpinned non-current version dirs, keeping current and pinned', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + for (const v of ['9.9.9', '1.0.0', '2.0.0']) { + mkdirSync(join(root, v), { recursive: true }) + } + pruneOldDaemonHosts(new Set(['2.0.0'])) + expect(existsSync(join(root, '9.9.9'))).toBe(true) + expect(existsSync(join(root, '2.0.0'))).toBe(true) + expect(existsSync(join(root, '1.0.0'))).toBe(false) + }) +}) + +describe('collectPinnedDaemonVersions', () => { + it('pins the app version of a live daemon pid file and skips dead ones', () => { + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(runtimeDir, { recursive: true }) + writeFileSync( + join(runtimeDir, 'daemon-v4.pid'), + JSON.stringify({ pid: process.pid, startedAtMs: null, appVersion: '7.0.0' }) + ) + writeFileSync( + join(runtimeDir, 'daemon-v3.pid'), + JSON.stringify({ pid: 2147483646, startedAtMs: null, appVersion: '6.0.0' }) + ) + const pinned = collectPinnedDaemonVersions(runtimeDir) + expect(pinned.has('7.0.0')).toBe(true) + expect(pinned.has('6.0.0')).toBe(false) + }) +}) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts new file mode 100644 index 00000000000..ac674fdd290 --- /dev/null +++ b/src/main/daemon/daemon-host-relocation.ts @@ -0,0 +1,398 @@ +import { randomBytes } from 'node:crypto' +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { dirname, join, win32 as winPath } from 'node:path' +import { app } from 'electron' +import { parseDaemonPidFile, startTimeMatches } from './daemon-health' + +/** + * Relocates the terminal daemon's process image out of the app install + * directory into userData so it survives Windows auto-updates. + * + * Why: the daemon is forked as plain Node via ELECTRON_RUN_AS_NODE, so its + * image is the install-dir Orca.exe and its loaded modules (node-pty native, + * ConPTY runtime) map from the install dir. On update, electron-builder's NSIS + * installer deletes the old install and force-closes every process whose image + * lives under it — killing the daemon and every live terminal it owns. Copying + * the daemon's whole file closure to a version-keyed userData dir and forking + * from that copy takes its image + loaded modules out of the installer's reach. + * + * The copy keeps the ELECTRON binary run as node (not stock node.exe): a copy + * of Orca.exe (renamed to a distinct image name) is byte-identical, so + * run-as-node behavior — no console flashing, asar-correct — matches the in-dir + * fork exactly. The win-unpacked layout is mirrored verbatim so + * require('node-pty') and node-pty's native loader resolve the relocated tree + * identically to the packaged app. + * + * Fail-open everywhere: any failure returns null and the caller forks the + * install-dir host — the pre-relocation behavior, byte-identical off win32. + */ + +export type RelocatedDaemonHost = { + /** The relocated host exe to fork the daemon from (run as node). */ + execPath: string + /** The copied daemon-entry.js, mirrored under the relocated resources tree. */ + entryPath: string +} + +const HOST_SUBDIR = 'daemon-host' +const MARKER_NAME = '.materialized.json' + +// The relocated host is machine-specific runtime (~260MB). It must live under +// LOCAL appData, not the roaming userData dir, so a roaming profile or OneDrive +// Known-Folder-Move never syncs it (slow login/logout, sync bloat). This folder +// name is shared verbatim with the NSIS uninstall cleanup +// (config/nsis/daemon-host-uninstall.nsh), which removes +// %LOCALAPPDATA%\\daemon-host — keep the two in sync. +const LOCAL_HOST_ROOT_NAME = 'Orca' + +// The relocated host exe is a copy of Orca.exe renamed to a distinct image +// name. The NSIS updater's name-based kill (`taskkill /IM Orca.exe`) matches by +// image name, so a distinct name spares the daemon from that branch, while the +// userData path (outside $INSTDIR) spares it from the path-based branch. +const DAEMON_HOST_EXE_NAME = 'orca-terminal-daemon.exe' + +// V8 snapshots + ICU data the Electron bootstrap reads even under +// ELECTRON_RUN_AS_NODE; siblings of Orca.exe in win-unpacked. +const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin'] + +type CopyOp = { + sourcePath: string + /** Destination path relative to the host root, posix-separated. */ + destRel: string + kind: 'file' | 'dir' + /** When true, a missing source is skipped rather than failing the copy. */ + optional?: boolean + /** Per-source-path predicate for dir copies: return false to skip a path. */ + filter?: (sourcePath: string) => boolean +} + +type DaemonHostSources = { + appDir: string + execPath: string + resourcesPath: string + entrySourcePath: string + entryRelPath: string +} + +type MaterializeMarker = { + version: string + completedAt: string + entryRelPath: string +} + +// Uses win32 path semantics so Windows layout paths (drive letters, `\`) +// decompose correctly regardless of host OS — needed for cross-platform unit +// tests; production runs this on win32 only. +function toPosixRelative(fromDir: string, absPath: string): string { + return winPath.relative(fromDir, absPath).split(winPath.sep).join('/') +} + +function destPath(root: string, destRel: string): string { + return join(root, ...destRel.split('/')) +} + +// Mirror getDaemonEntryPath()'s resolution order (unpacked root first, then +// out/main) so the copied entry is the exact file the in-dir fork would run. +function resolveEntrySourcePath(resourcesPath: string): string { + const unpackedRoot = join(resourcesPath, 'app.asar.unpacked') + const direct = join(unpackedRoot, 'daemon-entry.js') + if (existsSync(direct)) { + return direct + } + return join(unpackedRoot, 'out', 'main', 'daemon-entry.js') +} + +// Discover the relocation inputs from the live packaged process, or null when +// relocation does not apply (non-win32, dev, or missing resourcesPath). +function collectDaemonHostSources(): DaemonHostSources | null { + if (process.platform !== 'win32' || !app.isPackaged) { + return null + } + const resourcesPath = process.resourcesPath + if (typeof resourcesPath !== 'string' || resourcesPath.length === 0) { + return null + } + const execPath = process.execPath + const appDir = winPath.dirname(execPath) + const entrySourcePath = resolveEntrySourcePath(resourcesPath) + return { + appDir, + execPath, + resourcesPath, + entrySourcePath, + entryRelPath: toPosixRelative(appDir, entrySourcePath) + } +} + +// node-pty ships debug symbols (.pdb) and a win32 prebuild dir per CPU arch; the +// run-as-node daemon loads neither the symbols nor any non-host-arch prebuild +// (verified against the live daemon's loaded module list), so they are filtered +// out of the copy — the bulk of node-pty's on-disk size. Keyed on the host arch +// rather than dropping arm64 outright so a future Windows-arm64 build keeps the +// `win32-arm64` prebuild it actually needs and prunes `win32-x64` instead. +const HOST_WIN_PREBUILD_DIR = `win32-${process.arch}`.toLowerCase() +function isRuntimeNodePtyPath(sourcePath: string): boolean { + const p = sourcePath.toLowerCase() + if (p.endsWith('.pdb')) { + return false + } + // Keep only the host arch's win32 prebuild; drop any other win32- dir. + const prebuild = p.match(/prebuilds[\\/](win32-[^\\/]+)/) + return !prebuild || prebuild[1] === HOST_WIN_PREBUILD_DIR +} + +/** + * The ordered copy plan. Every destRel mirrors the source's win-unpacked + * relative path so require() and node-pty's native loader resolve the mirror + * identically to the packaged app. Pure over its inputs so tests can assert the + * layout without a real build. + */ +export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] { + const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources + const ops: CopyOp[] = [] + + // Electron host binary + V8/ICU data blobs at the dest root. The exe is + // renamed to a distinct image name so the NSIS updater's name-based + // `taskkill /IM Orca.exe` can't match it; the blobs beside it are read by the + // Electron bootstrap by fixed name. Top-level DLLs are deliberately NOT copied + // — they are all GPU/graphics/media (swiftshader, vulkan, d3d, dxcompiler, + // ffmpeg) that a windowless run-as-node host never loads (verified empirically + // against the live daemon's module list), so copying them only wastes ~48MB. + ops.push({ sourcePath: execPath, destRel: DAEMON_HOST_EXE_NAME, kind: 'file' }) + for (const name of RUNTIME_DATA_FILES) { + ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true }) + } + + // Daemon bundle: entry + its sibling chunks/ + the unpacked out/package.json + // (CJS/ESM loader resolution), mirrored verbatim. + ops.push({ sourcePath: entrySourcePath, destRel: entryRelPath, kind: 'file' }) + const chunksDir = join(winPath.dirname(entrySourcePath), 'chunks') + ops.push({ + sourcePath: chunksDir, + destRel: toPosixRelative(appDir, chunksDir), + kind: 'dir', + optional: true + }) + const pkgJson = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json') + ops.push({ + sourcePath: pkgJson, + destRel: toPosixRelative(appDir, pkgJson), + kind: 'file', + optional: true + }) + + // node-pty package tree (native conpty.node + conpty/ runtime dir). It is a + // sibling of app.asar.unpacked; require('node-pty') resolves it by walking up + // from the mirrored daemon-entry dir to resources/node_modules. Filtered to + // drop .pdb debug symbols and other-arch prebuilds the host never loads. + const nodePtyDir = join(resourcesPath, 'node_modules', 'node-pty') + ops.push({ + sourcePath: nodePtyDir, + destRel: toPosixRelative(appDir, nodePtyDir), + kind: 'dir', + filter: isRuntimeNodePtyPath + }) + + return ops +} + +function executeManifest(ops: CopyOp[], stagingRoot: string): void { + for (const op of ops) { + if (!existsSync(op.sourcePath)) { + if (op.optional) { + continue + } + throw new Error(`daemon-host relocation: missing required input ${op.sourcePath}`) + } + const dest = destPath(stagingRoot, op.destRel) + mkdirSync(dirname(dest), { recursive: true }) + const { filter } = op + // Dereference symlinks so the copy holds no link back into the install dir. + cpSync(op.sourcePath, dest, { + recursive: op.kind === 'dir', + dereference: true, + force: true, + ...(filter ? { filter: (src: string) => filter(src) } : {}) + }) + } +} + +function readMarker(dir: string): MaterializeMarker | null { + try { + const parsed = JSON.parse( + readFileSync(join(dir, MARKER_NAME), 'utf8') + ) as Partial + if (typeof parsed.version === 'string' && typeof parsed.entryRelPath === 'string') { + return { + version: parsed.version, + completedAt: typeof parsed.completedAt === 'string' ? parsed.completedAt : '', + entryRelPath: parsed.entryRelPath + } + } + } catch { + // Missing/corrupt marker — treat as not materialized. + } + return null +} + +function hostRootDir(): string { + // Prefer LOCAL appData (see LOCAL_HOST_ROOT_NAME). Fall back to userData only + // if LOCALAPPDATA is somehow unset — a no-op off win32, where relocation never + // runs anyway; on win32 packaged the env var is always present. + const localAppData = process.env.LOCALAPPDATA + const base = + typeof localAppData === 'string' && localAppData.length > 0 + ? join(localAppData, LOCAL_HOST_ROOT_NAME) + : app.getPath('userData') + return join(base, HOST_SUBDIR) +} + +/** + * Cheap idempotency check: the relocated host for the current version, or null. + * Valid only when the marker matches this version AND the exe + entry exist, so + * a partial or stale copy never reports ready. + */ +export function getRelocatedDaemonHost(): RelocatedDaemonHost | null { + const sources = collectDaemonHostSources() + if (!sources) { + return null + } + const version = app.getVersion() + const dest = join(hostRootDir(), version) + const marker = readMarker(dest) + if (!marker || marker.version !== version) { + return null + } + const execPath = join(dest, DAEMON_HOST_EXE_NAME) + const entryPath = destPath(dest, marker.entryRelPath) + if (!existsSync(execPath) || !existsSync(entryPath)) { + return null + } + return { execPath, entryPath } +} + +/** + * Ensure the current version's daemon host is materialized under + * userData/daemon-host/, returning its fork paths or null (fail-open). + * Idempotent: a valid marker for this version short-circuits without recopying. + * The copy stages into a temp sibling and is published by atomic rename, so a + * crash mid-copy never leaves a half-populated dest. + */ +export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { + const existing = getRelocatedDaemonHost() + if (existing) { + return existing + } + const sources = collectDaemonHostSources() + if (!sources) { + return null + } + const version = app.getVersion() + const root = hostRootDir() + const dest = join(root, version) + const staging = join(root, `${version}.staging-${randomBytes(6).toString('hex')}`) + try { + mkdirSync(root, { recursive: true }) + rmSync(staging, { recursive: true, force: true }) + executeManifest(buildDaemonHostManifest(sources), staging) + // Marker written LAST: an interrupted copy leaves a marker-less staging dir + // that the next launch discards, never a dest the cheap check trusts. + const marker: MaterializeMarker = { + version, + completedAt: new Date().toISOString(), + entryRelPath: sources.entryRelPath + } + writeFileSync(join(staging, MARKER_NAME), JSON.stringify(marker)) + // Replace any stale/partial dest, then publish the staging dir atomically. + rmSync(dest, { recursive: true, force: true }) + renameSync(staging, dest) + } catch { + try { + rmSync(staging, { recursive: true, force: true }) + } catch { + // Best-effort staging cleanup. + } + return null + } + return getRelocatedDaemonHost() +} + +function isDaemonPidAlive(pid: number, startedAtMs: number | null): boolean { + try { + process.kill(pid, 0) + } catch { + return false + } + return startTimeMatches(pid, startedAtMs) +} + +/** + * App versions still pinned by a live daemon, read from the daemon-v.pid + * files under `runtimeDir`. A surviving daemon runs from its version's host dir, + * so its dir must never be reclaimed while the process is alive. On win32 the + * start-time check cannot verify, so a matching pid pins conservatively. + */ +export function collectPinnedDaemonVersions(runtimeDir: string): Set { + const pinned = new Set() + let entries + try { + entries = readdirSync(runtimeDir, { withFileTypes: true }) + } catch { + return pinned + } + for (const entry of entries) { + if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { + continue + } + let parsed + try { + parsed = parseDaemonPidFile(readFileSync(join(runtimeDir, entry.name), 'utf8')) + } catch { + continue + } + // appVersion null => a pre-relocation daemon forked from the install dir, + // which pins no host dir here. + if (parsed && parsed.appVersion !== null && isDaemonPidAlive(parsed.pid, parsed.startedAtMs)) { + pinned.add(parsed.appVersion) + } + } + return pinned +} + +/** + * Reclaim daemon-host/ dirs whose ver is neither the current version nor + * pinned by a live daemon. Best-effort — never throws; a still-locked or + * concurrently-staging dir is simply retried on a future launch. + */ +export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { + if (process.platform !== 'win32' || !app.isPackaged) { + return + } + const version = app.getVersion() + const root = hostRootDir() + let entries + try { + entries = readdirSync(root, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === version || pinnedVersions.has(entry.name)) { + continue + } + try { + rmSync(join(root, entry.name), { recursive: true, force: true }) + } catch { + // Still locked or already gone — retry on a future launch. + } + } +} diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index b6028a7a81c..39f05c641ee 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -996,7 +996,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ cwd: '/fake/userData', detached: true }) ) }) @@ -1122,7 +1129,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ cwd: '/fake/userData', detached: true }) ) }) @@ -1239,7 +1253,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ detached: true }) ) }) @@ -1344,6 +1365,123 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(child.unref).not.toHaveBeenCalled() }) + it('captures daemon startup stderr into the failure error', async () => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const stderrDataCbs: ((chunk: Buffer) => void)[] = [] + const stderrDestroy = vi.fn() + const stderr = { + on(event: string, cb: (chunk: Buffer) => void) { + if (event === 'data') { + stderrDataCbs.push(cb) + } + return this + }, + off(event: string, cb: (chunk: Buffer) => void) { + if (event === 'data') { + const idx = stderrDataCbs.indexOf(cb) + if (idx !== -1) { + stderrDataCbs.splice(idx, 1) + } + } + return this + }, + destroy: stderrDestroy + } + const child = { + pid: 4321, + stderr, + on(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + if (event === 'exit') { + // Why: deliver the stderr tail before the exit so the failure path + // sees the captured crash reason, mirroring a module-load crash. + queueMicrotask(() => { + for (const dataCb of stderrDataCbs.slice()) { + dataCb(Buffer.from("Error: Cannot find module 'electron'\n")) + } + cb(1) + }) + } + return this + }, + off: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }), + disconnect: vi.fn(), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + + const error = await launcher('/fake/socket', '/fake/token').catch((err: Error) => err) + + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toMatch(/Cannot find module 'electron'/) + expect((error as Error).message).toMatch(/Daemon stderr \(tail\)/) + // Why: the piped stderr must be released so the detached daemon does not + // keep the parent event loop alive after the failure. + expect(stderrDestroy).toHaveBeenCalled() + }) + + it('destroys the daemon stderr pipe once the daemon signals ready', async () => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const stderrOff = vi.fn() + const stderrDestroy = vi.fn() + const stderr = { + on() { + return this + }, + off: stderrOff, + destroy: stderrDestroy + } + const child = { + pid: 12345, + stderr, + on(event: string, cb: (arg?: unknown) => void) { + handlers[event]?.push(cb) + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + off: vi.fn(() => child), + disconnect: vi.fn(), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + + await launcher('/fake/socket', '/fake/token') + + expect(stderrOff).toHaveBeenCalledWith('data', expect.any(Function)) + expect(stderrDestroy).toHaveBeenCalledOnce() + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + }) + it('preserves a health-check-failing daemon when it owns live sessions', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1499,7 +1637,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ detached: true }) ) }) @@ -1584,7 +1729,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ detached: true }) ) }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 3ba351c3576..4de553cde16 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -36,6 +36,11 @@ import { isDaemonStaleForCurrentBundle, killStaleDaemon } from './daemon-health' +import { + collectPinnedDaemonVersions, + materializeRelocatedDaemonHost, + pruneOldDaemonHosts +} from './daemon-host-relocation' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { getLocalPtyProvider, @@ -44,6 +49,7 @@ import { rebindLocalProviderListeners } from '../ipc/pty' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' +import { getDaemonLogFilePath } from '../observability/logs-directory' import { confirmSeededClaudeLivePtys, hasSeededUnconfirmedClaudePtys @@ -93,6 +99,18 @@ function getDaemonEntryPath(): string { return join(basePath, 'out', 'main', 'daemon-entry.js') } +// Why: the detached daemon writes lifecycle events to a rotated file so field +// failures are diagnosable from a bundle. Honor the same hard privacy switch +// the local trace sink honors (ORCA_DIAGNOSTICS_DISABLED); absence of the arg +// is fully supported, so gating it off is safe and adoption-neutral. +function daemonLogArgs(): string[] { + const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase() + if (disabled === '1' || disabled === 'true') { + return [] + } + return ['--log-file', getDaemonLogFilePath()] +} + // Why: before spawning a new daemon, check if an existing one is alive by // attempting a TCP connection to the socket. If it connects, the daemon // survived from a previous app session — reuse it instead of spawning. @@ -264,27 +282,70 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { await killStaleDaemon(runtimeDir, socketPath, tokenPath) const userDataPath = app.getPath('userData') - const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { - // Why: detached daemons can outlive dev worktrees. Starting from - // userData keeps process.cwd() valid after a repo/worktree is deleted. - cwd: userDataPath, - // Why: detached + unref lets the daemon outlive the Electron process. - // stdio 'ignore' prevents the child from holding the parent's stdout - // open, which would prevent Electron from exiting cleanly. - detached: true, - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain - // Node.js process instead of an Electron renderer/main process. Without - // it, Electron's GPU/display initialization can interfere with native - // module operations like node-pty's posix_spawn of the spawn-helper. - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: '1', - // Why: the detached daemon is plain Node and cannot call Electron's - // app.getPath(), but shell-ready rcfiles must live outside swept tmp. - ORCA_USER_DATA_PATH: userDataPath + // Why: on win32 packaged, fork from a copy of the Electron runtime staged + // in userData so the daemon's image + loaded modules escape the install dir + // the NSIS updater deletes and force-closes. Staged here (not at app start) + // so the one-time copy stays off the first-paint path and is skipped on + // launches that adopt a live daemon. Fail-open: null → in-dir host, below. + const relocatedHost = materializeRelocatedDaemonHost() + // Fork the relocated entry when available; otherwise the install-dir entry. + const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath + const child = fork( + forkEntryPath, + ['--socket', socketPath, '--token', tokenPath, ...daemonLogArgs()], + { + // Why: detached daemons can outlive dev worktrees. Starting from + // userData keeps process.cwd() valid after a repo/worktree is deleted. + cwd: userDataPath, + // Why: detached + unref lets the daemon outlive the Electron process. + // stdout stays 'ignore' so the child never holds the parent's stdout + // open (which would block Electron exit); stderr is 'pipe' so a + // module-load crash during startup is captured instead of discarded + // (v1.4.129-rc.1 shipped a daemon that only logged "exited with code 1" + // because stderr was thrown away). The pipe is destroyed on readiness. + detached: true, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + // Why: run the relocated Orca.exe copy instead of the install-dir one. + // It is byte-identical, so run-as-node behavior is unchanged; only the + // image path moves out of the updater's kill zone. + ...(relocatedHost ? { execPath: relocatedHost.execPath } : {}), + // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain + // Node.js process instead of an Electron renderer/main process. Without + // it, Electron's GPU/display initialization can interfere with native + // module operations like node-pty's posix_spawn of the spawn-helper. + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + // Why: the detached daemon is plain Node and cannot call Electron's + // app.getPath(), but shell-ready rcfiles must live outside swept tmp. + ORCA_USER_DATA_PATH: userDataPath + } } - }) + ) + + // Why: keep only the startup-window stderr tail so a crash cause is + // visible without unbounded memory if the daemon spews before dying. + const STARTUP_STDERR_MAX_BYTES = 8192 + let startupStderr = '' + let collectingStderr = true + const onStartupStderr = (chunk: Buffer): void => { + if (!collectingStderr) { + return + } + startupStderr += chunk.toString('utf8') + if (startupStderr.length > STARTUP_STDERR_MAX_BYTES) { + startupStderr = startupStderr.slice(-STARTUP_STDERR_MAX_BYTES) + } + } + child.stderr?.on('data', onStartupStderr) + // Why: once the daemon is up (or has failed) the parent must not keep a + // live handle on the detached daemon's stderr — a piped stream would ref + // the parent event loop and prevent Electron from exiting cleanly. + const releaseStderr = (): void => { + collectingStderr = false + child.stderr?.off('data', onStartupStderr) + child.stderr?.destroy() + } // Wait for the daemon to signal readiness via IPC await new Promise((resolve, reject) => { @@ -304,6 +365,14 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { } settled = true cleanupStartupListeners() + // Why: stderr was previously discarded, so a startup crash surfaced only + // as "exited with code 1". Attach the captured tail to the thrown error + // (which the fallback path reports) and log it so the real cause shows. + const stderrTail = startupStderr.trim() + if (stderrTail) { + console.warn(`[daemon] startup failed; captured stderr tail:\n${stderrTail}`) + } + releaseStderr() if (child.pid) { try { process.kill(child.pid, 'SIGTERM') @@ -311,7 +380,9 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // Already dead } } - reject(error) + reject( + stderrTail ? new Error(`${error.message}\nDaemon stderr (tail):\n${stderrTail}`) : error + ) } function onReadyMessage(msg: unknown): void { if (msg && typeof msg === 'object' && (msg as { type?: string }).type === 'ready') { @@ -338,8 +409,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { { mode: 0o600 } ) } - // Why: disconnect IPC channel and unref so Electron can exit - // without waiting for the daemon. The daemon keeps running. + // Why: disconnect IPC channel, release the stderr pipe, and unref so + // Electron can exit without waiting for the daemon. The daemon keeps + // running detached. + releaseStderr() child.disconnect() child.unref() resolve() @@ -398,6 +471,10 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise // throws, a stale spawner would prevent shutdownDaemon() from cleaning up // correctly on retry. const info = await newSpawner.ensureRunning() + // Reclaim superseded daemon-host copies on EVERY launch, not just on a fresh + // spawn: surviving daemons make spawns rare, so a spawn-only sweep would let + // old-version copies accumulate. Current + live-daemon-pinned versions stay. + pruneOldDaemonHosts(collectPinnedDaemonVersions(runtimeDir)) const launchMode = newSpawner.getHandle()?.mode logDaemonMilestone('daemon-current-ready') if (signal?.aborted) { diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 63078067c93..82f4dad7b7e 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -1,9 +1,11 @@ import { DaemonServer, type DaemonServerOptions } from './daemon-server' +import type { DaemonFileLog } from './daemon-file-log' export type DaemonStartOptions = { socketPath: string tokenPath: string spawnSubprocess: DaemonServerOptions['spawnSubprocess'] + log?: DaemonFileLog } export type DaemonHandle = { @@ -14,7 +16,8 @@ export async function startDaemon(opts: DaemonStartOptions): Promise Promise + log?: DaemonFileLog spawnSubprocess: (opts: { sessionId: string cols: number @@ -48,6 +50,7 @@ export class DaemonServer { private socketPath: string private tokenPath: string private ptySpawnHealthCheck: () => Promise + private log: DaemonFileLog private clients = new Map() private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId)) @@ -66,6 +69,7 @@ export class DaemonServer { this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth + this.log = opts.log ?? createNoopDaemonFileLog() } async start(): Promise { @@ -139,23 +143,30 @@ export class DaemonServer { ): void { const hello = msg as HelloMessage if (hello.type !== 'hello') { + this.log.log('client-hello-rejected', { reason: 'expected-hello' }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) socket.destroy() return } if (hello.version !== PROTOCOL_VERSION) { + this.log.log('client-hello-rejected', { + reason: 'protocol-mismatch', + clientVersion: hello.version + }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' })) socket.destroy() return } if (hello.token !== this.token) { + this.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) socket.destroy() return } + this.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId }) socket.write(encodeNdjson({ type: 'hello', ok: true })) if (hello.role === 'control') { @@ -297,6 +308,7 @@ export class DaemonServer { onExit: (code) => { // Why: exit tears down renderer handlers; flush final output first // so the last few milliseconds of PTY data are not stranded. + this.log.log('session-exited', { sessionId: p.sessionId, code }) this.streamDataBatcher.flush(clientId) this.lastInputAtBySessionId.delete(p.sessionId) if (client?.streamSocket) { @@ -312,6 +324,10 @@ export class DaemonServer { } } }) + this.log.log(result.isNew ? 'session-created' : 'session-attached', { + sessionId: p.sessionId, + pid: result.pid + }) return { isNew: result.isNew, snapshot: result.snapshot, @@ -349,6 +365,10 @@ export class DaemonServer { case 'kill': this.lastInputAtBySessionId.delete(request.payload.sessionId) + this.log.log('session-killed', { + sessionId: request.payload.sessionId, + immediate: request.payload.immediate === true + }) this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate }) return {} @@ -359,6 +379,7 @@ export class DaemonServer { case 'detach': // Note: detach token handling is simplified here — full implementation // would track tokens per client + this.log.log('session-detached', { sessionId: request.payload.sessionId }) return {} case 'getCwd': @@ -402,6 +423,10 @@ export class DaemonServer { return { healthy: true } case 'shutdown': + this.log.log('shutdown', { + reason: 'rpc', + killSessions: request.payload.killSessions === true + }) if (request.payload.killSessions) { this.host.dispose() } diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index fb0a9c190d4..74381d3d543 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -3,6 +3,7 @@ import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' import { Unicode11Addon } from '@xterm/addon-unicode11' import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' +import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail' import { extractLastOscTitle } from '../../shared/agent-detection' import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' @@ -36,6 +37,13 @@ export class HeadlessEmulator { private oscScanTail = '' private privateModes = new TerminalPrivateModeTracker() private restoredOscLinks: TerminalOscLinkRange[] = [] + // Why: a PTY read can end mid-escape-sequence — those bytes live in xterm's + // parser, not the screen buffer, so serialize() drops them and the next + // chunk's continuation renders literally after a remote snapshot restore + // (#7329). Track the unparsed trailing partial at ingest (committed after + // xterm parses the same bytes, like the private-mode mirror) and ship it in + // the snapshot so the restorer can complete the sequence. + private partialEscapeTail = '' private disposed = false private readonly pathFlavor?: 'posix' | 'win32' private readonly remotePosixFileUriAuthority: boolean @@ -89,6 +97,7 @@ export class HeadlessEmulator { // Why: snapshots combine serialized xterm state with mirrored mouse // modes. Commit the mirror only after xterm has parsed the same bytes. this.privateModes.scan(data) + this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) resolve() }) }) @@ -115,6 +124,7 @@ export class HeadlessEmulator { // PTY bursts; queued headless writes can snapshot half-cleared TUI rows. writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) this.privateModes.scan(data) + this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) return true } @@ -164,7 +174,13 @@ export class HeadlessEmulator { cols: this.terminal.cols, rows: this.terminal.rows, scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows, - lastTitle: this.lastTitle ?? undefined + lastTitle: this.lastTitle ?? undefined, + // Why: written LAST by the restorer (after any reset) so the next live + // chunk completes this dangling sequence instead of rendering it literally + // (#7329). Its bytes are already counted by the snapshot seq. + ...(this.partialEscapeTail.length > 0 + ? { pendingEscapeTailAnsi: this.partialEscapeTail } + : {}) } } diff --git a/src/main/daemon/hibernation-cold-restore-repro.test.ts b/src/main/daemon/hibernation-cold-restore-repro.test.ts index d3b5f4ee0e2..4d5281364d9 100644 --- a/src/main/daemon/hibernation-cold-restore-repro.test.ts +++ b/src/main/daemon/hibernation-cold-restore-repro.test.ts @@ -133,7 +133,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => { // src/renderer/src/components/terminal-pane/layout-serialization.ts (copied // as a literal because a main-process test must not import a renderer module). const POST_REPLAY_MODE_RESET = - '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?2004l' + '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b[?2004l' const fresh = new HeadlessEmulator({ cols: 80, rows: 24 }) fresh.writeSync('\x1b[2J\x1b[3J\x1b[H') fresh.writeSync(adapterScrollback as string) diff --git a/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts new file mode 100644 index 00000000000..b198f3e7475 --- /dev/null +++ b/src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts @@ -0,0 +1,185 @@ +// Regression guard: bound the volume of whole-process-table scans the daemon +// schedules for persisted sessions. On Windows each agent-foreground refresh +// forks a powershell.exe/CIM whole-table scan (the daemon-side analogue of +// #6288), so an idle shell with no agent identity and no recent output must +// retry slowly no matter how often readers poll getForegroundProcess; PTY +// output re-arms the fast retry so agent starts still resolve promptly, and +// sessions with a cached agent identity keep the 1s refresh unrelaxed. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { spawnMock, isPwshAvailableMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + isPwshAvailableMock: vi.fn(), + resolveAgentForegroundProcessMock: vi.fn() +})) + +vi.mock('node-pty', () => ({ + spawn: spawnMock +})) + +vi.mock('../pwsh', () => ({ + isPwshAvailable: isPwshAvailableMock +})) + +// Resolve PowerShell family names to deterministic absolute paths so these +// tests run on non-Windows CI (mirrors pty-subprocess.test.ts). +const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' +const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' +const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' +vi.mock('../providers/windows-powershell-executable', () => ({ + resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => + family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, + resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') => + family === 'pwsh.exe' + ? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS] + : [WINDOWS_POWERSHELL_ABS, CMD_ABS], + getWindowsCmdPath: () => CMD_ABS +})) + +vi.mock('../providers/agent-foreground-process', () => ({ + resolveAgentForegroundProcess: resolveAgentForegroundProcessMock +})) + +import { createPtySubprocess } from './pty-subprocess' + +// Well past any hot window or retry throttle so t=0 reads behave as idle. +const BASE_TIME_MS = 1_000_000 + +function mockPtyProcess(processName: string, pid = 12345) { + const onDataListeners: ((data: string) => void)[] = [] + return { + pid, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + process: processName, + onData: vi.fn((cb: (data: string) => void) => { + onDataListeners.push(cb) + return { dispose: vi.fn() } + }), + onExit: vi.fn(() => ({ dispose: vi.fn() })), + _simulateData: (data: string) => onDataListeners.forEach((cb) => cb(data)) + } +} + +async function flushAsyncTicks(count = 6): Promise { + for (let i = 0; i < count; i++) { + await Promise.resolve() + } +} + +// One renderer/daemon reader poll: read the foreground and let the scheduled +// background refresh (if any) settle so its cache write lands before the next. +async function readForegroundAt( + handle: { getForegroundProcess: () => string | null }, + atMs: number +): Promise { + vi.setSystemTime(BASE_TIME_MS + atMs) + const foreground = handle.getForegroundProcess() + await flushAsyncTicks() + return foreground +} + +describe('daemon pty foreground scan cadence', () => { + let platform: PropertyDescriptor | undefined + let previousUserDataPath: string | undefined + let userDataPath: string + + beforeEach(() => { + spawnMock.mockReset() + isPwshAvailableMock.mockReset() + isPwshAvailableMock.mockReturnValue(false) + resolveAgentForegroundProcessMock.mockReset() + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-scan-cadence-test-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + platform = Object.getOwnPropertyDescriptor(process, 'platform') + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(BASE_TIME_MS) + }) + + afterEach(() => { + vi.useRealTimers() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + }) + + function spawnShellSubprocess(shellProcessName: string, targetPlatform: 'win32' | 'darwin') { + Object.defineProperty(process, 'platform', { configurable: true, value: targetPlatform }) + const proc = mockPtyProcess(shellProcessName) + spawnMock.mockReturnValue(proc) + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + return { proc, handle } + } + + it('bounds scans for an idle Windows shell polled every 2s to the 15s retry tier', async () => { + resolveAgentForegroundProcessMock.mockResolvedValue('powershell.exe') + const { handle } = spawnShellSubprocess('powershell.exe', 'win32') + + for (let atMs = 0; atMs <= 60_000; atMs += 2_000) { + expect(await readForegroundAt(handle, atMs)).toBe('powershell.exe') + } + + // Scans at t=0s, 16s, 32s, 48s. Pre-fix (5s retry) the same 31 reads drove + // 11 whole-table scans; the 2s read cadence itself would drive 31. + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(4) + }) + + it('re-arms the fast retry when PTY output appears on an idle Windows shell', async () => { + resolveAgentForegroundProcessMock.mockResolvedValueOnce('powershell.exe') + const { proc, handle } = spawnShellSubprocess('powershell.exe', 'win32') + + await readForegroundAt(handle, 0) + await readForegroundAt(handle, 2_000) + await readForegroundAt(handle, 4_000) + await readForegroundAt(handle, 6_000) + // Idle: only the t=0 scan ran (6s is within the relaxed 15s retry). + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1) + + // The user launches an agent; its startup output re-arms the 5s retry. + resolveAgentForegroundProcessMock.mockResolvedValue('claude') + vi.setSystemTime(BASE_TIME_MS + 6_500) + proc._simulateData('claude\r\n') + + await readForegroundAt(handle, 8_000) + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) + // The resolved identity is served from the cache on the next read. + expect(await readForegroundAt(handle, 8_500)).toBe('claude') + }) + + it('keeps the fast identity refresh for a Windows session with a cached agent', async () => { + resolveAgentForegroundProcessMock.mockResolvedValue('codex') + const { handle } = spawnShellSubprocess('powershell.exe', 'win32') + + await readForegroundAt(handle, 0) + expect(await readForegroundAt(handle, 1_000)).toBe('codex') + await readForegroundAt(handle, 3_000) + await readForegroundAt(handle, 5_000) + + // Every read past the 1s cache TTL refreshes (t=0s, 1s, 3s, 5s): agent-exit + // detection through the identity cache must not be relaxed by the idle tier. + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(4) + }) + + it('keeps the 5s retry for an idle POSIX shell with no output', async () => { + resolveAgentForegroundProcessMock.mockResolvedValue('zsh') + const { handle } = spawnShellSubprocess('zsh', 'darwin') + + for (let atMs = 0; atMs <= 30_000; atMs += 2_000) { + expect(await readForegroundAt(handle, atMs)).toBe('zsh') + } + + // Scans at t=0s, 6s, 12s, 18s, 24s, 30s — the cheap `ps` path is unchanged. + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(6) + }) +}) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index a4fdfce15ee..30eb7b40082 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -64,6 +64,12 @@ const PANE_IDENTITY_ENV_KEYS = [ ] as const const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 +// Why: a Windows refresh forks a powershell.exe whole-process-table CIM scan +// (~10-40x heavier than POSIX `ps`). An idle shell with no agent identity and +// no recent output retries far slower; output re-arms the 5s retry so an agent +// start (which always prints) is still resolved promptly. +const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000 +const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000 const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000 const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 // Why: a busy machine right after an upgrade can make one short-lived shell @@ -840,7 +846,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl } } + let lastOutputAt = 0 proc.onData((data) => { + if (data.length > 0) { + lastOutputAt = Date.now() + } if (onDataCb) { onDataCb(data) } else { @@ -912,10 +922,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return } const now = Date.now() - const retryMs = + const idleNoEvidenceShell = fallbackIsShell && !getActiveStartupAgentForeground(now) && !cachedAgentForeground - ? SHELL_FOREGROUND_REFRESH_RETRY_MS - : FOREGROUND_AGENT_CACHE_TTL_MS + // Why: on Windows each refresh is a whole-table CIM scan; only shells with + // no agent evidence and no recent output relax, so agent-identity refresh + // (cached identity → 1s TTL) and post-output starts keep the fast retry. + const retryMs = !idleNoEvidenceShell + ? FOREGROUND_AGENT_CACHE_TTL_MS + : process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS + ? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS + : SHELL_FOREGROUND_REFRESH_RETRY_MS if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) { return } diff --git a/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts b/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts new file mode 100644 index 00000000000..8f8256519a4 --- /dev/null +++ b/src/main/daemon/repro-7329-remote-snapshot-corruption.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { HeadlessEmulator } from './headless-emulator' + +// Repro for #7329: "remote server + terminal" — typing gets escape sequences +// injected/wrapped around it and follow-up commands are corrupted. +// +// The remote-server path serializes terminal state on the daemon via +// HeadlessEmulator.getSnapshot() (SerializeAddon + buildRehydrateSequences) and +// replays it into the renderer xterm, then applies POST_REPLAY_REATTACH_RESET. +// This test drives the REAL daemon serializer and a REAL renderer-side xterm to +// see what the user's terminal ends up looking like after a subscribe/reattach. + +const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' +const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u' +const RESET_MOUSE_REPORTING = '\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l' +// Verbatim from layout-serialization.ts (the reattach path the remote onSnapshot uses). +const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l` + +function writeXterm(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +async function emulatorWrite(emu: HeadlessEmulator, data: string): Promise { + await emu.write(data) +} + +/** Replay a daemon snapshot into a renderer xterm exactly like the remote + * onSnapshot → replayDataCallback path: clear, write rehydrate+snapshot, + * then the reattach mode reset. */ +async function replayRemoteSnapshot( + term: Terminal, + snapshot: { rehydrateSequences: string; snapshotAnsi: string; pendingEscapeTailAnsi?: string } +): Promise { + await writeXterm(term, '\x1b[2J\x1b[3J\x1b[H') + await writeXterm(term, snapshot.rehydrateSequences + snapshot.snapshotAnsi) + await writeXterm(term, POST_REPLAY_REATTACH_RESET) + // The fix: the restorer writes the pending mid-escape tail LAST, after the + // reset (mirrors drainReplayDataQueue in pty-connection.ts). + if (snapshot.pendingEscapeTailAnsi) { + await writeXterm(term, snapshot.pendingEscapeTailAnsi) + } +} + +function renderVisible(term: Terminal): string { + const buf = term.buffer.active + const lines: string[] = [] + for (let y = 0; y < term.rows; y += 1) { + lines.push(buf.getLine(buf.viewportY + y)?.translateToString(true) ?? '') + } + return lines.join('\n').replace(/\s+$/g, '') +} + +describe('#7329 remote-server snapshot corruption', () => { + it('disarms rehydrated mouse modes but keeps bracketed paste after a reattach snapshot', async () => { + const emu = new HeadlessEmulator({ cols: 80, rows: 24 }) + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + // A live remote shell that armed bracketed paste (bash 4.4+/readline + // default) and vt200+SGR mouse (a TUI the user just exited uncleanly). + await emulatorWrite(emu, '\x1b[?2004h\x1b[?1000h\x1b[?1006h') + await emulatorWrite(emu, 'user@host:~$ ') + + const snapshot = emu.getSnapshot({ scrollbackRows: 0 }) + // The daemon snapshot re-arms the modes it observed. + expect(snapshot.rehydrateSequences).toContain('\x1b[?2004h') + expect(snapshot.modes.bracketedPaste).toBe(true) + expect(snapshot.modes.mouseTracking).toBe(true) + + await replayRemoteSnapshot(term, snapshot) + + // Bracketed paste survives — the live shell armed it and won't re-arm + // until the next prompt. Mouse reporting must NOT survive: with a plain + // shell in the foreground, xterm would emit `35;x;yM` motion reports the + // shell echoes as literal input on every pointer move. (Live agent panes + // preserve mouse via POST_REPLAY_LIVE_AGENT_REATTACH_RESET instead.) + expect(term.modes.bracketedPasteMode).toBe(true) + expect(term.modes.mouseTrackingMode).toBe('none') + } finally { + emu.dispose() + term.dispose() + } + }) + + it('disarms any-motion (?1003) and pixel-encoded (?1016) mouse modes left by a killed TUI', async () => { + const emu = new HeadlessEmulator({ cols: 80, rows: 24 }) + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + // An agent TUI armed any-motion tracking, then was SIGKILLed — no + // disarm bytes ever reach the daemon, so its tracker keeps the mode. + await emulatorWrite(emu, '\x1b[?1003h\x1b[?1016h') + await emulatorWrite(emu, 'user@host:~$ ') + + const snapshot = emu.getSnapshot({ scrollbackRows: 0 }) + expect(snapshot.rehydrateSequences).toContain('\x1b[?1003h') + expect(snapshot.rehydrateSequences).toContain('\x1b[?1016h') + + await replayRemoteSnapshot(term, snapshot) + + expect(term.modes.mouseTrackingMode).toBe('none') + } finally { + emu.dispose() + term.dispose() + } + }) + + it('drops a mid-escape tail so continuation bytes render literally after reattach', async () => { + const emu = new HeadlessEmulator({ cols: 80, rows: 24 }) + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + // A remote PTY read that ends mid-escape: the shell was about to paint a + // colored prompt but the read boundary split the SGR sequence. The next + // read carries the continuation. The daemon serializes BETWEEN the two + // reads (e.g. the client subscribes/reattaches right then). + await emulatorWrite(emu, 'first line\r\n') + await emulatorWrite(emu, '\x1b[3') // <-- partial SGR: "\x1b[38;5;...m" started + + const snapshot = emu.getSnapshot({ scrollbackRows: 0 }) + // The serializer still cannot put the dangling "\x1b[3" in the screen ANSI + // (it lives in the parser)... + expect(snapshot.snapshotAnsi).not.toContain('\x1b[3') + // ...but the emulator now ships it as a separate pending-escape tail. + expect(snapshot.pendingEscapeTailAnsi).toBe('\x1b[3') + + await replayRemoteSnapshot(term, snapshot) + + // The continuation of the split escape arrives as the next live chunk. + await writeXterm(term, '8;5;196mred$ yes\r\n') + + const visible = renderVisible(term) + // FIXED: the tail was replayed after the reset, so the continuation + // "8;5;196m" completes the SGR escape and is consumed — not rendered + // literally. The visible text is just the prompt + typed command. + expect(visible).not.toContain('8;5;196m') + expect(visible).toContain('red$ yes') + } finally { + emu.dispose() + term.dispose() + } + }) + + it('does not eat the continuation byte when the tail and continuation are contiguous', async () => { + // The safety property the fix relies on (mirrors the snapshot-seq accounting + // in orca-runtime/getOutputAfterSnapshotSeq): the snapshot seq counts the + // tail bytes, so the FIRST live chunk after the snapshot is the exact + // continuation and completes the dangling sequence with no eaten byte. + const emu = new HeadlessEmulator({ cols: 80, rows: 24 }) + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await emulatorWrite(emu, '\x1b[38;5;') // dangling: params so far + const snapshot = emu.getSnapshot({ scrollbackRows: 0 }) + expect(snapshot.pendingEscapeTailAnsi).toBe('\x1b[38;5;') + await replayRemoteSnapshot(term, snapshot) + // Continuation completes the SGR then prints a visible token. + await writeXterm(term, '82mHELLO') + const visible = renderVisible(term) + // No byte of "82m" leaks; the token renders whole (color applied). + expect(visible).toBe('HELLO') + } finally { + emu.dispose() + term.dispose() + } + }) + + it('documents the residual edge: an idle-death tail eats the next output byte', async () => { + // KNOWN, ACCEPTED trade-off: if a mid-escape read is the LAST thing the + // process emits (it dies/idles and never sends the continuation), the tail + // sits armed in the parser and the NEXT unrelated output loses its first + // byte. This is strictly narrower than the bug it fixes (which garbled every + // real split escape), and pre-fix such a stream simply dropped the partial. + const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true }) + try { + await writeXterm(term, 'prompt$ ') + await writeXterm(term, '\x1b[3') // armed tail, no continuation ever comes + await writeXterm(term, 'yes\r\n') // unrelated later output + // The 'y' is absorbed as a CSI parameter of the dangling sequence. + expect(renderVisible(term)).toBe('prompt$ es') + } finally { + term.dispose() + } + }) +}) diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index a38cc21a425..10d27f2c8e0 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -25,6 +25,12 @@ export type TerminalSnapshot = { scrollbackAnsi: string oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string + /** The trailing partial escape sequence left unparsed in the emulator when a + * PTY read ended mid-escape. serialize() cannot carry it (it lives in the + * parser, not the buffer), so the restorer must write it LAST — after any + * post-snapshot reset — so the next live chunk's continuation completes the + * sequence instead of rendering literally (#7329). */ + pendingEscapeTailAnsi?: string cwd: string | null modes: TerminalModes cols: number diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 920e079989d..946f5ac8809 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -1,5 +1,6 @@ import { homedir } from 'node:os' import { join } from 'node:path' +import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' import { buildManagedCommandHook, @@ -12,8 +13,14 @@ import { wrapWindowsHookCommand, writeHooksJson, writeManagedScript, - type HookDefinition + type HookDefinition, + type HooksConfig } from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' // Why: SessionStart is installed (not just listened for) so that resuming a // droid session via `droid --resume` resets the per-pane prompt/tool caches @@ -64,8 +71,8 @@ function getManagedCommand(scriptPath: string): string { : wrapPosixHookCommand(scriptPath) } -function getManagedScript(): string { - if (process.platform === 'win32') { +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { return [ '@echo off', 'setlocal', @@ -111,6 +118,45 @@ function getManagedScript(): string { ].join('\n') } +// Why: local install() and installRemote() must produce byte-identical Factory +// settings.json hook trees; sharing this mutation is what keeps the SSH install +// from drifting off the local one (the exact class of bug that left Droid with +// no remote installer at all — issue #7253). +function buildInstalledDroidConfig( + config: HooksConfig, + command: string, + scriptFileName: string +): void { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const managedEvents = new Set(DROID_EVENTS.map((event) => event.eventName)) + + // Why: sweep managed entries out of events we no longer subscribe to. + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + for (const event of DROID_EVENTS) { + const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + ...event.definition, + hooks: [buildManagedCommandHook(command)] + } + nextHooks[event.eventName] = [...cleaned, definition] + } + + config.hooks = nextHooks +} + export class DroidHookService { getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() @@ -189,43 +235,57 @@ export class DroidHookService { } } - const command = getManagedCommand(scriptPath) - const nextHooks = { ...config.hooks } - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) - const managedEvents = new Set(DROID_EVENTS.map((event) => event.eventName)) - - // Why: sweep managed entries out of events we no longer subscribe to. - for (const [eventName, definitions] of Object.entries(nextHooks)) { - if (managedEvents.has(eventName)) { - continue - } - if (!Array.isArray(definitions)) { - continue - } - const cleaned = removeManagedCommands(definitions, isManagedCommand) - if (cleaned.length === 0) { - delete nextHooks[eventName] - } else { - nextHooks[eventName] = cleaned - } - } - - for (const event of DROID_EVENTS) { - const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] - const cleaned = removeManagedCommands(current, isManagedCommand) - const definition: HookDefinition = { - ...event.definition, - hooks: [buildManagedCommandHook(command)] - } - nextHooks[event.eventName] = [...cleaned, definition] - } - - config.hooks = nextHooks + buildInstalledDroidConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName()) writeManagedScript(scriptPath, getManagedScript()) writeHooksJson(configPath, config) return this.getStatus() } + // Why: SSH remotes run the Droid CLI on the remote host, so its hook config + // and managed script must be written into the remote ~/.factory + ~/.orca via + // SFTP. Without this, Droid never fires the managed hook over SSH and its + // status row is absent from the task tree (issue #7253). Mirrors the local + // install() but always emits POSIX script/paths — even from a Windows host. + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const home = remoteHome.replace(/\/$/, '') + const remoteConfigPath = `${home}/.factory/settings.json` + const remoteScriptPath = `${home}/.orca/agent-hooks/droid-hook.sh` + try { + const config = await readHooksJsonRemote(sftp, remoteConfigPath) + if (!config) { + return { + agent: 'droid', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote Factory settings.json' + } + } + + buildInstalledDroidConfig(config, wrapPosixHookCommand(remoteScriptPath), 'droid-hook.sh') + // Why: script first, config last — a partial config write must never + // point Droid at a script that isn't on disk yet. + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, config) + + return { + agent: 'droid', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'droid', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + remove(): AgentHookInstallStatus { const configPath = getConfigPath() const config = readHooksJson(configPath) diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts index 006531dff20..bf9d7a61fa1 100644 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ b/src/main/emulator/serve-sim-state-watcher.test.ts @@ -6,6 +6,17 @@ import { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-s const TEST_UDID = '11111111-2222-3333-4444-555555555555' +let isolatedStateDirSeq = 0 +// Why: the default state dir is the real $TMPDIR/serve-sim — a leftover state +// file from an actual simulator session would leak extra events into tests +// that only exercise PTY ingestion. Point at a dir that never exists. +function createIsolatedWatcher(): ServeSimStateWatcher { + isolatedStateDirSeq += 1 + return new ServeSimStateWatcher({ + stateDir: join(tmpdir(), `orca-serve-sim-test-none-${process.pid}-${isolatedStateDirSeq}`) + }) +} + async function waitForEvent( events: ServeSimStateDetectedEvent[], predicate: (event: ServeSimStateDetectedEvent) => boolean @@ -68,7 +79,7 @@ describe('ServeSimStateWatcher', () => { }) it('suppresses Orca-managed sessions only while they are marked managed', async () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const payload = JSON.stringify({ device: TEST_UDID, @@ -95,7 +106,7 @@ describe('ServeSimStateWatcher', () => { }) it('prunes worktree-scoped dedupe keys on forget so a re-bound worktree re-emits', () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const payload = JSON.stringify({ device: TEST_UDID, @@ -122,7 +133,7 @@ describe('ServeSimStateWatcher', () => { }) it('dedupes one helper without hiding a later helper for the same simulator', () => { - const watcher = new ServeSimStateWatcher() + const watcher = createIsolatedWatcher() const events: ServeSimStateDetectedEvent[] = [] const firstPayload = JSON.stringify({ device: TEST_UDID, diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts index 507e9b5700d..8ced95ffd2f 100644 --- a/src/main/external-editor-launch.test.ts +++ b/src/main/external-editor-launch.test.ts @@ -1,8 +1,22 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { resolveCliCommandMock } = vi.hoisted(() => ({ + resolveCliCommandMock: vi.fn((command: string) => command) +})) + +vi.mock('./codex-cli/command', () => ({ + resolveCliCommand: resolveCliCommandMock +})) + import { getCmdExePath } from './win32-utils' import { resolveExternalEditorLaunchSpec } from './external-editor-launch' describe('resolveExternalEditorLaunchSpec', () => { + beforeEach(() => { + resolveCliCommandMock.mockReset() + resolveCliCommandMock.mockImplementation((command: string) => command) + }) + it('keeps simple CLI commands on the executable launch path', () => { const spec = resolveExternalEditorLaunchSpec('cursor', '/tmp/workspace', { platform: 'darwin' @@ -121,4 +135,123 @@ describe('resolveExternalEditorLaunchSpec', () => { spawnArgs: ['/d', '/s', '/c', 'nvim --clean C:\\workspaces\\orca'] }) }) + + it.each(['code', 'code-insiders'])( + 'opens modern WSL UNC workspaces with %s in the matching VS Code remote', + (editorCommand) => { + expect( + resolveExternalEditorLaunchSpec( + editorCommand, + '\\\\wsl.localhost\\Ubuntu\\home\\aliuq\\project', + { platform: 'win32' } + ).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu', '/home/aliuq/project']) + } + ) + + it.each([ + [ + 'legacy WSL UNC workspace', + '\\\\wsl$\\Debian\\home\\ada\\project', + 'wsl+Debian', + '/home/ada/project' + ], + ['modern WSL distro root', '\\\\wsl.localhost\\Ubuntu', 'wsl+Ubuntu', '/'], + ['legacy WSL distro root', '\\\\wsl$\\Debian', 'wsl+Debian', '/'] + ])('opens a %s in the matching VS Code remote', (_label, pathValue, authority, linuxPath) => { + expect( + resolveExternalEditorLaunchSpec('code', pathValue, { platform: 'win32' }).spawnArgs + ).toEqual(['--remote', authority, linuxPath]) + }) + + it.each([ + 'C:\\Program Files\\Microsoft VS Code\\Code.exe', + 'C:\\Program Files\\Microsoft VS Code Insiders\\Code - Insiders.exe', + 'C:\\Tools\\CODE.CMD', + 'C:\\Tools\\code.bat', + 'C:\\Tools\\code-insiders.cmd' + ])('recognizes the direct Windows VS Code launcher %s', (editorCommand) => { + expect( + resolveExternalEditorLaunchSpec( + editorCommand, + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project', + { platform: 'win32' } + ).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu', '/home/ada/project']) + }) + + it.each([ + ['code', 'C:\\Tools\\CODE.CMD'], + ['code', 'C:\\Tools\\code.bat'], + ['code-insiders', 'C:\\Tools\\code-insiders.cmd'] + ])('recognizes the resolved Windows VS Code launcher %s', (editorCommand, resolvedCommand) => { + resolveCliCommandMock.mockReturnValueOnce(resolvedCommand) + + expect( + resolveExternalEditorLaunchSpec( + editorCommand, + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project', + { platform: 'win32' } + ) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: resolvedCommand, + spawnArgs: ['--remote', 'wsl+Ubuntu', '/home/ada/project'] + }) + }) + + it('preserves spaces in WSL distro and folder arguments', () => { + expect( + resolveExternalEditorLaunchSpec( + 'code', + '\\\\wsl.localhost\\Ubuntu Preview\\home\\Ada Lovelace\\project', + { platform: 'win32' } + ).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu Preview', '/home/Ada Lovelace/project']) + }) + + it.each(['darwin', 'linux'] as const)('keeps WSL-looking paths local on %s', (platform) => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + expect(resolveExternalEditorLaunchSpec('code', pathValue, { platform }).spawnArgs).toEqual([ + pathValue + ]) + }) + + it.each(['C:\\workspaces\\orca', '\\\\server\\share\\project'])( + 'keeps the non-WSL Windows path %s local', + (pathValue) => { + expect( + resolveExternalEditorLaunchSpec('code', pathValue, { platform: 'win32' }).spawnArgs + ).toEqual([pathValue]) + } + ) + + it('does not add VS Code remote arguments to other editors', () => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + + expect( + resolveExternalEditorLaunchSpec('C:\\Tools\\cursor.exe', pathValue, { + platform: 'win32' + }).spawnArgs + ).toEqual(['--new-window', pathValue]) + expect( + resolveExternalEditorLaunchSpec('C:\\Tools\\codium.exe', pathValue, { + platform: 'win32' + }).spawnArgs + ).toEqual([pathValue]) + }) + + it('does not rewrite compound VS Code commands', () => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + + expect( + resolveExternalEditorLaunchSpec('code --reuse-window', pathValue, { platform: 'win32' }) + ).toEqual({ + kind: 'shell', + hideWindowsConsole: true, + spawnCmd: getCmdExePath(), + spawnArgs: ['/d', '/s', '/c', `code --reuse-window ${pathValue}`] + }) + }) }) diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index 8e5c4a0b397..b86ca3e896f 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -1,10 +1,12 @@ import { existsSync } from 'node:fs' import { basename, posix, win32 } from 'node:path' +import { parseWslUncPath } from '../shared/wsl-paths' import { resolveCliCommand } from './codex-cli/command' import { getCmdExePath } from './win32-utils' export const EXTERNAL_EDITOR_CLI_COMMAND = 'code' const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim']) +const VSCODE_REMOTE_EDITORS = new Set(['code', 'code-insiders', 'code - insiders']) export type ExternalEditorLaunchSpec = | { @@ -106,12 +108,24 @@ function shouldShowWindowsConsole( return platform === 'win32' && WINDOWS_CONSOLE_EDITORS.has(getLauncherBaseName(command, options)) } -function buildExecutableArgs(editorCommand: string, pathValue: string): string[] { - if (getLauncherBaseName(editorCommand) === 'cursor') { +function buildExecutableArgs( + editorCommand: string, + pathValue: string, + platform: NodeJS.Platform +): string[] { + const launcherBaseName = getLauncherBaseName(editorCommand) + if (launcherBaseName === 'cursor') { // Why: Cursor can route bare folder launches through the last active // workbench. A new window keeps "Open in Cursor" scoped to this worktree. return ['--new-window', pathValue] } + if (platform === 'win32' && VSCODE_REMOTE_EDITORS.has(launcherBaseName)) { + const wslPath = parseWslUncPath(pathValue) + if (wslPath) { + // Why: VS Code otherwise treats a WSL UNC path as a local Windows folder. + return ['--remote', `wsl+${wslPath.distro}`, wslPath.linuxPath] + } + } return [pathValue] } @@ -156,7 +170,7 @@ export function resolveExternalEditorLaunchSpec( kind: 'executable', hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue) + spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) } } @@ -169,6 +183,6 @@ export function resolveExternalEditorLaunchSpec( kind: 'executable', hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue) + spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) } } diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 4e7b27639f0..166db56079d 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -270,6 +270,35 @@ describe('git remote operations', () => { await expect(gitPush('/repo', false)).rejects.toThrow('fatal: something obscure happened') }) + it('preserves redacted pre-push hook output from failed pushes', async () => { + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('no branch')) + .mockRejectedValueOnce( + new Error( + [ + 'Command failed: git push https://x-access-token:ghp_secret@github.com/acme/repo.git HEAD', + 'husky - pre-push hook failed', + 'eslint found 2 errors', + "error: failed to push some refs to 'https://ghp_tailSecret@github.com/acme/repo.git'" + ].join('\n') + ) + ) + + let caught: Error | undefined + try { + await gitPush('/repo', false) + } catch (error) { + caught = error as Error + } + + expect(caught).toBeInstanceOf(Error) + expect(caught?.message).toContain('husky - pre-push hook failed') + expect(caught?.message).toContain('eslint found 2 errors') + expect(caught?.message).not.toContain('x-access-token') + expect(caught?.message).not.toContain('ghp_secret') + expect(caught?.message).not.toContain('ghp_tailSecret') + }) + it('strips embedded credentials from push error messages', async () => { gitExecFileAsyncMock .mockRejectedValueOnce(new Error('no branch')) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 3aae182432c..c7710858838 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -352,6 +352,29 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('does not wake the default WSL distro for host-only GitLab diagnostics', async () => { + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' })) + }) + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(null, { stdout: 'Logged in to gitlab.com', stderr: '' }) + }) + + await expect( + glabExecFileAsync(['auth', 'status'], { allowDefaultWslFallback: false }) + ).rejects.toThrow('spawn glab ENOENT') + + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(execFileMock).toHaveBeenCalledWith( + 'glab', + ['auth', 'status'], + expect.objectContaining({ cwd: undefined }), + expect.any(Function) + ) + }) + it('still retries idempotent glab transient failures', async () => { execFileMock .mockImplementationOnce((_binary, _args, _options, callback) => { diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index c9009845369..fca0db2ef65 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1480,6 +1480,7 @@ type GlabExecOptions = Omit & { cwd?: string wslDistro?: string idempotent?: boolean + allowDefaultWslFallback?: boolean } /** @@ -1544,6 +1545,7 @@ export async function glabExecFileAsync( resolved.wsl === null && !options.cwd && !options.wslDistro && + options.allowDefaultWslFallback !== false && isHostCommandMissing(err, 'glab') ) { const wslResolved = resolveDefaultWslCli('glab', args) diff --git a/src/main/git/status-wsl-pathspecs.test.ts b/src/main/git/status-wsl-pathspecs.test.ts new file mode 100644 index 00000000000..e802fb72b5b --- /dev/null +++ b/src/main/git/status-wsl-pathspecs.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileAsyncBuffer: vi.fn(), + gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => env, + gitStreamStdout: vi.fn() +})) + +import { + bulkDiscardChanges, + bulkStageFiles, + bulkUnstageFiles, + discardChanges, + stageFile, + unstageFile +} from './status' + +const worktreePath = path.resolve('repo') +const windowsRelativePath = 'tests\\breakgit' +const wslOptions = { wslDistro: 'Ubuntu' } + +describe('WSL git pathspecs', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'tests/breakgit\0', stderr: '' }) + }) + + it('uses POSIX separators for tracked file mutations executed inside WSL', async () => { + await stageFile(worktreePath, windowsRelativePath, wslOptions) + await unstageFile(worktreePath, windowsRelativePath, wslOptions) + await bulkStageFiles(worktreePath, [windowsRelativePath], wslOptions) + await bulkUnstageFiles(worktreePath, [windowsRelativePath], wslOptions) + await discardChanges(worktreePath, windowsRelativePath, wslOptions) + await bulkDiscardChanges(worktreePath, [windowsRelativePath], wslOptions) + + const pathspecs = gitExecFileAsyncMock.mock.calls.flatMap(([args]) => + (args as string[]).filter((arg) => arg.startsWith(':(literal)')) + ) + expect(pathspecs).toEqual(Array(8).fill(':(literal)tests/breakgit')) + }) + + it('uses POSIX separators when discarding untracked files inside WSL', async () => { + const repo = await mkdtemp(path.join(tmpdir(), 'orca-wsl-pathspec-')) + const targetPath = path.resolve(repo, windowsRelativePath) + await mkdir(path.dirname(targetPath), { recursive: true }) + await writeFile(targetPath, 'untracked') + + try { + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('not tracked')) + .mockResolvedValue({ stdout: '', stderr: '' }) + await discardChanges(repo, windowsRelativePath, wslOptions) + + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['ls-files', '--error-unmatch', '--', ':(literal)tests/breakgit'], + ['clean', '-ffdx', '--', ':(literal)tests/breakgit'] + ]) + + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + await bulkDiscardChanges(repo, [windowsRelativePath], wslOptions) + + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['ls-files', '-z', '--', ':(literal)tests/breakgit'], + ['clean', '-ffdx', '--', ':(literal)tests/breakgit'] + ]) + } finally { + await rm(repo, { recursive: true, force: true }) + } + }) + + it('preserves backslashes for host Git where they can be literal filename characters', async () => { + await stageFile(worktreePath, windowsRelativePath) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['add', '--', ':(literal)tests\\breakgit'], { + cwd: worktreePath + }) + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 862c8c9b76b..6ec73ef8a3b 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1837,7 +1837,7 @@ export async function stageFile( clearGitReadInvalidationState() try { await gitExecFileAsync( - ['add', '--', literalPathspec(filePath)], + ['add', '--', literalPathspec(filePath, options)], gitOptionsForWorktree(worktreePath, options) ) } finally { @@ -1855,7 +1855,7 @@ export async function unstageFile( ): Promise { clearGitReadInvalidationState() try { - await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath)], { + await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) }) } finally { @@ -1961,9 +1961,12 @@ export async function discardChanges( let tracked = false try { - await gitExecFileAsync(['ls-files', '--error-unmatch', '--', literalPathspec(filePath)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + ['ls-files', '--error-unmatch', '--', literalPathspec(filePath, options)], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) tracked = true } catch { // File is not tracked by git @@ -1971,7 +1974,7 @@ export async function discardChanges( if (tracked) { await gitExecFileAsync( - ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath)], + ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) } @@ -1991,9 +1994,11 @@ function normalizeGitPathForCompare(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, '') } -function literalPathspec(filePath: string): string { - // Why: source-control selections are concrete paths, not user-authored Git globs. - return `:(literal)${filePath}` +function literalPathspec(filePath: string, options: GitRuntimeOptions): string { + // Why: Windows validation produces backslashes, but Git running inside WSL + // needs POSIX paths. Host paths stay untouched so POSIX filenames remain literal. + const runtimePath = options.wslDistro ? filePath.replace(/\\/g, '/') : filePath + return `:(literal)${runtimePath}` } function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { @@ -2013,7 +2018,7 @@ async function listTrackedPathSpecs( for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) const { stdout } = await gitExecFileAsync( - ['ls-files', '-z', '--', ...chunk.map(literalPathspec)], + ['ls-files', '-z', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], { ...gitOptionsForWorktree(worktreePath, options) } @@ -2038,9 +2043,12 @@ async function cleanUntrackedPaths( const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) if (chunk.length > 0) { // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. - await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk.map(literalPathspec)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + ['clean', '-ffdx', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) } } } @@ -2082,7 +2090,13 @@ export async function bulkDiscardChanges( for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( - ['restore', '--worktree', '--source=HEAD', '--', ...chunk.map(literalPathspec)], + [ + 'restore', + '--worktree', + '--source=HEAD', + '--', + ...chunk.map((filePath) => literalPathspec(filePath, options)) + ], { ...gitOptionsForWorktree(worktreePath, options) } @@ -2125,7 +2139,7 @@ export async function bulkStageFiles( for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( - ['add', '--', ...chunk.map(literalPathspec)], + ['add', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], gitOptionsForWorktree(worktreePath, options) ) } @@ -2149,9 +2163,17 @@ export async function bulkUnstageFiles( try { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['restore', '--staged', '--', ...chunk.map(literalPathspec)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + [ + 'restore', + '--staged', + '--', + ...chunk.map((filePath) => literalPathspec(filePath, options)) + ], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) } } finally { clearGitReadInvalidationState() diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 2138ba0aad0..5363cb56c60 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -122,7 +122,7 @@ import { _resetMergeQueueCacheForTests, __resetTrackedUpstreamBranchCacheForTests } from './client' -import { __resetPRConflictSummaryGitCapabilityCacheForTests } from './conflict-summary' +import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership' describe('checkOrcaStarred', () => { @@ -194,7 +194,7 @@ describe('getPRForBranch', () => { _resetOwnerRepoCache() _resetMergeQueueCacheForTests() __resetTrackedUpstreamBranchCacheForTests() - __resetPRConflictSummaryGitCapabilityCacheForTests() + __resetPRConflictSummaryCachesForTests() resetMergedPRCommitMembershipCacheForTest() }) @@ -2987,11 +2987,16 @@ describe('getPRForBranch', () => { baseRefOid: 'base-oid', headRefOid: 'head-oid' } + // Why a second head OID: identical inputs now hit the summary result + // cache outright; a pushed head re-derives and must still skip the + // unsupported --merge-base retry via the capability cache. + const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } } + const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' } ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) }) .mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) }) - .mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) }) - .mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([pushedBranchLookup]) }) + .mockResolvedValueOnce({ stdout: JSON.stringify(pushedExactLookup) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) @@ -2999,8 +3004,6 @@ describe('getPRForBranch', () => { .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) - .mockResolvedValueOnce({ stdout: '' }) - .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) @@ -3567,6 +3570,7 @@ describe('GitHub GraphQL rate-limit guard', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + __resetPRConflictSummaryCachesForTests() }) it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => { diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 3a308a3adfb..6e4005421a4 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -848,6 +848,43 @@ async function fetchIssueWorkItem( return mapIssueWorkItem(JSON.parse(stdout) as Record) } +// REST /pulls/{n} has requested_reviewers but not latestReviews. When the JSON +// `gh pr view` path fails, still pull review fields from gh so mobile/desktop +// reviewer lists (CodeRabbit COMMENTED, etc.) are not silently empty. +const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews' + +async function fetchPullRequestReviewFields( + number: number, + ownerRepo: OwnerRepo | null, + ghOptions: GhExecOptions +): Promise> { + try { + const args = ownerRepo + ? [ + 'pr', + 'view', + String(number), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + WORK_ITEM_PR_REVIEW_JSON_FIELDS + ] + : ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS] + const { stdout } = await ghExecFileAsync(args, ghOptions) + const item = JSON.parse(stdout) as Record + return { + ...(item.reviewRequests !== undefined + ? { reviewRequests: usersFromUnknown(item.reviewRequests) } + : {}), + ...(item.latestReviews !== undefined + ? { latestReviews: latestReviewsFromUnknown(item.latestReviews) } + : {}) + } + } catch { + return {} + } +} + async function fetchPullRequestWorkItem( repoPath: string, ownerRepo: OwnerRepo | null, @@ -872,24 +909,36 @@ async function fetchPullRequestWorkItem( ) const item = JSON.parse(stdout) as Record const mapped = mapPullRequestWorkItem(item, ownerRepo) + // Why: merge-metadata GraphQL is best-effort. A failure here must not fall + // through to the REST path below — that path drops latestReviews and blanks + // the mobile/desktop reviewer list for bots that only left a review. const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined - const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) - return { - ...mapped, - mergeQueueRequired: mergeMetadata.mergeQueueRequired, - ...(mergeMetadata.autoMergeAllowed !== null - ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } - : {}), - ...(mergeMetadata.mergeMethodSettings - ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } - : {}) + try { + const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions) + return { + ...mapped, + mergeQueueRequired: mergeMetadata.mergeQueueRequired, + ...(mergeMetadata.autoMergeAllowed !== null + ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed } + : {}), + ...(mergeMetadata.mergeMethodSettings + ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings } + : {}) + } + } catch { + return mapped } } catch { const { stdout } = await ghExecFileAsync( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], ghOptions ) - return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo) + const mapped = mapPullRequestWorkItem( + JSON.parse(stdout) as Record, + ownerRepo + ) + const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions) + return { ...mapped, ...reviewFields } } } diff --git a/src/main/github/conflict-summary-cache.ts b/src/main/github/conflict-summary-cache.ts new file mode 100644 index 00000000000..99a2715ce6d --- /dev/null +++ b/src/main/github/conflict-summary-cache.ts @@ -0,0 +1,150 @@ +import type { PRConflictSummary } from '../../shared/types' + +// Why 60s: the hottest coordinator cadences that re-derive a CONFLICTING PR +// (10s mergeability-pending, 2.5s manual-pending) previously each ran a +// network fetch; one fetch per base branch per minute matches the 60s minimum +// background refresh cadence, so the tracked base tip is never staler than the +// PR data around it. Manual refresh intentionally shares the window: GitHub's +// own mergeability recompute is async too, and the card self-corrects within +// a minute. +export const CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS = 60_000 + +// Why bounded: the main process is long-lived; cap the maps so repos and PRs +// that stop refreshing can't accumulate entries forever. +const BASE_OID_CACHE_MAX = 64 +const SUMMARY_CACHE_MAX = 128 + +export type FreshBaseTipResolution = + | { kind: 'resolved'; oid: string } + | { kind: 'fallback-unresolved' } + +type CachedBaseTipResolution = { + oid: string | null + resolvedAt: number +} + +type CachedSummary = { + value: PRConflictSummary | undefined + // Why: a successful summary is a pure function of two immutable commit OIDs + // and never goes stale; a failed derivation depends on which objects exist + // locally, which the next fetch window can change, so it carries an expiry. + staleAt: number | null +} + +const baseOidCache = new Map() +const summaryCache = new Map() +const inFlightBaseOidResolves = new Map>() +const inFlightSummaryDerivations = new Map>() + +// Why: WSL distros have their own git binary, filesystem view, and remote +// access, so cached state must never leak across the host/distro boundary. +export function getConflictSummaryGitRuntimeKey(wslDistro: string | undefined): string { + return wslDistro ? `wsl:${wslDistro}` : 'local:host' +} + +// Why JSON: repo paths and git ref names may contain any printable joiner +// character (git allows `|` in branch names), so a delimiter-joined key could +// alias distinct identities onto one cache entry. +export function buildConflictSummaryCacheKey(...parts: string[]): string { + return JSON.stringify(parts) +} + +export function readFreshBaseTipResolution(baseKey: string): FreshBaseTipResolution | null { + const entry = baseOidCache.get(baseKey) + if (!entry) { + return null + } + if (Date.now() - entry.resolvedAt >= CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS) { + baseOidCache.delete(baseKey) + return null + } + return entry.oid ? { kind: 'resolved', oid: entry.oid } : { kind: 'fallback-unresolved' } +} + +export function storeResolvedBaseTip(baseKey: string, oid: string): void { + setBoundedMapEntry(baseOidCache, baseKey, { oid, resolvedAt: Date.now() }, BASE_OID_CACHE_MAX) +} + +export function rememberUnresolvedBaseTip(baseKey: string): void { + setBoundedMapEntry( + baseOidCache, + baseKey, + { oid: null, resolvedAt: Date.now() }, + BASE_OID_CACHE_MAX + ) +} + +export function readCachedSummary(summaryKey: string): CachedSummary | null { + const entry = summaryCache.get(summaryKey) + if (!entry) { + return null + } + if (entry.staleAt !== null && Date.now() >= entry.staleAt) { + summaryCache.delete(summaryKey) + return null + } + return entry +} + +export function storeCachedSummary(summaryKey: string, value: PRConflictSummary | undefined): void { + setBoundedMapEntry( + summaryCache, + summaryKey, + { + value, + staleAt: value === undefined ? Date.now() + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS : null + }, + SUMMARY_CACHE_MAX + ) +} + +export function dedupeBaseOidResolve( + key: string, + factory: () => Promise +): Promise { + return dedupeInFlight(inFlightBaseOidResolves, key, factory) +} + +export function dedupeSummaryDerivation( + key: string, + factory: () => Promise +): Promise { + return dedupeInFlight(inFlightSummaryDerivations, key, factory) +} + +function dedupeInFlight( + map: Map>, + key: string, + factory: () => Promise +): Promise { + const existing = map.get(key) + if (existing) { + return existing + } + const promise = factory().finally(() => { + map.delete(key) + }) + map.set(key, promise) + return promise +} + +function setBoundedMapEntry(map: Map, key: K, value: V, maxEntries: number): void { + if (map.has(key)) { + map.delete(key) + } + map.set(key, value) + while (map.size > maxEntries) { + const oldest = map.keys().next() + if (oldest.done) { + return + } + map.delete(oldest.value) + } +} + +export function __resetPRConflictSummaryDerivationCachesForTests(): void { + baseOidCache.clear() + summaryCache.clear() + inFlightBaseOidResolves.clear() + inFlightSummaryDerivations.clear() +} diff --git a/src/main/github/conflict-summary.test.ts b/src/main/github/conflict-summary.test.ts new file mode 100644 index 00000000000..a9b5e9aee9a --- /dev/null +++ b/src/main/github/conflict-summary.test.ts @@ -0,0 +1,331 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Why these tests exist: the conflict-summary derivation used to re-run a +// network `git fetch` plus a four-subprocess chain on every PR refresh tick +// (measured ~490 full derivations in 2.1h on one machine). They pin the +// regression contract by counting subprocess spawns: unchanged inputs must +// cost zero subprocesses, and the base fetch must run at most once per +// throttle window. + +const gitExecFileAsyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS } from './conflict-summary-cache' +import { __resetPRConflictSummaryCachesForTests, getPRConflictSummary } from './conflict-summary' + +type GitResult = { stdout: string } +type GitHandler = (argv: string[]) => Promise + +const defaultHandlers: Record = { + fetch: async () => ({ stdout: '' }), + 'rev-parse': async () => ({ stdout: 'base-tip-1\n' }), + 'merge-base': async () => ({ stdout: 'merge-base-1\n' }), + 'rev-list': async () => ({ stdout: '3\n' }), + 'merge-tree': async () => ({ stdout: 'tree-oid\u0000src/conflict.ts\u0000' }) +} + +function mockGitDispatch(overrides: Record = {}): void { + gitExecFileAsyncMock.mockImplementation((argv: string[]) => { + const handler = overrides[argv[0]] ?? defaultHandlers[argv[0]] + if (!handler) { + return Promise.reject(new Error(`unexpected git command: ${argv.join(' ')}`)) + } + return handler(argv) + }) +} + +function spawnCount(command?: string): number { + const calls = gitExecFileAsyncMock.mock.calls + if (!command) { + return calls.length + } + return calls.filter(([argv]) => Array.isArray(argv) && argv[0] === command).length +} + +const expectedSummary = { + baseRef: 'main', + baseCommit: 'base-ti', + commitsBehind: 3, + files: ['src/conflict.ts'] +} + +function deriveSummary(headRefOid = 'head-oid-1', wslDistro?: string) { + return getPRConflictSummary( + '/repo-root', + 'main', + 'github-base-oid', + headRefOid, + wslDistro ? { wslDistro } : {} + ) +} + +describe('getPRConflictSummary caching', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_750_000_000_000) + gitExecFileAsyncMock.mockReset() + __resetPRConflictSummaryCachesForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('runs exactly one fetch and one derivation chain, then zero subprocesses on repeat calls', async () => { + mockGitDispatch() + + const first = await deriveSummary() + expect(first).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount()).toBe(5) + + const second = await deriveSummary() + const third = await deriveSummary() + expect(second).toEqual(expectedSummary) + expect(third).toEqual(expectedSummary) + expect(spawnCount()).toBe(5) + }) + + it('re-derives when headRefOid changes without re-fetching inside the throttle window', async () => { + mockGitDispatch() + + await deriveSummary('head-oid-1') + const afterPush = await deriveSummary('head-oid-2') + + expect(afterPush).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + expect(spawnCount('rev-list')).toBe(2) + expect(spawnCount('merge-tree')).toBe(2) + }) + + it('fetches again after the throttle window and skips derivation when the base tip is unchanged', async () => { + mockGitDispatch() + + await deriveSummary() + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + const afterExpiry = await deriveSummary() + + expect(afterExpiry).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('rev-parse')).toBe(2) + // Why: same (headRefOid, latestBaseOid) pair — the summary cache still hits. + expect(spawnCount('merge-base')).toBe(1) + expect(spawnCount('rev-list')).toBe(1) + expect(spawnCount('merge-tree')).toBe(1) + }) + + it('re-derives after the throttle window when the base tip moved', async () => { + mockGitDispatch() + await deriveSummary() + + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + mockGitDispatch({ + 'rev-parse': async () => ({ stdout: 'base-tip-2\n' }), + 'rev-list': async () => ({ stdout: '5\n' }) + }) + const afterBaseMove = await deriveSummary() + + expect(afterBaseMove).toEqual({ ...expectedSummary, baseCommit: 'base-ti', commitsBehind: 5 }) + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('merge-base')).toBe(2) + expect(spawnCount('rev-list')).toBe(2) + expect(spawnCount('merge-tree')).toBe(2) + }) + + it('dedupes concurrent identical calls onto one in-flight subprocess chain', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const firstCall = deriveSummary() + const secondCall = deriveSummary() + releaseFetch?.({ stdout: '' }) + const [first, second] = await Promise.all([firstCall, secondCall]) + + expect(first).toEqual(expectedSummary) + expect(second).toEqual(expectedSummary) + expect(spawnCount()).toBe(5) + }) + + it('dedupes overlapping derivations after resolving the same live base tip', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const backgroundRefresh = getPRConflictSummary( + '/repo-root', + 'main', + 'older-github-base-oid', + 'head-oid-1', + {} + ) + const manualRefresh = getPRConflictSummary( + '/repo-root', + 'main', + 'newer-github-base-oid', + 'head-oid-1', + {} + ) + releaseFetch?.({ stdout: '' }) + const [backgroundSummary, manualSummary] = await Promise.all([backgroundRefresh, manualRefresh]) + + expect(backgroundSummary).toEqual(expectedSummary) + expect(manualSummary).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(1) + expect(spawnCount('rev-list')).toBe(1) + expect(spawnCount('merge-tree')).toBe(1) + }) + + it('shares one base fetch across concurrent PRs on the same base branch', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }) + }) + + const prA = deriveSummary('head-oid-a') + const prB = deriveSummary('head-oid-b') + releaseFetch?.({ stdout: '' }) + const [summaryA, summaryB] = await Promise.all([prA, prB]) + + expect(summaryA).toEqual(expectedSummary) + expect(summaryB).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('rev-parse')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('keeps concurrent fallback base OIDs isolated per PR when the base ref is unavailable', async () => { + let releaseFetch: ((value: GitResult) => void) | undefined + mockGitDispatch({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve + }), + 'rev-parse': () => Promise.reject(new Error('missing remote-tracking ref')), + 'rev-list': async (argv) => ({ + stdout: argv[2]?.includes('bbbb2222-base') ? '7\n' : '4\n' + }), + 'merge-tree': async (argv) => ({ + stdout: argv.includes('bbbb2222-base') + ? 'tree-oid\u0000src/b.ts\u0000' + : 'tree-oid\u0000src/a.ts\u0000' + }) + }) + + const prA = getPRConflictSummary('/repo-root', 'main', 'aaaa1111-base', 'head-oid-a', {}) + const prB = getPRConflictSummary('/repo-root', 'main', 'bbbb2222-base', 'head-oid-b', {}) + releaseFetch?.({ stdout: '' }) + const [summaryA, summaryB] = await Promise.all([prA, prB]) + + expect(summaryA).toEqual({ + baseRef: 'main', + baseCommit: 'aaaa111', + commitsBehind: 4, + files: ['src/a.ts'] + }) + expect(summaryB).toEqual({ + baseRef: 'main', + baseCommit: 'bbbb222', + commitsBehind: 7, + files: ['src/b.ts'] + }) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('falls back to the local remote-tracking ref when fetch fails', async () => { + mockGitDispatch({ + fetch: () => Promise.reject(new Error('offline')) + }) + + const summary = await deriveSummary() + + expect(summary).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + + // Why: a failed fetch attempt must also be throttled — offline machines + // were the worst case, paying the 10s fetch timeout on every tick. + const repeat = await deriveSummary() + expect(repeat).toEqual(expectedSummary) + expect(spawnCount('fetch')).toBe(1) + expect(spawnCount()).toBe(5) + }) + + it("falls back to GitHub's baseRefOid when fetch and remote-tracking refs are unavailable", async () => { + mockGitDispatch({ + fetch: () => Promise.reject(new Error('offline')), + 'rev-parse': () => Promise.reject(new Error('unknown revision')) + }) + + const summary = await deriveSummary() + + expect(summary).toEqual({ ...expectedSummary, baseCommit: 'github-' }) + expect( + gitExecFileAsyncMock.mock.calls.some( + ([argv]) => argv[0] === 'merge-base' && argv.includes('github-base-oid') + ) + ).toBe(true) + }) + + it('keeps WSL-distro derivations isolated from host derivations', async () => { + mockGitDispatch() + + await deriveSummary() + await deriveSummary('head-oid-1', 'Ubuntu') + + expect(spawnCount('fetch')).toBe(2) + expect( + gitExecFileAsyncMock.mock.calls.filter( + ([argv, options]) => + argv[0] === 'fetch' && (options as { wslDistro?: string })?.wslDistro === 'Ubuntu' + ) + ).toHaveLength(1) + }) + + it('keeps identities distinct when paths or ref names contain a joiner character', async () => { + mockGitDispatch() + + // Why: these two calls alias under naive pipe-joined keys — the segment + // boundary shifts between repoPath and baseRefName. + await getPRConflictSummary('/repo|x', 'main', 'github-base-oid', 'head-oid-1', {}) + await getPRConflictSummary('/repo', 'x|main', 'github-base-oid', 'head-oid-1', {}) + + expect(spawnCount('fetch')).toBe(2) + expect(spawnCount('merge-base')).toBe(2) + }) + + it('caches failed derivations only until the throttle window expires', async () => { + mockGitDispatch({ + 'merge-base': () => Promise.reject(new Error('bad object')) + }) + + await expect(deriveSummary()).resolves.toBeUndefined() + const failureSpawns = spawnCount() + + // Within the window the failure is negative-cached: zero subprocesses. + await expect(deriveSummary()).resolves.toBeUndefined() + expect(spawnCount()).toBe(failureSpawns) + + // After expiry the derivation retries (a fetch may have brought objects in). + vi.setSystemTime(1_750_000_000_000 + CONFLICT_SUMMARY_BASE_FETCH_WINDOW_MS + 1_000) + mockGitDispatch() + await expect(deriveSummary()).resolves.toEqual(expectedSummary) + expect(spawnCount('merge-base')).toBe(2) + }) +}) diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index ce0c4b361b7..9644555048a 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -1,5 +1,17 @@ import type { PRConflictSummary } from '../../shared/types' import { gitExecFileAsync } from '../git/runner' +import { + __resetPRConflictSummaryDerivationCachesForTests, + buildConflictSummaryCacheKey, + dedupeBaseOidResolve, + dedupeSummaryDerivation, + getConflictSummaryGitRuntimeKey, + readCachedSummary, + readFreshBaseTipResolution, + rememberUnresolvedBaseTip, + storeResolvedBaseTip, + storeCachedSummary +} from './conflict-summary-cache' type LocalGitExecOptions = { wslDistro?: string @@ -7,8 +19,9 @@ type LocalGitExecOptions = { const mergeTreeMergeBaseUnsupportedRuntimes = new Set() -export function __resetPRConflictSummaryGitCapabilityCacheForTests(): void { +export function __resetPRConflictSummaryCachesForTests(): void { mergeTreeMergeBaseUnsupportedRuntimes.clear() + __resetPRConflictSummaryDerivationCachesForTests() } export async function getPRConflictSummary( @@ -18,45 +31,122 @@ export async function getPRConflictSummary( headRefOid: string, localGitOptions: LocalGitExecOptions = {} ): Promise { - try { - // Why: the renderer only needs a read-only merge-conflict snapshot. We - // derive it from local git state so the PR card can show GitHub-style - // detail without spending additional gh API calls on every refresh. We use - // GitHub's head OID directly because the registered repo path may not have - // a matching local branch name for the PR head. For the base side, prefer a - // freshly-fetched remote-tracking ref so Orca matches GitHub's portal, - // which compares against the latest base branch tip rather than the PR's - // older pinned baseRefOid snapshot. - const latestBaseOid = await resolveLatestBaseOid( + // Why: the renderer only needs a read-only merge-conflict snapshot. We + // derive it from local git state so the PR card can show GitHub-style + // detail without spending additional gh API calls on every refresh. We use + // GitHub's head OID directly because the registered repo path may not have + // a matching local branch name for the PR head. For the base side, prefer a + // freshly-fetched remote-tracking ref so Orca matches GitHub's portal, + // which compares against the latest base branch tip rather than the PR's + // older pinned baseRefOid snapshot. + const latestBaseOid = await resolveLatestBaseOidThrottled( + repoPath, + baseRefName, + baseRefOid, + localGitOptions + ) + // Why: the summary is a pure function of the two commit OIDs, so a key hit + // can skip the whole merge-base/rev-list/merge-tree subprocess chain. + const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) + const summaryKey = buildConflictSummaryCacheKey( + runtimeKey, + repoPath, + baseRefName, + headRefOid, + latestBaseOid + ) + const cached = readCachedSummary(summaryKey) + if (cached) { + return cached.value + } + + // Why: different GitHub reads can report different pinned baseRefOid values + // while still resolving to the same live base tip; dedupe the expensive + // local derivation on the actual summary identity. + return dedupeSummaryDerivation(summaryKey, () => + derivePRConflictSummary( repoPath, baseRefName, - baseRefOid, + headRefOid, + latestBaseOid, + summaryKey, localGitOptions ) + ) +} + +async function derivePRConflictSummary( + repoPath: string, + baseRefName: string, + headRefOid: string, + latestBaseOid: string, + summaryKey: string, + localGitOptions: LocalGitExecOptions +): Promise { + const cached = readCachedSummary(summaryKey) + if (cached) { + return cached.value + } + + try { const mergeBase = await resolveMergeBase(repoPath, headRefOid, latestBaseOid, localGitOptions) const [commitsBehind, files] = await Promise.all([ countCommits(repoPath, `${headRefOid}..${latestBaseOid}`, localGitOptions), loadConflictingFiles(repoPath, mergeBase, headRefOid, latestBaseOid, localGitOptions) ]) - return { + const summary = { baseRef: baseRefName, baseCommit: latestBaseOid.slice(0, 7), commitsBehind, files, ...(files.length === 0 ? { localMergeState: 'clean' as const } : {}) } + storeCachedSummary(summaryKey, summary) + return summary } catch { + storeCachedSummary(summaryKey, undefined) return undefined } } -async function resolveLatestBaseOid( +async function resolveLatestBaseOidThrottled( repoPath: string, baseRefName: string, fallbackBaseOid: string, localGitOptions: LocalGitExecOptions ): Promise { + const runtimeKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) + const baseKey = buildConflictSummaryCacheKey(runtimeKey, repoPath, baseRefName) + const cachedResolution = readFreshBaseTipResolution(baseKey) + if (cachedResolution) { + return cachedResolution.kind === 'resolved' ? cachedResolution.oid : fallbackBaseOid + } + return dedupeBaseOidResolve(baseKey, async () => { + // Why re-check inside the dedupe slot: a sibling caller may have finished + // resolving between our cache read and this factory starting. + const freshResolution = readFreshBaseTipResolution(baseKey) + if (freshResolution) { + return freshResolution + } + const oid = await resolveLatestBaseOid(repoPath, baseRefName, localGitOptions) + if (oid) { + storeResolvedBaseTip(baseKey, oid) + return { kind: 'resolved', oid } + } + // Why cache the unresolved probe, not the caller fallback: the fetch + // attempt is branch-wide expensive work, but GitHub's baseRefOid is + // PR-specific and must not leak to sibling PRs on the same base branch. + rememberUnresolvedBaseTip(baseKey) + return { kind: 'fallback-unresolved' } + }).then((resolution) => (resolution.kind === 'resolved' ? resolution.oid : fallbackBaseOid)) +} + +async function resolveLatestBaseOid( + repoPath: string, + baseRefName: string, + localGitOptions: LocalGitExecOptions +): Promise { const remoteName = 'origin' try { @@ -88,7 +178,7 @@ async function resolveLatestBaseOid( } } - return fallbackBaseOid + return null } async function resolveMergeBase( @@ -123,7 +213,7 @@ async function loadConflictingFiles( baseOid: string, localGitOptions: LocalGitExecOptions ): Promise { - const capabilityKey = getMergeTreeCapabilityKey(localGitOptions) + const capabilityKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) const modernArgs = [ 'merge-tree', '--write-tree', @@ -193,10 +283,6 @@ async function loadConflictingFilesWithLegacyMergeTree( } } -function getMergeTreeCapabilityKey(localGitOptions: LocalGitExecOptions): string { - return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'local:host' -} - function parseMergeTreeNameOnlyOutput(stdout: string): string[] { const entries = stdout.split('\0').filter(Boolean) if (entries.length === 0) { diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 76bd2460307..3e53d0116d1 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -3,14 +3,19 @@ request timestamps, and follow-up scheduling against shared module state. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { GitHubPRRefreshCandidate, PRInfo } from '../../shared/types' -const { sendMock, getAllWebContentsMock, getPRForBranchOutcomeMock, getRateLimitMock } = vi.hoisted( - () => ({ - sendMock: vi.fn(), - getAllWebContentsMock: vi.fn(), - getPRForBranchOutcomeMock: vi.fn(), - getRateLimitMock: vi.fn() - }) -) +const { + sendMock, + getAllWebContentsMock, + getPRForBranchOutcomeMock, + getRateLimitMock, + rateLimitGuardMock +} = vi.hoisted(() => ({ + sendMock: vi.fn(), + getAllWebContentsMock: vi.fn(), + getPRForBranchOutcomeMock: vi.fn(), + getRateLimitMock: vi.fn(), + rateLimitGuardMock: vi.fn() +})) vi.mock('electron', () => ({ webContents: { @@ -25,7 +30,7 @@ vi.mock('./client', () => ({ vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, noteRateLimitSpend: vi.fn(), - rateLimitGuard: vi.fn(() => ({ blocked: false })) + rateLimitGuard: rateLimitGuardMock })) function makeCandidate( @@ -80,6 +85,8 @@ describe('pr-refresh-coordinator', () => { getAllWebContentsMock.mockReset() getPRForBranchOutcomeMock.mockReset() getRateLimitMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) getAllWebContentsMock.mockReturnValue([ { id: 1, @@ -505,13 +512,45 @@ describe('pr-refresh-coordinator', () => { expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(5) }) + it('proceeds with background refreshes when the budget probe fails (fail open)', async () => { + const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') + // Why: regression for #7553 — GHES with rate limiting disabled 404s every + // probe; an unreadable budget must not pause refreshes. + getRateLimitMock.mockResolvedValue({ + ok: false, + error: 'HTTP 404: Rate limiting is not enabled.' + }) + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'no-pr', + fetchedAt: Date.now() + }) + + enqueuePRRefresh(makeCandidate(), 'active', 80, 1) + + await vi.advanceTimersByTimeAsync(0) + + expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(1) + const pausedEvents = sendMock.mock.calls + .map(([, event]) => event) + .filter((event) => event.status === 'paused' && event.skippedReason === 'rate-limit') + expect(pausedEvents).toHaveLength(0) + }) + it('does not consume active burst slots for rate-limit pauses', async () => { const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') - getRateLimitMock - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValue({ ok: true }) + // Why: keyed on drained items (one getRateLimit call each) rather than + // guard-call counts, so the guard's per-bucket evaluation order stays free + // to change without breaking the slot-accounting assertion. + let drainedItems = 0 + getRateLimitMock.mockImplementation(async () => { + drainedItems += 1 + return { ok: true } + }) + rateLimitGuardMock.mockImplementation(() => + drainedItems <= 3 + ? { blocked: true, remaining: 0, limit: 5000, resetAt: 61 } + : { blocked: false } + ) getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'upstream-error', errorType: 'unknown', diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index a2576e4805e..8065fbb8145 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -708,20 +708,11 @@ async function drainQueue(): Promise { ) if (isBackground(next.reason)) { - const rateLimit = await getRateLimit() - if (!rateLimit.ok) { - const retryAt = Date.now() + 30_000 - queue.set(next.key, { ...next, dueAt: retryAt }) - broadcast({ - aliases, - reason: next.reason, - status: 'paused', - pausedUntil: retryAt, - skippedReason: 'rate-limit' - }) - scheduleDrain(30_000) - continue - } + // Why: the probe only warms rateLimitGuard's cached snapshot, so a + // failed probe must fail open — GHES with rate limiting disabled 404s + // every probe (#7553). A genuinely broken gh surfaces per-key as typed + // fetch outcomes instead (visible keys additionally back off). + await getRateLimit() const buckets = backgroundRefreshBuckets() const blockedGuard = buckets .map((bucket) => rateLimitGuard(bucket)) diff --git a/src/main/github/rate-limit.test.ts b/src/main/github/rate-limit.test.ts new file mode 100644 index 00000000000..ab1373a70f0 --- /dev/null +++ b/src/main/github/rate-limit.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ + ghExecFileAsync: ghExecFileAsyncMock +})) + +vi.mock('./gh-utils', () => ({ + acquire: vi.fn(), + release: vi.fn() +})) + +import { getRateLimit, rateLimitGuard, _resetRateLimitCache } from './rate-limit' + +const PAYLOAD = JSON.stringify({ + resources: { + core: { limit: 5000, remaining: 4200, reset: 1_700_000_000 }, + search: { limit: 30, remaining: 28, reset: 1_700_000_000 }, + graphql: { limit: 5000, remaining: 4900, reset: 1_700_000_000 } + } +}) + +describe('getRateLimit', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + ghExecFileAsyncMock.mockReset() + _resetRateLimitCache() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('parses bucket counts from gh api rate_limit', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD }) + + const result = await getRateLimit() + + expect(result).toEqual({ + ok: true, + snapshot: { + core: { limit: 5000, remaining: 4200, resetAt: 1_700_000_000 }, + search: { limit: 30, remaining: 28, resetAt: 1_700_000_000 }, + graphql: { limit: 5000, remaining: 4900, resetAt: 1_700_000_000 }, + fetchedAt: 1_000 + } + }) + }) + + it('caches a failed probe for the TTL instead of re-spawning gh', async () => { + // Why: regression for #7553 — GHES with rate limiting disabled fails every + // probe. Refreshes fail open past the failure, so without a negative cache + // each queued refresh would spawn its own doomed gh subprocess. + ghExecFileAsyncMock.mockRejectedValue(new Error('HTTP 404: Rate limiting is not enabled.')) + + const first = await getRateLimit() + const second = await getRateLimit() + + expect(first).toEqual({ ok: false, error: 'HTTP 404: Rate limiting is not enabled.' }) + expect(second).toEqual(first) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('re-probes after the failure TTL elapses and recovers on success', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom')) + ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD }) + + expect((await getRateLimit()).ok).toBe(false) + expect((await getRateLimit()).ok).toBe(false) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + + vi.setSystemTime(1_000 + 30_000) + + expect((await getRateLimit()).ok).toBe(true) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('bypasses the failure cache when forced', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('boom')) + ghExecFileAsyncMock.mockResolvedValue({ stdout: PAYLOAD }) + + expect((await getRateLimit()).ok).toBe(false) + expect((await getRateLimit({ force: true })).ok).toBe(true) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('rateLimitGuard', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + ghExecFileAsyncMock.mockReset() + _resetRateLimitCache() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function cacheExhaustedSnapshot(resetEpochSeconds: number): Promise { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + resources: { + core: { limit: 5000, remaining: 3, reset: resetEpochSeconds }, + search: { limit: 30, remaining: 28, reset: resetEpochSeconds }, + graphql: { limit: 5000, remaining: 4900, reset: resetEpochSeconds } + } + }) + }) + expect((await getRateLimit()).ok).toBe(true) + } + + it('blocks below the floor while resetAt is in the future', async () => { + await cacheExhaustedSnapshot(61) + + expect(rateLimitGuard('core')).toEqual({ + blocked: true, + remaining: 3, + limit: 5000, + resetAt: 61 + }) + }) + + it('fails open once the blocking bucket resetAt has elapsed', async () => { + // Why: probes can fail indefinitely (sleep past resetAt, network blip), so + // a stale exhausted snapshot must not block once GitHub has already reset + // the budget — a past-due pause would spin the coordinator drain loop. + await cacheExhaustedSnapshot(61) + + vi.setSystemTime(61_000) + + expect(rateLimitGuard('core')).toEqual({ blocked: false }) + }) +}) diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index ad140422488..f4a1ba56248 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -35,6 +35,11 @@ import { // 1-per-second "is it safe now?" polling pattern UIs tend to fall into. const RATE_LIMIT_CACHE_TTL_MS = 30_000 let cached: GitHubRateLimitSnapshot | null = null +// Why: failed probes are cached for the same TTL as successes. Refreshes fail +// open past a failed probe, so on a host that can never report a budget (GHES +// with rate limiting disabled 404s every probe) an uncached failure would cost +// a gh subprocess per queued refresh. +let probeFailure: { at: number; error: string } | null = null type GhRateLimitPayload = { resources?: { @@ -66,6 +71,7 @@ function parseBucket( /** @internal — test-only */ export function _resetRateLimitCache(): void { cached = null + probeFailure = null } // Why: hard-stop thresholds for the circuit breaker. We refuse to issue a new @@ -196,6 +202,9 @@ export async function getRateLimit(options?: { force?: boolean }): Promise { fetchedAt: Date.now() } cached = snapshot + probeFailure = null return { ok: true, snapshot } } catch (err) { const message = err instanceof Error ? err.message : String(err) + probeFailure = { at: Date.now(), error: message } return { ok: false, error: message } } finally { release() diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index 21747de6a26..a090ec7963b 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -245,6 +245,9 @@ describe('gitlab client — MR operations', () => { hosts: ['gitlab.com'], activeHost: 'gitlab.com' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { + allowDefaultWslFallback: false + }) }) }) diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index ec4a178ea64..b6b32dfb8c3 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -97,7 +97,10 @@ export async function diagnoseAuth(): Promise { ? 'GLAB_TOKEN' : null try { - const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) + // Why: a host-global diagnostic must not wake an unrelated default WSL distro. + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { + allowDefaultWslFallback: false + }) const output = `${stdout}\n${stderr}` const hosts = parseGlabAuthStatusHosts(output) return { diff --git a/src/main/index.ts b/src/main/index.ts index 251ea69e67d..0eb2d9d9a4f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,6 +14,7 @@ import { getCanonicalUserDataPath, migrateMobilePairingDataToCanonicalUserDataPath } from './persistence' +import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { applyAppIcon } from './app-icon' import { StatsCollector, initStatsPath } from './stats/collector' import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' @@ -27,7 +28,8 @@ import { registerCoreHandlers } from './ipc/register-core-handlers' import { initObservability, shutdownObservability } from './observability' import { startSpan } from './observability/tracer' import { registerMobileHandlers } from './ipc/mobile' -import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce } from './telemetry/client' +import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client' +import { classifyError } from './telemetry/classify-error' import { runManagedHookInstallers } from './agent-hooks/install-telemetry' import { isAgentStatusHooksEnabled, @@ -109,8 +111,9 @@ import { ensureAutoUpdaterConfigured } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { createSystemTray, destroySystemTray } from './tray/system-tray' +import { createSystemTray, destroySystemTray, setTrayAttention } from './tray/system-tray' import { focusExistingMainWindow } from './window/focus-existing-window' +import { notifyMainWindowBecameVisible } from './window/main-window-visibility' import { CodexAccountService } from './codex-accounts/service' import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' import { @@ -119,6 +122,7 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' +import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import { @@ -151,6 +155,7 @@ import { AutomationService } from './automations/service' import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' import { AgentAwakeService } from './agent-awake-service' +import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { getCrashBreadcrumbSnapshot, recordCoalescedCrashBreadcrumb, @@ -212,6 +217,7 @@ let starNag: StarNagService | null = null let agentAwakeService: AgentAwakeService | null = null let crashReports: CrashReportStore | null = null let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null +let unsubscribeSystemResumeBroadcast: (() => void) | null = null let watcherShutdownPromise: Promise | null = null let watcherShutdownDone = false let automations: AutomationService | null = null @@ -578,6 +584,7 @@ if (hasSingleInstanceLock) { // orca-dev in dev mode) but before app.setName('Orca') inside whenReady // (which would change the resolved path on case-sensitive filesystems). initDataPath() + initOrcaProfilePaths() // Why: same timing constraint as initDataPath — capture the userData path // before app.setName changes it. See persistence.ts:20-28. initStatsPath() @@ -641,7 +648,16 @@ function startDesktopFirstWindowStartupServices(): Promise { logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) }, onDaemonError: (error) => { - console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error) + // Why: daemon startup failure silently dropped terminals onto the local + // provider (killed on quit, no persistence) — the v1.4.129-rc.1 outage was + // invisible in the field. Log loudly (error.message carries the captured + // daemon stderr tail from the fork) and emit a low-cardinality telemetry + // signal so a fleet-wide daemon failure is observable without a bug report. + const reason = error instanceof Error ? error.message : String(error) + console.error( + `[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}` + ) + track('daemon_start_failed', classifyError(error)) }, onAgentHookServerError: (error) => { // Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar @@ -679,13 +695,22 @@ async function startServeAgentHookServer(): Promise { function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) + const hookTarget = + target?.runtime === 'wsl' + ? { + runtime: 'wsl' as const, + wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() + } + : target const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) try { // Why: launch prep is reachable after startup via PTY/runtime paths; honor // the persisted off switch so those launches cannot reinstall removed hooks. const status = hooksEnabled - ? codexHookService.install() - : codexHookService.refreshRuntimeUserHooks() + ? (codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget) ?? + codexHookService.install()) + : (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ?? + codexHookService.refreshRuntimeUserHooks()) if (status.state === 'error') { console.warn( `[codex-hook-service] failed to ${ @@ -984,6 +1009,15 @@ function openMainWindow(): BrowserWindow { window.on('restore', resumeSyntheticTitleSpinnerTimer) window.on('hide', stopSyntheticTitleSpinnerTimer) window.on('minimize', stopSyntheticTitleSpinnerTimer) + // Why: visibility-gated main-process pollers (SSH port scanner) park while + // hidden and rely on this signal to resume; re-wired per window because + // macOS dock re-activation recreates the BrowserWindow. + window.on('show', notifyMainWindowBecameVisible) + window.on('restore', notifyMainWindowBecameVisible) + // Why: showing/restoring the window means the user is back, so clear the + // tray attention dot set while it was minimized/hidden (see notifications.ts). + window.on('show', () => setTrayAttention(false)) + window.on('restore', () => setTrayAttention(false)) agentHookServer.setListener( ({ paneKey, @@ -1624,7 +1658,8 @@ app.whenReady().then(async () => { electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) app.setName(devInstanceIdentity.name) - store = new Store() + const activeOrcaProfile = ensureActiveOrcaProfile() + store = new Store({ dataFile: activeOrcaProfile.dataFile }) logStartupMilestone('store-loaded') // Why: must run before ClaudeRuntimeAuthService's constructor sync — a Claude // CLI that survived the restart inside the daemon still holds the current @@ -1652,7 +1687,11 @@ app.whenReady().then(async () => { } // Why: browser sessions are used by desktop webviews and runtime profile // commands, so initialize them at app startup instead of a renderer IPC path. - initializeBrowserSessionsForApp() + initializeBrowserSessionsForApp({ + orcaProfileId: activeOrcaProfile.profile.id, + profileDirectory: activeOrcaProfile.profileDirectory + }) + unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() agentAwakeService = new AgentAwakeService() agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) // Why: disk-hydrated status rows are UI continuity only. The service starts @@ -2018,7 +2057,9 @@ app.whenReady().then(async () => { // a random OS-assigned port — breaking deterministic mobile pairing/repro // scripts against the dev instance. Pin the first dev instance to 6769 so // ws://127.0.0.1:6769 is stable; a second dev instance still falls back via - // ws-transport's EADDRINUSE handler. + // ws-transport's EADDRINUSE handler. Note: once an instance has ever fallen + // back, the persisted fallback port is re-bound in preference to 6769 + // (STA-1511) until mobile-ws-fallback-port.json is removed from userData. const devWsPort = is.dev && !isE2E ? 6769 : undefined let serveOptions: ServeOptions | null = null try { @@ -2170,6 +2211,8 @@ app.whenReady().then(async () => { app.on('before-quit', () => { isQuitting = true + unsubscribeSystemResumeBroadcast?.() + unsubscribeSystemResumeBroadcast = null unsubscribeAgentAwakeStatusChanges?.() unsubscribeAgentAwakeStatusChanges = null agentAwakeService?.dispose() diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 9cc8060eee2..ab1c7d77958 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -6,6 +6,7 @@ import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' const mocks = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn(), scanRemoteAiVaultSessions: vi.fn(), + scanRuntimeAiVaultSessions: vi.fn(), getAiVaultWslHomeDirs: vi.fn(), getSshFilesystemProvider: vi.fn(), getActiveSshAiVaultHostInfo: vi.fn(), @@ -41,7 +42,7 @@ vi.mock('./ssh', () => ({ getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos })) -const { _internals } = await import('./ai-vault') +const { _internals, registerAiVaultHandlers } = await import('./ai-vault') const provider = {} as IFilesystemProvider @@ -52,6 +53,9 @@ beforeEach(() => { mocks.scanRemoteAiVaultSessions.mockResolvedValue( result([session('ssh:dev-box', 'remote-session')]) ) + mocks.scanRuntimeAiVaultSessions.mockResolvedValue( + result([session('runtime:remote-server', 'runtime-session')]) + ) mocks.getSshFilesystemProvider.mockReturnValue(provider) mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box')) mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')]) @@ -96,6 +100,77 @@ describe('listAiVaultSessions host routing', () => { expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) }) + it('merges paired runtime servers for all hosts', async () => { + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => [ + { + environmentId: 'remote-server', + executionHostId: 'runtime:remote-server' + } + ], + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + + const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith( + 'remote-server', + { + executionHostScope: 'runtime:remote-server' + }, + expect.objectContaining({ timeoutMs: expect.any(Number) }) + ) + expect(result.sessions.map((entry) => entry.executionHostId)).toEqual([ + 'runtime:remote-server', + 'ssh:dev-box', + 'local' + ]) + }) + + it('keeps local and SSH results when runtime host discovery fails', async () => { + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => { + throw new Error('runtime store is invalid') + }, + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + + const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRuntimeAiVaultSessions).not.toHaveBeenCalled() + expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) + expect(result.issues).toEqual([ + expect.objectContaining({ + agent: 'codex', + path: 'runtime environments', + message: 'runtime store is invalid' + }) + ]) + }) + + it('keeps direct runtime host scans on the normal runtime timeout', async () => { + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => [], + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + + await _internals.listAiVaultSessions({ + executionHostScope: 'runtime:remote-server', + force: true + }) + + expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledWith( + 'remote-server', + { + executionHostScope: 'runtime:remote-server', + force: true + }, + {} + ) + }) + it('returns a scan issue for a disconnected SSH target', async () => { mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null) mocks.getSshFilesystemProvider.mockReturnValue(undefined) @@ -153,11 +228,17 @@ function session( codexHome: null, createdAt: null, updatedAt: - sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z', + sessionId === 'runtime-session' + ? '2026-07-04T03:00:00.000Z' + : sessionId === 'remote-session' + ? '2026-07-04T02:00:00.000Z' + : '2026-07-04T01:00:00.000Z', modifiedAt: '2026-07-04T00:00:00.000Z', messageCount: 1, totalTokens: 0, previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, resumeCommand: `codex resume ${sessionId}` } } diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 4551379afde..94a8de0c475 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -2,17 +2,14 @@ import { app, ipcMain } from 'electron' import { join } from 'node:path' import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner' import { scanAiVaultSessions } from '../ai-vault/session-scanner' -import { sessionSortTime } from '../ai-vault/session-scanner-accumulator' +import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' -import type { - AiVaultListArgs, - AiVaultListResult, - AiVaultScanIssue -} from '../../shared/ai-vault-types' +import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostScope, parseExecutionHostId, + toRuntimeExecutionHostId, toSshExecutionHostId, type ExecutionHostScope } from '../../shared/execution-host' @@ -23,9 +20,20 @@ import { import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh' const AI_VAULT_CACHE_TTL_MS = 15_000 +const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 type AiVaultHandlerOptions = { getAdditionalCodexHomePaths?: () => readonly string[] + getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] + scanRuntimeAiVaultSessions?: ( + environmentId: string, + args: AiVaultListArgs, + options?: RuntimeAiVaultScanOptions + ) => Promise +} + +type RuntimeAiVaultScanOptions = { + timeoutMs?: number } type CachedAiVaultList = { @@ -34,6 +42,11 @@ type CachedAiVaultList = { expiresAt: number } +type RuntimeAiVaultHostInfo = { + environmentId: string + executionHostId: `runtime:${string}` +} + let cachedList: CachedAiVaultList | null = null let inflightList: Promise | null = null let inflightKey: string | null = null @@ -89,12 +102,20 @@ async function scanAiVaultSessionsByHostScope( } if (executionHostScope === 'all') { + const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() + const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] return mergeAiVaultListResults( await Promise.all([ scanLocalAiVaultSessions(args), ...getActiveSshAiVaultHostInfos().map((hostInfo) => scanSshAiVaultSessions(hostInfo.targetId, args) - ) + ), + ...runtimeHosts.hostInfos.map((hostInfo) => + scanRuntimeAiVaultSessions(hostInfo, args, { + timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS + }) + ), + ...runtimeResults ]), args?.limit ) @@ -104,19 +125,88 @@ async function scanAiVaultSessionsByHostScope( if (parsed?.kind === 'ssh') { return scanSshAiVaultSessions(parsed.targetId, args) } - - return { - sessions: [], - issues: [ + if (parsed?.kind === 'runtime') { + return scanRuntimeAiVaultSessions( { - executionHostId: executionHostScope, - agent: 'codex', - path: executionHostScope, - message: 'Agent Session History is not available for this execution host.' - } - ], - scannedAt: new Date().toISOString() + environmentId: parsed.environmentId, + executionHostId: toRuntimeExecutionHostId(parsed.environmentId) + }, + args + ) } + + return aiVaultScanIssueResult({ + executionHostId: executionHostScope, + path: executionHostScope, + message: 'Agent Session History is not available for this execution host.' + }) +} + +function getActiveRuntimeAiVaultHostInfos(): readonly RuntimeAiVaultHostInfo[] { + return handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? [] +} + +function getActiveRuntimeAiVaultHostInfosResult(): { + hostInfos: readonly RuntimeAiVaultHostInfo[] + issue?: AiVaultListResult +} { + try { + return { hostInfos: getActiveRuntimeAiVaultHostInfos() } + } catch (error) { + return { + hostInfos: [], + issue: runtimeHostDiscoveryIssueResult( + error instanceof Error ? error.message : 'Runtime hosts are unavailable.' + ) + } + } +} + +async function scanRuntimeAiVaultSessions( + hostInfo: RuntimeAiVaultHostInfo, + args?: AiVaultListArgs, + options: RuntimeAiVaultScanOptions = {} +): Promise { + const scanner = handlerOptions.scanRuntimeAiVaultSessions + if (!scanner) { + return runtimeScanIssueResult( + hostInfo, + 'Agent Session History is not available for this execution host.' + ) + } + const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId } + if (args?.limit !== undefined) { + scanArgs.limit = args.limit + } + if (args?.force !== undefined) { + scanArgs.force = args.force + } + if (args?.scopePaths !== undefined) { + scanArgs.scopePaths = args.scopePaths + } + try { + return await scanner(hostInfo.environmentId, scanArgs, options) + } catch (error) { + return runtimeScanIssueResult( + hostInfo, + error instanceof Error ? error.message : 'Remote Orca server is unavailable.' + ) + } +} + +function runtimeScanIssueResult( + hostInfo: RuntimeAiVaultHostInfo, + message: string +): AiVaultListResult { + return aiVaultScanIssueResult({ + executionHostId: hostInfo.executionHostId, + path: hostInfo.environmentId, + message + }) +} + +function runtimeHostDiscoveryIssueResult(message: string): AiVaultListResult { + return aiVaultScanIssueResult({ path: 'runtime environments', message }) } async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise { @@ -161,40 +251,11 @@ function sshScanIssueResult(args: { targetId: string message: string }): AiVaultListResult { - return { - sessions: [], - issues: [ - { - executionHostId: args.executionHostId, - agent: 'codex', - path: args.targetId, - message: args.message - } - ], - scannedAt: new Date().toISOString() - } -} - -function mergeAiVaultListResults( - results: readonly AiVaultListResult[], - rawLimit: number | undefined -): AiVaultListResult { - const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000 - const byId = new Map() - const issues: AiVaultScanIssue[] = [] - for (const result of results) { - for (const session of result.sessions) { - byId.set(session.id, session) - } - issues.push(...result.issues) - } - return { - sessions: [...byId.values()] - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - .slice(0, limit), - issues, - scannedAt: new Date().toISOString() - } + return aiVaultScanIssueResult({ + executionHostId: args.executionHostId, + path: args.targetId, + message: args.message + }) } export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index e1149d1b6d1..1eb9bbb17a2 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -5,7 +6,7 @@ const { appExitMock, appQuitMock, appRelaunchMock, - execFileMock, + spawnMock, destroySystemTrayMock, showOpenDialogMock, grantFloatingWorkspaceDirectoryMock @@ -14,16 +15,53 @@ const { appExitMock: vi.fn(), appQuitMock: vi.fn(), appRelaunchMock: vi.fn(), - execFileMock: vi.fn(), + spawnMock: vi.fn(), destroySystemTrayMock: vi.fn(), showOpenDialogMock: vi.fn(), grantFloatingWorkspaceDirectoryMock: vi.fn() })) vi.mock('node:child_process', () => ({ - execFile: execFileMock + spawn: spawnMock })) +// Fakes the detached `spawn` child: a stdout EventEmitter plus close/error +// events, so tests drive the async command lifecycle readCommandStdout expects. +function createFakeSpawnChild(options: { + stdout?: string + code?: number + error?: Error + pid?: number + hang?: boolean +}): EventEmitter & { pid: number; kill: ReturnType; stdout: EventEmitter } { + const { stdout, code = 0, error, pid = 4242, hang = false } = options + const child = new EventEmitter() as EventEmitter & { + pid: number + kill: ReturnType + stdout: EventEmitter & { setEncoding: ReturnType } + } + child.pid = pid + child.kill = vi.fn() + const stdoutStream = new EventEmitter() as EventEmitter & { + setEncoding: ReturnType + } + stdoutStream.setEncoding = vi.fn() + child.stdout = stdoutStream + if (!hang) { + queueMicrotask(() => { + if (error) { + child.emit('error', error) + return + } + if (stdout !== undefined) { + stdoutStream.emit('data', stdout) + } + child.emit('close', code) + }) + } + return child +} + vi.mock('electron', () => ({ app: { exit: appExitMock, @@ -63,6 +101,9 @@ import { registerAppHandlers } from './app' describe('registerAppHandlers', () => { const originalPlatform = process.platform + // Why: readCommandStdout process-group-kills on timeout; stub the real signal + // so a fake child pid can never target a live process group during tests. + let processKillSpy: ReturnType beforeEach(() => { vi.useFakeTimers() @@ -70,14 +111,16 @@ describe('registerAppHandlers', () => { appExitMock.mockReset() appQuitMock.mockReset() appRelaunchMock.mockReset() - execFileMock.mockReset() + spawnMock.mockReset() destroySystemTrayMock.mockReset() showOpenDialogMock.mockReset() grantFloatingWorkspaceDirectoryMock.mockReset() + processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true) Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) afterEach(() => { + processKillSpy.mockRestore() vi.useRealTimers() Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) @@ -177,10 +220,9 @@ describe('registerAppHandlers', () => { it('returns the selected macOS input mode before the keyboard layout fallback', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - execFileMock.mockImplementation((_command, _args, _options, callback) => { - callback( - null, - JSON.stringify([ + spawnMock.mockImplementation(() => + createFakeSpawnChild({ + stdout: JSON.stringify([ { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' }, { 'Bundle ID': 'com.apple.inputmethod.SCIM', @@ -188,60 +230,96 @@ describe('registerAppHandlers', () => { InputSourceKind: 'Input Mode' } ]) - ) - return { kill: vi.fn() } - }) + }) + ) registerAppHandlers({} as never) await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( 'com.apple.inputmethod.SCIM.ITABC' ) - expect(execFileMock).toHaveBeenCalledTimes(1) - expect(execFileMock).toHaveBeenCalledWith( - '/usr/bin/plutil', - expect.arrayContaining(['AppleSelectedInputSources']), - expect.any(Object), - expect.any(Function) + expect(spawnMock).toHaveBeenCalledTimes(1) + // Why: macOS 15's `plutil -extract json` aborts on the input-source + // array, so the probe reads live cfprefsd via `defaults export` and dodges + // the bug with an xml1 extract before converting the clean subtree to JSON. + // Pin the exact pipeline (absolute paths, stdin markers) so dropping any + // stage silently regressing CJK detection to the fallback fails the test. + expect(spawnMock).toHaveBeenCalledWith( + '/bin/sh', + [ + '-c', + '/usr/bin/defaults export com.apple.HIToolbox - | ' + + '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - - | ' + + '/usr/bin/plutil -convert json -o - -' + ], + expect.objectContaining({ detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) ) }) it('falls back to the keyboard layout when no keyboard input mode is selected', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - execFileMock - .mockImplementationOnce((_command, _args, _options, callback) => { - callback( - null, - JSON.stringify([ + spawnMock + .mockImplementationOnce(() => + createFakeSpawnChild({ + stdout: JSON.stringify([ { 'Bundle ID': 'com.apple.PressAndHold', InputSourceKind: 'Non Keyboard Input Method' } ]) - ) - return { kill: vi.fn() } - }) - .mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'com.apple.keylayout.ABC\n') - return { kill: vi.fn() } - }) + }) + ) + .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) registerAppHandlers({} as never) await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( 'com.apple.keylayout.ABC' ) - expect(execFileMock).toHaveBeenCalledTimes(2) - expect(execFileMock).toHaveBeenLastCalledWith( + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenLastCalledWith( '/usr/bin/defaults', ['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'], - expect.any(Object), - expect.any(Function) + expect.objectContaining({ detached: true }) ) }) + it('falls back to the keyboard layout when the selected input source probe exits non-zero', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + // Why: reproduces macOS 15's `plutil` abort — the pipeline exits non-zero, so + // the probe rejects on the `close` branch and the handler falls back. + spawnMock + .mockImplementationOnce(() => createFakeSpawnChild({ code: 1 })) + .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) + registerAppHandlers({} as never) + + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( + 'com.apple.keylayout.ABC' + ) + expect(spawnMock).toHaveBeenCalledTimes(2) + expect(spawnMock).toHaveBeenLastCalledWith( + '/usr/bin/defaults', + ['read', 'com.apple.HIToolbox', 'AppleCurrentKeyboardLayoutInputSourceID'], + expect.objectContaining({ detached: true }) + ) + }) + + it('falls back to the keyboard layout when the selected input source probe fails to spawn', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + // Why: a spawn-level failure (ENOENT/EACCES) emits 'error'; the handler must + // still fall back rather than reject out of the IPC call. + spawnMock + .mockImplementationOnce(() => createFakeSpawnChild({ error: new Error('spawn ENOENT') })) + .mockImplementationOnce(() => createFakeSpawnChild({ stdout: 'com.apple.keylayout.ABC\n' })) + registerAppHandlers({} as never) + + await expect(handlers.get('app:getKeyboardInputSourceId')?.(null)).resolves.toBe( + 'com.apple.keylayout.ABC' + ) + expect(spawnMock).toHaveBeenCalledTimes(2) + }) + it('falls back when macOS keyboard input source probes never report completion', async () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - const killMock = vi.fn() - execFileMock.mockImplementation(() => ({ kill: killMock })) + spawnMock.mockImplementation(() => createFakeSpawnChild({ pid: 4242, hang: true })) registerAppHandlers({} as never) const handler = handlers.get('app:getKeyboardInputSourceId') @@ -256,7 +334,10 @@ describe('registerAppHandlers', () => { expect(settled).toBe(true) await expect(resultPromise).resolves.toBeNull() - expect(killMock).toHaveBeenCalledTimes(2) + // Why: both wedged probes get a process-group SIGKILL (negative pid) so the + // shell and any orphaned `defaults`/`plutil` stages are reaped on timeout. + expect(processKillSpy).toHaveBeenCalledTimes(2) + expect(processKillSpy).toHaveBeenCalledWith(-4242, 'SIGKILL') }) it('picks an existing floating workspace directory without enabling native directory creation', async () => { diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 276c3ba5f71..2dc3c81f91b 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -1,6 +1,5 @@ -import { execFile } from 'node:child_process' +import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' -import { homedir } from 'node:os' import path from 'node:path' import { pathToFileURL } from 'node:url' import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'electron' @@ -24,12 +23,15 @@ import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500 const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox' -const MAC_HITOOLBOX_PREFERENCES_PATH = path.join( - homedir(), - 'Library', - 'Preferences', - `${MAC_HITOOLBOX_DOMAIN}.plist` -) +// Why: macOS 15's `plutil -extract json` aborts on the (pure-string) +// input-source array and the on-disk plist lags cfprefsd; read live prefs via +// `defaults export`, extract as xml1 (dodges the json bug), then convert to JSON. +// Absolute paths so a GUI-launched app's minimal PATH can't shadow the tools. +const MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND = [ + `/usr/bin/defaults export ${MAC_HITOOLBOX_DOMAIN} -`, + '/usr/bin/plutil -extract AppleSelectedInputSources xml1 -o - -', + '/usr/bin/plutil -convert json -o - -' +].join(' | ') type RegisterAppHandlersOptions = { onBeforeRelaunch?: () => void | Promise @@ -118,13 +120,32 @@ function readCommandStdout( ): Promise { return new Promise((resolve, reject) => { let settled = false - let child: ReturnType | undefined + let child: ReturnType | undefined + + // Why: the probe runs a `/bin/sh -c` pipeline, so signaling only the shell + // orphans wedged `defaults`/`plutil` stages on a stuck cfprefsd. Spawning + // detached makes the child a process-group leader, so one negative-pid + // SIGKILL reaps the shell and every stage; child.kill() covers the fallback. + const killTree = (): void => { + if (!child?.pid) { + return + } + try { + process.kill(-child.pid, 'SIGKILL') + } catch { + child.kill() + } + } + + // Why: short timeout so a wedged macOS preference probe (corporate-managed + // config, sandbox policy, ...) never holds the handle indefinitely. This + // manual timer is the only timeout guard, so it owns the process-group kill. const timer = setTimeout(() => { if (settled) { return } settled = true - child?.kill() + killTree() reject(new Error(timeoutMessage)) }, KEYBOARD_INPUT_SOURCE_TIMEOUT_MS) @@ -137,24 +158,32 @@ function readCommandStdout( callback() } - // Why: execFile's timeout only signals `defaults`; if the callback - // never arrives, window-focus keyboard probes would remain pending. try { - child = execFile( - command, - args, - // Why: short timeout so a wedged macOS preference probe (corporate-managed - // config, sandbox policy, ...) never holds the handle indefinitely. - // Fall through to the fingerprint on timeout. - { encoding: 'utf8', timeout: KEYBOARD_INPUT_SOURCE_TIMEOUT_MS }, - (error, stdout) => { - if (error) { - settle(() => reject(error)) - return - } - settle(() => resolve(String(stdout ?? ''))) - } - ) + child = spawn(command, args, { detached: true, stdio: ['ignore', 'pipe', 'ignore'] }) + let stdout = '' + child.stdout?.setEncoding('utf8') + child.stdout?.on('data', (chunk: string) => { + stdout += chunk + }) + const failWith = (error: Error): void => { + killTree() + settle(() => reject(error)) + } + // Why: an unhandled Readable 'error' would crash the main process; treat a + // stdout read failure the same as a child spawn error. + child.stdout?.on('error', failWith) + child.on('error', failWith) + child.on('close', (code, signal) => { + settle(() => + code === 0 + ? resolve(stdout) + : reject( + new Error( + `${command} exited with ${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}` + ) + ) + ) + }) } catch (error) { settle(() => reject(error)) } @@ -196,8 +225,8 @@ function readSelectedInputSourceIdFromJson(stdout: string): string | null { async function readSelectedKeyboardInputSourceId(): Promise { try { const stdout = await readCommandStdout( - '/usr/bin/plutil', - ['-extract', 'AppleSelectedInputSources', 'json', '-o', '-', MAC_HITOOLBOX_PREFERENCES_PATH], + '/bin/sh', + ['-c', MAC_SELECTED_INPUT_SOURCES_JSON_COMMAND], 'Selected keyboard input source probe timed out' ) return readSelectedInputSourceIdFromJson(stdout) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 51cbfbaefc6..56ab2aa2981 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -13,6 +13,7 @@ import { createWslWatcher } from './filesystem-watcher-wsl' import type { WatchedRoot } from './filesystem-watcher-wsl' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' +import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watcher-process' // Why: high-churn directories are suppressed at the native watcher level so // events never leave the OS/daemon. This list is separate from the File // Explorer display filter (which only hides rows). Directories like `dist` @@ -278,10 +279,6 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { // ── Watcher creation ───────────────────────────────────────────────── async function createWatcher(rootKey: string, rootPath: string): Promise { - // Why: @parcel/watcher is a native module that may not load in all - // environments. Dynamic import keeps the require() lazy. - const watcher = await import('@parcel/watcher') - const root: WatchedRoot = { subscription: null!, listeners: new Map(), @@ -303,7 +300,11 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { if (err) { @@ -334,7 +335,13 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { + // The watcher process crashed and this root was resubscribed; events + // during the gap are lost, so hand the renderer an overflow refresh. + root.batch.overflowed = true + scheduleBatchFlush(rootKey, root) + } ) // Why: if the error callback already fired and cleaned up watchedRoots @@ -929,6 +936,10 @@ export async function closeAllWatchers(): Promise { } watchedRoots.clear() await Promise.allSettled(Array.from(pendingLocalUnsubscribes)) + // Why: with every local subscription released, drop the forked watcher + // process outright — process death frees any remaining native handles + // without running watcher.node's crash-prone async teardown in this process. + disposeWatcherProcess() // Why: remote watchers are tracked separately from local @parcel/watcher // subscriptions. Without cleaning them up here, their unwatch callbacks diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 25894ffb723..072f8239dcb 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -2316,4 +2316,39 @@ describe('registerFilesystemHandlers', () => { excludePaths: ['/home/user/repo/worktrees/feature'] }) }) + + // Why #7721: without a cancel path, every workspace switch left the previous + // workspace's full-tree SSH scan running, stacking scans on the relay until + // interactive fs.readDir/fs.stat starved past their 30s timeout. + it('fs:cancelListFiles aborts an in-flight SSH listing by request token (#7721)', async () => { + let capturedSignal: AbortSignal | undefined + const listFilesMock = vi.fn( + (_rootPath: string, options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + capturedSignal = options.signal + options.signal?.addEventListener('abort', () => reject(new Error('listing cancelled')), { + once: true + }) + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ listFiles: listFilesMock }) + + registerFilesystemHandlers(store as never) + + const pending = handlers.get('fs:listFiles')!(null, { + rootPath: '/home/user/repo', + connectionId: 'conn-1', + requestToken: 'token-1' + }) as Promise + + expect(capturedSignal?.aborted).toBe(false) + await handlers.get('fs:cancelListFiles')!(null, { requestToken: 'token-1' }) + expect(capturedSignal?.aborted).toBe(true) + await expect(pending).rejects.toThrow('listing cancelled') + + // Unknown or already-settled tokens are a no-op, not an error. + expect(() => + handlers.get('fs:cancelListFiles')!(null, { requestToken: 'unknown' }) + ).not.toThrow() + }) }) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 85c0ce56e31..7e7006a375e 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1026,30 +1026,58 @@ export function registerFilesystemHandlers( ) // ─── List all files (for quick-open) ───────────────────── + // Why #7721: keyed by renderer-generated token so a workspace switch can + // abort the previous workspace's full-tree scan (SSH relays otherwise stack + // scans that starve interactive fs.readDir/fs.stat past their 30s timeout). + const listFilesCancellations = new Map() ipcMain.handle( 'fs:listFiles', async ( _event, - args: { rootPath: string; connectionId?: string; excludePaths?: string[] } + args: { + rootPath: string + connectionId?: string + excludePaths?: string[] + requestToken?: string + } ): Promise => { - if (args.connectionId) { - const provider = getSshFilesystemProvider(args.connectionId) - // Why: when the SSH connection is not yet established (cold start) or - // temporarily disconnected, return [] so quick-open shows "No matching - // files" instead of an error banner. The file list will repopulate when - // the user re-opens quick-open after the connection is restored. - if (!provider) { - return [] + const controller = args.requestToken ? new AbortController() : null + if (controller && args.requestToken) { + listFilesCancellations.set(args.requestToken, controller) + } + try { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + // Why: when the SSH connection is not yet established (cold start) or + // temporarily disconnected, return [] so quick-open shows "No matching + // files" instead of an error banner. The file list will repopulate when + // the user re-opens quick-open after the connection is restored. + if (!provider) { + return [] + } + // Why: forward excludePaths through to the remote provider. + // Dropping it here would silently double-scan nested linked worktrees + // over SSH and contribute to timeout-induced partial results. + return await provider.listFiles(args.rootPath, { + excludePaths: args.excludePaths, + signal: controller?.signal + }) } - // Why: forward excludePaths through to the remote provider. - // Dropping it here would silently double-scan nested linked worktrees - // over SSH and contribute to timeout-induced partial results. - return provider.listFiles(args.rootPath, { excludePaths: args.excludePaths }) + return await listQuickOpenFiles(args.rootPath, store, args.excludePaths) + } finally { + if (args.requestToken) { + listFilesCancellations.delete(args.requestToken) + } } - return listQuickOpenFiles(args.rootPath, store, args.excludePaths) } ) + ipcMain.handle('fs:cancelListFiles', (_event, args: { requestToken: string }): void => { + // Why: best-effort — the entry is gone once the listing settles, and + // local scans are fast enough to simply let them finish. + listFilesCancellations.get(args.requestToken)?.abort() + }) + // ─── Git operations ───────────────────────────────────── ipcMain.handle( 'git:status', diff --git a/src/main/ipc/notification-authorization-status.ts b/src/main/ipc/notification-authorization-status.ts new file mode 100644 index 00000000000..d51f97ede17 --- /dev/null +++ b/src/main/ipc/notification-authorization-status.ts @@ -0,0 +1,92 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' + +export type NotificationAuthorizationStatus = 'authorized' | 'denied' | 'not-determined' | 'unknown' + +const HELPER_EXECUTABLE = 'orca-notification-status' +const HELPER_TIMEOUT_MS = 4000 + +let cachedHelperPath: string | null | undefined + +/** + * Resolves the bundled notification-status helper binary. + * + * Why: the helper must live in Contents/MacOS next to the Electron executable + * — NSBundle resolves the process's bundle by walking up from the executable + * path, macOS keys notification records to that identity, and macOS 26 aborts + * UNUserNotificationCenter for executables run from Contents/Resources + * (#7929). Dev copies and packaged builds (extraFiles) both place it there. + */ +function resolveHelperPath(): string | null { + if (cachedHelperPath !== undefined) { + return cachedHelperPath + } + if (process.platform !== 'darwin') { + cachedHelperPath = null + return cachedHelperPath + } + const candidate = join(dirname(process.execPath), HELPER_EXECUTABLE) + cachedHelperPath = existsSync(candidate) ? candidate : null + return cachedHelperPath +} + +/** + * Reads the app's real macOS notification authorization via a helper binary + * calling UNUserNotificationCenter.getNotificationSettings. Returns null when + * the helper is unavailable or fails, so callers can fall back to weaker + * delivery-probe evidence. + * + * Why a helper at all: Electron exposes no API for notification authorization + * (scheduling silently succeeds even while macOS is suppressing display), so + * the only truthful signal is the native settings read. + */ +let readInFlight: Promise | null = null + +export function readNotificationAuthorizationStatus(): Promise { + const helperPath = resolveHelperPath() + if (!helperPath) { + return Promise.resolve(null) + } + // Why: simultaneous agent completions across worktrees each consult the + // readout — one in-flight helper run answers all of them. + if (readInFlight) { + return readInFlight + } + readInFlight = runStatusHelper(helperPath).finally(() => { + readInFlight = null + }) + return readInFlight +} + +function runStatusHelper(helperPath: string): Promise { + return new Promise((resolve) => { + execFile(helperPath, [], { timeout: HELPER_TIMEOUT_MS }, (error, stdout) => { + if (error) { + resolve(null) + return + } + try { + const parsed = JSON.parse(String(stdout).trim()) as { authorization?: string } + switch (parsed.authorization) { + case 'authorized': + case 'provisional': + case 'ephemeral': + resolve('authorized') + return + case 'denied': + resolve('denied') + return + case 'not-determined': + resolve('not-determined') + return + case undefined: + default: + resolve('unknown') + } + } catch { + resolve(null) + } + }) + }) +} diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index 3ada1917185..8fb5566a895 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -70,6 +70,25 @@ vi.mock('electron', () => ({ } })) +const { readAuthorizationStatusMock } = vi.hoisted(() => ({ + readAuthorizationStatusMock: vi.fn( + (): Promise<'authorized' | 'denied' | 'not-determined' | 'unknown' | null> => + Promise.resolve(null) + ) +})) + +vi.mock('./notification-authorization-status', () => ({ + readNotificationAuthorizationStatus: readAuthorizationStatusMock +})) + +// Why: notifications.ts pulls in the tray module (for the minimized attention +// dot), which transitively loads app-icon/electron-toolkit; stub it so this +// suite stays focused on notification dispatch and avoids that import chain. +const setTrayAttentionMock = vi.hoisted(() => vi.fn()) +vi.mock('../tray/system-tray', () => ({ + setTrayAttention: setTrayAttentionMock +})) + import { registerNotificationHandlers, triggerStartupNotificationRegistration @@ -98,9 +117,12 @@ describe('registerNotificationHandlers', () => { notificationRemoveListenerMock.mockClear() notificationIsSupportedMock.mockReset() notificationIsSupportedMock.mockReturnValue(true) + readAuthorizationStatusMock.mockReset() + readAuthorizationStatusMock.mockResolvedValue(null) getAllWindowsMock.mockReset() getAllWindowsMock.mockReturnValue([]) shellOpenExternalMock.mockClear() + setTrayAttentionMock.mockClear() }) afterEach(() => { @@ -167,7 +189,7 @@ describe('registerNotificationHandlers', () => { return call[1] as () => void } - it('registers the IPC handler', () => { + it('registers the IPC handler', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -183,7 +205,7 @@ describe('registerNotificationHandlers', () => { expect(handleMock).toHaveBeenCalledWith('notifications:dispatch', expect.any(Function)) }) - it('opens the current macOS app notification settings entry', () => { + it('opens the current macOS app notification settings entry', async () => { const originalPlatform = process.platform const originalBundleId = process.env.ORCA_DEV_MACOS_BUNDLE_ID Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) @@ -216,7 +238,7 @@ describe('registerNotificationHandlers', () => { } }) - it('opens Windows notification settings', () => { + it('opens Windows notification settings', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) try { @@ -240,7 +262,7 @@ describe('registerNotificationHandlers', () => { } }) - it('suppresses notifications when disabled in settings', () => { + it('suppresses notifications when disabled in settings', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -253,14 +275,14 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getDispatchHandler() - expect(handler({}, { source: 'agent-task-complete' })).toEqual({ + expect(await handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: false, reason: 'disabled' }) expect(notificationCtorMock).not.toHaveBeenCalled() }) - it('suppresses active-worktree notifications while Orca is focused', () => { + it('suppresses active-worktree notifications while Orca is focused', async () => { getAllWindowsMock.mockReturnValue([ { isDestroyed: () => false, @@ -280,14 +302,73 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getDispatchHandler() - expect(handler({}, { source: 'agent-task-complete', isActiveWorktree: true })).toEqual({ + expect(await handler({}, { source: 'agent-task-complete', isActiveWorktree: true })).toEqual({ delivered: false, reason: 'suppressed-focus' }) expect(notificationCtorMock).not.toHaveBeenCalled() }) - it('delivers a notification when the event is allowed', () => { + describe('minimized tray attention dot', () => { + function registerEnabledNotifications(): void { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: true + } + }) + } as never) + } + + it('lights the tray dot for an agent completion while the window is hidden', () => { + getAllWindowsMock.mockReturnValue([ + { isDestroyed: () => false, isVisible: () => false, isMinimized: () => false } as never + ]) + registerEnabledNotifications() + + getDispatchHandler()({}, { source: 'agent-task-complete' }) + + expect(setTrayAttentionMock).toHaveBeenCalledWith(true) + }) + + it('lights the tray dot for a terminal bell while the window is minimized', () => { + getAllWindowsMock.mockReturnValue([ + { isDestroyed: () => false, isVisible: () => true, isMinimized: () => true } as never + ]) + registerEnabledNotifications() + + getDispatchHandler()({}, { source: 'terminal-bell' }) + + expect(setTrayAttentionMock).toHaveBeenCalledWith(true) + }) + + it('does not light the tray dot while the window is visible', () => { + getAllWindowsMock.mockReturnValue([ + { isDestroyed: () => false, isVisible: () => true, isMinimized: () => false } as never + ]) + registerEnabledNotifications() + + getDispatchHandler()({}, { source: 'agent-task-complete' }) + + expect(setTrayAttentionMock).not.toHaveBeenCalled() + }) + + it('does not light the tray dot for non-bell/completion sources', () => { + getAllWindowsMock.mockReturnValue([ + { isDestroyed: () => false, isVisible: () => false, isMinimized: () => false } as never + ]) + registerEnabledNotifications() + + getDispatchHandler()({}, { source: 'test' }) + + expect(setTrayAttentionMock).not.toHaveBeenCalled() + }) + }) + + it('delivers a notification when the event is allowed', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -301,7 +382,10 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() expect( - handler({}, { source: 'agent-task-complete', repoLabel: 'orca', worktreeLabel: 'feat/notis' }) + await handler( + {}, + { source: 'agent-task-complete', repoLabel: 'orca', worktreeLabel: 'feat/notis' } + ) ).toEqual({ delivered: true }) expect(notificationCtorMock).toHaveBeenCalledWith( expectedNativeNotificationOptions({ @@ -312,7 +396,7 @@ describe('registerNotificationHandlers', () => { expect(notificationShowMock).toHaveBeenCalledTimes(1) }) - it('uses the macOS default notification sound when no custom sound is configured', () => { + it('uses the macOS default notification sound when no custom sound is configured', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) try { @@ -329,7 +413,7 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getDispatchHandler() - expect(handler({}, { source: 'test' })).toEqual({ delivered: true }) + expect(await handler({}, { source: 'test' })).toEqual({ delivered: true }) expect(notificationCtorMock).toHaveBeenCalledWith({ title: 'Orca notifications are on', body: 'This is a test notification from Orca.', @@ -340,7 +424,7 @@ describe('registerNotificationHandlers', () => { } }) - it('does not request a native macOS sound when a custom sound is configured', () => { + it('does not request a native macOS sound when a custom sound is configured', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) try { @@ -357,7 +441,7 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getDispatchHandler() - expect(handler({}, { source: 'test' })).toEqual({ delivered: true }) + expect(await handler({}, { source: 'test' })).toEqual({ delivered: true }) expect(notificationCtorMock).toHaveBeenCalledWith({ title: 'Orca notifications are on', body: 'This is a test notification from Orca.', @@ -368,7 +452,7 @@ describe('registerNotificationHandlers', () => { } }) - it('focuses the originating terminal pane when a notification with paneKey is clicked', () => { + it('focuses the originating terminal pane when a notification with paneKey is clicked', async () => { const webContentsSend = vi.fn() const restore = vi.fn() const focus = vi.fn() @@ -396,7 +480,7 @@ describe('registerNotificationHandlers', () => { const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' const handler = getDispatchHandler() expect( - handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1', paneKey }) + await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1', paneKey }) ).toEqual({ delivered: true }) expect(vi.getTimerCount()).toBe(1) @@ -420,7 +504,7 @@ describe('registerNotificationHandlers', () => { }) }) - it('clears the retained notification fallback timer when the native notification closes', () => { + it('clears the retained notification fallback timer when the native notification closes', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -433,7 +517,7 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getDispatchHandler() - expect(handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true }) + expect(await handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true }) expect(vi.getTimerCount()).toBe(1) const closeHandler = getNotificationEventHandler('close') @@ -443,7 +527,7 @@ describe('registerNotificationHandlers', () => { expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler) }) - it('releases retained notifications when native delivery fails', () => { + it('releases retained notifications when native delivery fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { registerNotificationHandlers({ @@ -458,7 +542,7 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getDispatchHandler() - expect(handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true }) + expect(await handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true }) expect(vi.getTimerCount()).toBe(1) const failedHandler = getNotificationEventHandler('failed') @@ -474,7 +558,7 @@ describe('registerNotificationHandlers', () => { } }) - it('formats agent-task-complete with the agent response when a status snapshot is present', () => { + it('formats agent-task-complete with the agent response when a status snapshot is present', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -488,7 +572,7 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() expect( - handler( + await handler( {}, { source: 'agent-task-complete', @@ -512,7 +596,7 @@ describe('registerNotificationHandlers', () => { ) }) - it('includes the repo name when multiple repos are active', () => { + it('includes the repo name when multiple repos are active', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -526,7 +610,7 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() expect( - handler( + await handler( {}, { source: 'agent-task-complete', @@ -549,7 +633,7 @@ describe('registerNotificationHandlers', () => { ) }) - it('keeps a readable body when no assistant response was captured', () => { + it('keeps a readable body when no assistant response was captured', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -563,7 +647,7 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() expect( - handler( + await handler( {}, { source: 'agent-task-complete', @@ -586,7 +670,7 @@ describe('registerNotificationHandlers', () => { ) }) - it('formats blocked and interrupted agent snapshots distinctly', () => { + it('formats blocked and interrupted agent snapshots distinctly', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -600,7 +684,7 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() expect( - handler( + await handler( {}, { source: 'agent-task-complete', @@ -614,7 +698,7 @@ describe('registerNotificationHandlers', () => { ).toEqual({ delivered: true }) vi.advanceTimersByTime(5001) expect( - handler( + await handler( {}, { source: 'agent-task-complete', @@ -644,7 +728,7 @@ describe('registerNotificationHandlers', () => { ) }) - it('normalizes custom agent labels and re-bounds multiline assistant previews', () => { + it('normalizes custom agent labels and re-bounds multiline assistant previews', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -659,7 +743,7 @@ describe('registerNotificationHandlers', () => { const longAssistantMessage = `Line one\n\n${'x'.repeat(400)}` const handler = getDispatchHandler() expect( - handler( + await handler( {}, { source: 'agent-task-complete', @@ -686,7 +770,7 @@ describe('registerNotificationHandlers', () => { expect(options.body.length).toBeLessThanOrEqual(180) }) - it('uses tool context before falling back when no prompt or assistant preview exists', () => { + it('uses tool context before falling back when no prompt or assistant preview exists', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -700,7 +784,7 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() expect( - handler( + await handler( {}, { source: 'agent-task-complete', @@ -722,7 +806,7 @@ describe('registerNotificationHandlers', () => { ) }) - it('uses rich formatter output for mobile notifications before the native support guard', () => { + it('uses rich formatter output for mobile notifications before the native support guard', async () => { notificationIsSupportedMock.mockReturnValue(false) const dispatchMobileNotification = vi.fn() registerNotificationHandlers( @@ -741,7 +825,7 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() expect( - handler( + await handler( {}, { source: 'agent-task-complete', @@ -765,7 +849,7 @@ describe('registerNotificationHandlers', () => { expect(notificationCtorMock).not.toHaveBeenCalled() }) - it('does not dispatch mobile notifications when notifications are disabled', () => { + it('does not dispatch mobile notifications when notifications are disabled', async () => { const dispatchMobileNotification = vi.fn() registerNotificationHandlers( { @@ -782,7 +866,7 @@ describe('registerNotificationHandlers', () => { ) const handler = getDispatchHandler() - expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ + expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ delivered: false, reason: 'disabled' }) @@ -790,7 +874,7 @@ describe('registerNotificationHandlers', () => { expect(dispatchMobileNotification).not.toHaveBeenCalled() }) - it('does not dispatch mobile notifications when the source is disabled', () => { + it('does not dispatch mobile notifications when the source is disabled', async () => { const dispatchMobileNotification = vi.fn() registerNotificationHandlers( { @@ -807,7 +891,7 @@ describe('registerNotificationHandlers', () => { ) const handler = getDispatchHandler() - expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ + expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ delivered: false, reason: 'source-disabled' }) @@ -815,7 +899,7 @@ describe('registerNotificationHandlers', () => { expect(dispatchMobileNotification).not.toHaveBeenCalled() }) - it('does not dispatch mobile notifications for focused active-worktree notifications', () => { + it('does not dispatch mobile notifications for focused active-worktree notifications', async () => { getAllWindowsMock.mockReturnValue([ { isDestroyed: () => false, @@ -839,7 +923,7 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() expect( - handler( + await handler( {}, { source: 'agent-task-complete', worktreeId: 'repo::wt1', isActiveWorktree: true } ) @@ -851,7 +935,7 @@ describe('registerNotificationHandlers', () => { expect(dispatchMobileNotification).not.toHaveBeenCalled() }) - it('does not dispatch mobile notifications for cooldown-suppressed bursts', () => { + it('does not dispatch mobile notifications for cooldown-suppressed bursts', async () => { const dispatchMobileNotification = vi.fn() registerNotificationHandlers( { @@ -868,10 +952,10 @@ describe('registerNotificationHandlers', () => { ) const handler = getDispatchHandler() - expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ + expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ delivered: true }) - expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ + expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ delivered: false, reason: 'cooldown' }) @@ -882,7 +966,7 @@ describe('registerNotificationHandlers', () => { ) }) - it('does not forward explicit desktop test notifications to mobile clients', () => { + it('does not forward explicit desktop test notifications to mobile clients', async () => { const dispatchMobileNotification = vi.fn() registerNotificationHandlers( { @@ -899,12 +983,12 @@ describe('registerNotificationHandlers', () => { ) const handler = getDispatchHandler() - expect(handler({}, { source: 'test' })).toEqual({ delivered: true }) + expect(await handler({}, { source: 'test' })).toEqual({ delivered: true }) expect(dispatchMobileNotification).not.toHaveBeenCalled() }) - it('dismisses active native notifications and fans out mobile dismissal once per id', () => { + it('dismisses active native notifications and fans out mobile dismissal once per id', async () => { const dispatchMobileNotification = vi.fn() const dismissMobileNotification = vi.fn() registerNotificationHandlers( @@ -923,7 +1007,7 @@ describe('registerNotificationHandlers', () => { const dispatchHandler = getDispatchHandler() expect( - dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:one' }) + await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:one' }) ).toEqual({ delivered: true }) const dismissHandler = getDismissHandler() @@ -935,7 +1019,7 @@ describe('registerNotificationHandlers', () => { expect(dismissMobileNotification).toHaveBeenCalledWith('agent:one') }) - it('fans out mobile dismissal even when there is no active native notification', () => { + it('fans out mobile dismissal even when there is no active native notification', async () => { const dismissMobileNotification = vi.fn() registerNotificationHandlers( { @@ -958,7 +1042,7 @@ describe('registerNotificationHandlers', () => { expect(dismissMobileNotification).toHaveBeenCalledWith('agent:missing') }) - it('closes the previous native notification when replacing the same id', () => { + it('closes the previous native notification when replacing the same id', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -972,18 +1056,18 @@ describe('registerNotificationHandlers', () => { const dispatchHandler = getDispatchHandler() expect( - dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' }) + await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' }) ).toEqual({ delivered: true }) vi.advanceTimersByTime(5001) expect( - dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' }) + await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' }) ).toEqual({ delivered: true }) expect(notificationCloseMock).toHaveBeenCalledTimes(1) expect(notificationShowMock).toHaveBeenCalledTimes(2) }) - it('silences the native notification when a custom sound is configured', () => { + it('silences the native notification when a custom sound is configured', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -997,7 +1081,7 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getDispatchHandler() - expect(handler({}, { source: 'test' })).toEqual({ delivered: true }) + expect(await handler({}, { source: 'test' })).toEqual({ delivered: true }) expect(notificationCtorMock).toHaveBeenCalledWith({ title: 'Orca notifications are on', body: 'This is a test notification from Orca.', @@ -1005,7 +1089,7 @@ describe('registerNotificationHandlers', () => { }) }) - it('returns source-disabled when the specific source toggle is off', () => { + it('returns source-disabled when the specific source toggle is off', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -1018,13 +1102,13 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getDispatchHandler() - expect(handler({}, { source: 'agent-task-complete' })).toEqual({ + expect(await handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: false, reason: 'source-disabled' }) }) - it('deduplicates repeated notifications for the same worktree', () => { + it('deduplicates repeated notifications for the same worktree', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -1037,23 +1121,23 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getDispatchHandler() - expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ + expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ delivered: true }) - expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ + expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ delivered: false, reason: 'cooldown' }) vi.advanceTimersByTime(5001) - expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ + expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ delivered: true }) expect(notificationShowMock).toHaveBeenCalledTimes(2) }) - it('bounds notification cooldown keys during unique worktree bursts', () => { + it('bounds notification cooldown keys during unique worktree bursts', async () => { notificationIsSupportedMock.mockReturnValue(false) registerNotificationHandlers({ getSettings: () => ({ @@ -1068,24 +1152,24 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() for (let i = 0; i < 75; i++) { - expect(handler({}, { source: 'terminal-bell', worktreeId: `repo::wt-${i}` })).toEqual({ + expect(await handler({}, { source: 'terminal-bell', worktreeId: `repo::wt-${i}` })).toEqual({ delivered: false, reason: 'not-supported' }) } - expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-0' })).toEqual({ + expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-0' })).toEqual({ delivered: false, reason: 'not-supported' }) - expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-74' })).toEqual({ + expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-74' })).toEqual({ delivered: false, reason: 'cooldown' }) expect(notificationCtorMock).not.toHaveBeenCalled() }) - it('deduplicates agent-task-complete and terminal-bell for the same worktree', () => { + it('deduplicates agent-task-complete and terminal-bell for the same worktree', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -1099,17 +1183,46 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() - expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ + expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({ delivered: true }) - expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ + expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({ delivered: false, reason: 'cooldown' }) expect(notificationShowMock).toHaveBeenCalledTimes(1) }) - it('does not cooldown explicit test notifications', () => { + it('skips native delivery and reports blocked-by-system when macOS would swallow it', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + try { + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }) + } as never) + readAuthorizationStatusMock.mockResolvedValue('denied') + + const handler = getDispatchHandler() + expect(await handler({}, { source: 'agent-task-complete' })).toEqual({ + delivered: false, + reason: 'blocked-by-system' + }) + // Why: a swallowed native notification would still pile up in the + // Notification Center delivered list — skip creating it entirely. + expect(notificationCtorMock).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + } + }) + + it('does not cooldown explicit test notifications', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -1123,8 +1236,8 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() - expect(handler({}, { source: 'test' })).toEqual({ delivered: true }) - expect(handler({}, { source: 'test' })).toEqual({ delivered: true }) + expect(await handler({}, { source: 'test' })).toEqual({ delivered: true }) + expect(await handler({}, { source: 'test' })).toEqual({ delivered: true }) expect(notificationShowMock).toHaveBeenCalledTimes(2) }) @@ -1143,6 +1256,9 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() const result = handler({}, { source: 'test', requireDisplayConfirmation: true }) + // Why: the darwin authorization gate resolves before the notification is + // created, so flush microtasks before grabbing its event listeners. + await vi.advanceTimersByTimeAsync(0) const showHandler = getNotificationOnceEventHandler('show') const failedHandler = getNotificationOnceEventHandler('failed') showHandler() @@ -1168,6 +1284,7 @@ describe('registerNotificationHandlers', () => { const handler = getDispatchHandler() const result = handler({}, { source: 'test', requireDisplayConfirmation: true }) + await vi.advanceTimersByTimeAsync(0) const showHandler = getNotificationOnceEventHandler('show') const failedHandler = getNotificationOnceEventHandler('failed') await vi.advanceTimersByTimeAsync(2501) @@ -1217,13 +1334,13 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getLoadSoundHandler() - await expect(handler({})).resolves.toEqual({ + expect(await handler({})).toEqual({ ok: false, reason: 'unsupported-type' }) }) - it('resolves the sound path without reading the file', () => { + it('resolves the sound path without reading the file', async () => { const soundPath = join(tempDir, 'sound.ogg') writeFileSync(soundPath, Buffer.from([1, 2, 3])) registerNotificationHandlers({ @@ -1239,10 +1356,10 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getResolveSoundPathHandler() - expect(handler({})).toEqual({ ok: true, path: soundPath }) + expect(await handler({})).toEqual({ ok: true, path: soundPath }) }) - it('rejects unsupported types from resolveSoundPath without touching the disk', () => { + it('rejects unsupported types from resolveSoundPath without touching the disk', async () => { registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -1256,7 +1373,203 @@ describe('registerNotificationHandlers', () => { } as never) const handler = getResolveSoundPathHandler() - expect(handler({})).toEqual({ ok: false, reason: 'unsupported-type' }) + expect(await handler({})).toEqual({ ok: false, reason: 'unsupported-type' }) + }) +}) + +describe('notifications:probeDelivery', () => { + const originalPlatform = process.platform + + function getProbeDeliveryHandler(): (event: unknown, args?: { force?: boolean }) => unknown { + const call = handleMock.mock.calls.find( + (c: unknown[]) => c[0] === 'notifications:probeDelivery' + ) + if (!call) { + throw new Error('notifications:probeDelivery handler not registered') + } + return call[1] as (event: unknown, args?: { force?: boolean }) => unknown + } + + function getProbeOnceEventHandler(eventName: string): (...args: unknown[]) => void { + // Why: findLast — a test may run several probes, and only the newest + // probe's listeners can settle the pending promise. + const call = notificationOnceMock.mock.calls.findLast((c: unknown[]) => c[0] === eventName) + if (!call) { + throw new Error(`Probe notification ${eventName} once handler not registered`) + } + return call[1] as (...args: unknown[]) => void + } + + function createStore(ui: Record = {}): { + getSettings: () => unknown + getUI: () => Record + updateUI: ReturnType + } { + const state = { ...ui } + return { + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: false + } + }), + getUI: () => state, + updateUI: vi.fn((updates: Record) => { + Object.assign(state, updates) + }) + } + } + + beforeEach(() => { + vi.useFakeTimers() + handleMock.mockReset() + removeHandlerMock.mockReset() + notificationCtorMock.mockClear() + notificationShowMock.mockClear() + notificationCloseMock.mockClear() + notificationOnMock.mockClear() + notificationOnceMock.mockClear() + notificationRemoveListenerMock.mockClear() + notificationIsSupportedMock.mockReset() + notificationIsSupportedMock.mockReturnValue(true) + readAuthorizationStatusMock.mockReset() + readAuthorizationStatusMock.mockResolvedValue(null) + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + }) + + it('reports unsupported on non-darwin platforms without probing', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + const store = createStore() + registerNotificationHandlers(store as never) + + await expect(getProbeDeliveryHandler()({})).resolves.toEqual({ + state: 'unsupported', + authoritative: false + }) + expect(notificationCtorMock).not.toHaveBeenCalled() + expect(store.updateUI).not.toHaveBeenCalled() + }) + + it('reports authoritative states straight from the authorization readout', async () => { + const store = createStore() + registerNotificationHandlers(store as never) + const handler = getProbeDeliveryHandler() + + readAuthorizationStatusMock.mockResolvedValue('authorized') + expect(await handler({})).toEqual({ state: 'delivered', authoritative: true }) + + readAuthorizationStatusMock.mockResolvedValue('denied') + expect(await handler({})).toEqual({ state: 'blocked', authoritative: true }) + + // No probe notifications were needed for either readout. + expect(notificationCtorMock).not.toHaveBeenCalled() + }) + + it('fires one dialog-trigger probe per session while the decision is pending', async () => { + const store = createStore() + registerNotificationHandlers(store as never) + const handler = getProbeDeliveryHandler() + readAuthorizationStatusMock.mockResolvedValue('not-determined') + + expect(await handler({})).toEqual({ + state: 'awaiting-decision', + authoritative: true + }) + expect(notificationCtorMock).toHaveBeenCalledTimes(1) + + // Polling again while pending must not spam more probe notifications. + expect(await handler({}, { force: true })).toEqual({ + state: 'awaiting-decision', + authoritative: true + }) + expect(notificationCtorMock).toHaveBeenCalledTimes(1) + }) + + it('marks the one-shot permission registration as done so startup cannot re-prompt', async () => { + const store = createStore() + registerNotificationHandlers(store as never) + + const result = getProbeDeliveryHandler()({}) as Promise + await vi.advanceTimersByTimeAsync(0) + expect(store.updateUI).toHaveBeenCalledWith({ notificationPermissionRequested: true }) + + getProbeOnceEventHandler('failed')({}, 'not allowed') + await expect(result).resolves.toEqual({ state: 'blocked', authoritative: false }) + }) + + it('falls back to delivery probes when the readout is unavailable', async () => { + const store = createStore() + registerNotificationHandlers(store as never) + + const result = getProbeDeliveryHandler()({}) as Promise + await vi.advanceTimersByTimeAsync(0) + expect(notificationShowMock).toHaveBeenCalledTimes(1) + + getProbeOnceEventHandler('show')() + await expect(result).resolves.toEqual({ state: 'delivered', authoritative: false }) + // No persisted confirmation on purpose: OS permission changes between runs. + expect(store.updateUI).not.toHaveBeenCalledWith({ notificationDeliveryConfirmed: true }) + }) + + it('serves session evidence without probing again until forced', async () => { + const store = createStore() + registerNotificationHandlers(store as never) + const handler = getProbeDeliveryHandler() + + const probeResult = handler({}) as Promise + await vi.advanceTimersByTimeAsync(0) + getProbeOnceEventHandler('show')() + await expect(probeResult).resolves.toEqual({ state: 'delivered', authoritative: false }) + expect(notificationCtorMock).toHaveBeenCalledTimes(1) + + // Cached session evidence answers non-force calls with no new probe. + expect(await handler({})).toEqual({ state: 'delivered', authoritative: false }) + expect(notificationCtorMock).toHaveBeenCalledTimes(1) + + // Force bypasses the cache and schedules a fresh probe. + const forced = handler({}, { force: true }) as Promise + await vi.advanceTimersByTimeAsync(0) + expect(notificationCtorMock).toHaveBeenCalledTimes(2) + getProbeOnceEventHandler('show')() + await expect(forced).resolves.toEqual({ state: 'delivered', authoritative: false }) + }) + + it('serves cached failure evidence after a rejected probe', async () => { + const store = createStore() + registerNotificationHandlers(store as never) + const handler = getProbeDeliveryHandler() + + const probeResult = handler({}, { force: true }) as Promise + await vi.advanceTimersByTimeAsync(0) + getProbeOnceEventHandler('failed')({}, 'Notifications are not allowed for this application') + await expect(probeResult).resolves.toEqual({ state: 'blocked', authoritative: false }) + + expect(await handler({})).toEqual({ state: 'blocked', authoritative: false }) + expect(notificationCtorMock).toHaveBeenCalledTimes(1) + }) + + it('resolves blocked on timeout without recording a definitive failure', async () => { + const store = createStore() + registerNotificationHandlers(store as never) + const handler = getProbeDeliveryHandler() + + const probeResult = handler({}) as Promise + await vi.advanceTimersByTimeAsync(3001) + await expect(probeResult).resolves.toEqual({ state: 'blocked', authoritative: false }) + expect(notificationCloseMock).toHaveBeenCalledTimes(1) + + // A timeout is ambiguous evidence, so the next non-force call probes again. + const secondResult = handler({}) as Promise + await vi.advanceTimersByTimeAsync(0) + expect(notificationCtorMock).toHaveBeenCalledTimes(2) + getProbeOnceEventHandler('show')() + await expect(secondResult).resolves.toEqual({ state: 'delivered', authoritative: false }) }) }) @@ -1288,7 +1601,7 @@ describe('triggerStartupNotificationRegistration', () => { Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) }) - it('shows welcome notification when not yet requested', () => { + it('shows welcome notification when not yet requested', async () => { const store = { getUI: () => ({ notificationPermissionRequested: undefined }), updateUI: vi.fn() @@ -1304,7 +1617,7 @@ describe('triggerStartupNotificationRegistration', () => { expect(notificationShowMock).toHaveBeenCalledTimes(1) }) - it('does not fire when notificationPermissionRequested flag is set', () => { + it('does not fire when notificationPermissionRequested flag is set', async () => { const store = { getUI: () => ({ notificationPermissionRequested: true }), updateUI: vi.fn() @@ -1315,7 +1628,7 @@ describe('triggerStartupNotificationRegistration', () => { expect(notificationCtorMock).not.toHaveBeenCalled() }) - it('does nothing on non-darwin platforms', () => { + it('does nothing on non-darwin platforms', async () => { Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) const store = { getUI: () => ({ notificationPermissionRequested: undefined }), @@ -1327,7 +1640,7 @@ describe('triggerStartupNotificationRegistration', () => { expect(notificationCtorMock).not.toHaveBeenCalled() }) - it('clears startup notification timers when the notification is clicked', () => { + it('clears startup notification timers when the notification is clicked', async () => { const store = { getUI: () => ({ notificationPermissionRequested: undefined }), updateUI: vi.fn() @@ -1345,7 +1658,7 @@ describe('triggerStartupNotificationRegistration', () => { expect(notificationRemoveListenerMock).toHaveBeenCalledWith('show', expect.any(Function)) }) - it('cleans up startup notification registration when native delivery fails', () => { + it('cleans up startup notification registration when native delivery fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { const store = { diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index b9490da8631..47baa7e7725 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -13,6 +13,7 @@ import thumpSoundPath from '../../../resources/notification-sounds/thump.mp3?ass import twoToneSoundPath from '../../../resources/notification-sounds/two-tone.mp3?asset' import type { Store } from '../persistence' import type { + NotificationDeliveryProbeResult, NotificationDispatchRequest, NotificationDispatchResult, NotificationDismissResult, @@ -23,7 +24,10 @@ import type { import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { buildNotificationOptions } from './notification-options' +import { readNotificationAuthorizationStatus } from './notification-authorization-status' import { parsePaneKey } from '../../shared/stable-pane-id' +import { setTrayAttention } from '../tray/system-tray' +import { isMainWindowVisible } from '../window/main-window-visibility' const NOTIFICATION_COOLDOWN_MS = 5000 const MAX_RECENT_NOTIFICATION_KEYS = 50 @@ -96,6 +100,113 @@ function retainNotificationUntilRelease( return release } +const NOTIFICATION_PROBE_RESULT_TIMEOUT_MS = 3000 +const NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS = 4000 + +// Why: Electron has no API to read macOS UNUserNotificationCenter +// authorization, so the freshest signal we have is what happened to the last +// notification we scheduled. Session-scoped on purpose: OS-level permission +// can change between runs, and a stale positive renders a false green card. +let lastObservedDeliveryOutcome: 'delivered' | 'failed' | null = null +let deliveryProbeInFlight: Promise | null = null +// Why: firing one probe notification is what instantiates Electron's +// presenter and pops the macOS permission dialog. Once per session is enough +// while the authorization readout reports the decision as pending. +let permissionDialogTriggeredThisSession = false + +/** + * Fallback signal for hosts without the native helper. Schedules a silent + * probe notification and reports whether macOS accepted it. 'failed' means + * the request was rejected (permission denied, or an unsigned build). On a + * fresh install the probe also instantiates Electron's notification + * presenter, which is what makes macOS pop the "Allow notifications?" dialog. + * + * Known ambiguity with no public API to resolve it (verified on macOS 26): + * while the dialog is unanswered — and when notifications are toggled off in + * System Settings after being authorized — macOS still accepts requests and + * silently swallows them, so 'delivered' can over-report. 'failed' fires for + * hard rejections (unsigned builds, dialog-level denial). The bundled + * notification-status helper exists precisely to avoid this ambiguity. + */ +function probeNotificationDelivery(): Promise { + if (deliveryProbeInFlight) { + return deliveryProbeInFlight + } + permissionDialogTriggeredThisSession = true + + const probe = new Notification({ + title: 'Orca notifications are on', + body: 'Orca will alert you when agents finish or terminals need attention.', + silent: true + }) + activeNotifications.add(probe) + + deliveryProbeInFlight = new Promise((resolve) => { + let settled = false + let timeoutTimer: ReturnType | null = null + + function releaseProbe(): void { + activeNotifications.delete(probe) + probe.removeListener('show', onShow) + probe.removeListener('failed', onFailed) + probe.close() + } + + function settle(state: 'delivered' | 'blocked'): void { + if (settled) { + return + } + settled = true + if (timeoutTimer) { + clearTimeout(timeoutTimer) + timeoutTimer = null + } + lastObservedDeliveryOutcome = state === 'delivered' ? 'delivered' : 'failed' + resolve({ state, authoritative: false }) + } + + function onShow(): void { + settle('delivered') + // Why: when delivery works the probe banner is visible, so it doubles + // as the user-facing confirmation — let it linger briefly instead of + // vanishing the instant it appears. + const closeTimer = setTimeout(releaseProbe, NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS) + if (typeof closeTimer.unref === 'function') { + closeTimer.unref() + } + } + + function onFailed(_event: unknown, _error?: string): void { + // Why: a rejected probe is an expected outcome (denied permission), not + // an anomaly — logging it would spam the console on every poll while + // the onboarding card waits for the user to allow notifications. + settle('blocked') + releaseProbe() + } + + probe.once('show', onShow) + probe.once('failed', onFailed) + // Why: don't record a 'failed' outcome on timeout — a missing callback is + // ambiguous, while the 'failed' event is a definitive rejection. + timeoutTimer = setTimeout(() => { + if (!settled) { + settled = true + resolve({ state: 'blocked', authoritative: false }) + releaseProbe() + } + }, NOTIFICATION_PROBE_RESULT_TIMEOUT_MS) + if (typeof timeoutTimer.unref === 'function') { + timeoutTimer.unref() + } + + probe.show() + }).finally(() => { + deliveryProbeInFlight = null + }) + + return deliveryProbeInFlight +} + function getMacNotificationSettingsUrl(): string { const bundleId = process.env.ORCA_DEV_MACOS_BUNDLE_ID ?? MACOS_PACKAGED_BUNDLE_ID return `${MACOS_NOTIFICATION_SETTINGS_URL}?id=${encodeURIComponent(bundleId)}` @@ -203,10 +314,15 @@ function pruneRecentNotifications(recentNotifications: Map, now: export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void { const recentNotifications = new Map() + // Why: handler registration marks a fresh session — permission evidence + // from a previous registration must not leak into the new one. + lastObservedDeliveryOutcome = null + deliveryProbeInFlight = null + permissionDialogTriggeredThisSession = false ipcMain.removeHandler('notifications:openSystemSettings') ipcMain.removeHandler('notifications:getPermissionStatus') - ipcMain.removeHandler('notifications:requestPermission') + ipcMain.removeHandler('notifications:probeDelivery') ipcMain.handle('notifications:openSystemSettings', (): void => { openNotificationSystemSettings() }) @@ -225,10 +341,52 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime }) ipcMain.handle('notifications:getPermissionStatus', getPermissionStatus) - ipcMain.handle('notifications:requestPermission', (): NotificationPermissionStatusResult => { - triggerStartupNotificationRegistration(store) - return getPermissionStatus() - }) + ipcMain.handle( + 'notifications:probeDelivery', + async (_event, args?: { force?: boolean }): Promise => { + // Why: macOS-only. Windows/Linux have no equivalent first-use permission + // dialog, so the onboarding card that consumes this never renders there. + if (process.platform !== 'darwin' || !Notification.isSupported()) { + return { state: 'unsupported', authoritative: false } + } + // Why: probes (and the native helper's first-launch path) surface the + // macOS permission dialog — mark the one-shot startup registration as + // done so it can't fire a second prompt later. + if (store.getUI().notificationPermissionRequested !== true) { + store.updateUI({ notificationPermissionRequested: true }) + } + // Preferred source: the bundled helper reads the real + // UNUserNotificationCenter authorization. Silent, so polling with it + // tracks System Settings changes live without flashing banners. + const authorization = await readNotificationAuthorizationStatus() + if (authorization === 'authorized') { + lastObservedDeliveryOutcome = 'delivered' + return { state: 'delivered', authoritative: true } + } + if (authorization === 'denied') { + lastObservedDeliveryOutcome = 'failed' + return { state: 'blocked', authoritative: true } + } + if (authorization === 'not-determined') { + // Why: the dialog only appears once something asks — fire a single + // probe per session to trigger it, then report the pending decision. + if (!permissionDialogTriggeredThisSession) { + void probeNotificationDelivery() + } + return { state: 'awaiting-decision', authoritative: true } + } + // Helper unavailable ('unknown' status is also unusable evidence): + // fall back to scheduling-based probes with session caching, which + // avoids repeated probe banners when delivery works. + if (!args?.force && lastObservedDeliveryOutcome !== null) { + return { + state: lastObservedDeliveryOutcome === 'delivered' ? 'delivered' : 'blocked', + authoritative: false + } + } + return probeNotificationDelivery() + } + ) ipcMain.removeHandler('notifications:dismiss') ipcMain.handle('notifications:dismiss', (_event, ids: string[]): NotificationDismissResult => { @@ -255,6 +413,22 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime _event, args: NotificationDispatchRequest ): NotificationDispatchResult | Promise => { + // Why: a terminal bell or agent completion that arrives while the window + // is minimized/hidden lights the tray attention dot — a passive cue that + // clears on window show/restore (see index.ts). Placed before the + // focus-suppression, cooldown, and enabled gates below so those do not + // hold back the dot. It rides the notification dispatch, so it follows the + // renderer's per-source decision to notify: bells always reach here, while + // an agent completion is suppressed upstream when its notification is + // disabled. Tray exists only on Windows, so setTrayAttention no-ops + // elsewhere. + if (args.source === 'agent-task-complete' || args.source === 'terminal-bell') { + const activeWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null + if (!isMainWindowVisible(activeWindow)) { + setTrayAttention(true) + } + } + const settings = store.getSettings().notifications if (!settings.enabled) { return { delivered: false, reason: 'disabled' } @@ -317,116 +491,140 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime return { delivered: false, reason: 'not-supported' } } - if (getEffectiveNotificationSoundId(settings) !== 'system') { - notificationOptions.silent = true - } else if (process.platform === 'darwin') { - // Why: macOS treats an unset notification sound as silent. When Orca is - // using the OS sound, ask Electron for the default notification sound. - notificationOptions.sound = 'default' - } - const notification = new Notification(notificationOptions) - if (args.notificationId) { - const previous = activeNotificationsById.get(args.notificationId) - if (previous) { - previous.notification.close() - previous.release() + function deliverNativeNotification(): + | NotificationDispatchResult + | Promise { + if (getEffectiveNotificationSoundId(settings) !== 'system') { + notificationOptions.silent = true + } else if (process.platform === 'darwin') { + // Why: macOS treats an unset notification sound as silent. When Orca is + // using the OS sound, ask Electron for the default notification sound. + notificationOptions.sound = 'default' } - } - - // Why: prevent GC from collecting the notification (and its click - // handler) while it's still visible in macOS Notification Center. - let clickHandler: (() => void) | null = null - let failedHandler: ((_event: unknown, error?: string) => void) | null = null - const entryForId: { notification: Notification; release: () => void } | null = - args.notificationId ? { notification, release: () => {} } : null - const release = retainNotificationUntilRelease(notification, () => { - if (clickHandler) { - notification.removeListener('click', clickHandler) - clickHandler = null - } - if (failedHandler) { - notification.removeListener('failed', failedHandler) - failedHandler = null - } - if ( - args.notificationId && - activeNotificationsById.get(args.notificationId) === entryForId - ) { - activeNotificationsById.delete(args.notificationId) - } - }) - if (entryForId && args.notificationId) { - entryForId.release = release - activeNotificationsById.set(args.notificationId, entryForId) - } - - failedHandler = (_event, error) => { - // Why: Electron 42's macOS UNNotification backend reports unsigned - // apps and native delivery errors here; release immediately instead - // of retaining a dead notification until the fallback timer. - logNativeNotificationFailure(args.source, error) - release() - } - notification.on('failed', failedHandler) - - // Why: clicking a notification should bring Orca to the foreground and - // switch to the worktree/pane that triggered it. Worktree activation owns - // repo/sidebar state; the optional focusTerminal follow-up uses the stable - // pane leaf id so split-pane notifications land on the exact pane. - // Why: worktreeId is formatted as "repoId::worktreePath". If the - // separator is missing we cannot reliably extract a repoId, so skip - // the click-to-navigate binding — the notification still fires but - // clicking it will not attempt to switch to an unknown worktree. - if (args.worktreeId && args.worktreeId.includes('::')) { - const repoId = getRepoIdFromWorktreeId(args.worktreeId) - clickHandler = () => { - release() - const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()) - if (!win) { - return - } - if (process.platform === 'darwin') { - app.focus({ steal: true }) - } - if (win.isMinimized()) { - win.restore() - } - win.focus() - win.webContents.send('ui:activateWorktree', { - repoId, - worktreeId: args.worktreeId - }) - const paneTarget = args.paneKey ? parsePaneKey(args.paneKey) : null - if (paneTarget) { - win.webContents.send('ui:focusTerminal', { - tabId: paneTarget.tabId, - worktreeId: args.worktreeId, - leafId: paneTarget.leafId, - ackPaneKeyOnSuccess: args.paneKey, - flashFocusedPane: true, - scrollToBottomIfOutputSinceLastView: true - }) + const notification = new Notification(notificationOptions) + if (args.notificationId) { + const previous = activeNotificationsById.get(args.notificationId) + if (previous) { + previous.notification.close() + previous.release() } } - notification.on('click', clickHandler) - } - const displayConfirmation = args.requireDisplayConfirmation - ? waitForNotificationDisplay(notification) - : null - notification.show() - - if (displayConfirmation) { - return displayConfirmation.then((displayed) => { - if (!displayed) { - release() - return { delivered: false, reason: 'not-displayed' } + // Why: prevent GC from collecting the notification (and its click + // handler) while it's still visible in macOS Notification Center. + let clickHandler: (() => void) | null = null + let failedHandler: ((_event: unknown, error?: string) => void) | null = null + const entryForId: { notification: Notification; release: () => void } | null = + args.notificationId ? { notification, release: () => {} } : null + const release = retainNotificationUntilRelease(notification, () => { + if (clickHandler) { + notification.removeListener('click', clickHandler) + clickHandler = null + } + if (failedHandler) { + notification.removeListener('failed', failedHandler) + failedHandler = null + } + if ( + args.notificationId && + activeNotificationsById.get(args.notificationId) === entryForId + ) { + activeNotificationsById.delete(args.notificationId) } - return { delivered: true } }) + if (entryForId && args.notificationId) { + entryForId.release = release + activeNotificationsById.set(args.notificationId, entryForId) + } + + failedHandler = (_event, error) => { + // Why: Electron 42's macOS UNNotification backend reports unsigned + // apps and native delivery errors here; release immediately instead + // of retaining a dead notification until the fallback timer. + logNativeNotificationFailure(args.source, error) + // A definitive rejection — feeds the permission card's evidence. + lastObservedDeliveryOutcome = 'failed' + release() + } + notification.on('failed', failedHandler) + + // Why: clicking a notification should bring Orca to the foreground and + // switch to the worktree/pane that triggered it. Worktree activation owns + // repo/sidebar state; the optional focusTerminal follow-up uses the stable + // pane leaf id so split-pane notifications land on the exact pane. + // Why: worktreeId is formatted as "repoId::worktreePath". If the + // separator is missing we cannot reliably extract a repoId, so skip + // the click-to-navigate binding — the notification still fires but + // clicking it will not attempt to switch to an unknown worktree. + if (args.worktreeId && args.worktreeId.includes('::')) { + const repoId = getRepoIdFromWorktreeId(args.worktreeId) + clickHandler = () => { + release() + const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()) + if (!win) { + return + } + if (process.platform === 'darwin') { + app.focus({ steal: true }) + } + if (win.isMinimized()) { + win.restore() + } + win.focus() + win.webContents.send('ui:activateWorktree', { + repoId, + worktreeId: args.worktreeId + }) + const paneTarget = args.paneKey ? parsePaneKey(args.paneKey) : null + if (paneTarget) { + win.webContents.send('ui:focusTerminal', { + tabId: paneTarget.tabId, + worktreeId: args.worktreeId, + leafId: paneTarget.leafId, + ackPaneKeyOnSuccess: args.paneKey, + flashFocusedPane: true, + scrollToBottomIfOutputSinceLastView: true + }) + } + } + notification.on('click', clickHandler) + } + + const displayConfirmation = args.requireDisplayConfirmation + ? waitForNotificationDisplay(notification) + : null + notification.show() + + if (displayConfirmation) { + return displayConfirmation.then((displayed) => { + if (!displayed) { + release() + return { delivered: false, reason: 'not-displayed' } + } + lastObservedDeliveryOutcome = 'delivered' + return { delivered: true } + }) + } + + return { delivered: true } } - return { delivered: true } + if (process.platform !== 'darwin') { + return deliverNativeNotification() + } + // Why: macOS silently swallows accepted notifications while permission + // is denied or the permission dialog is unanswered (verified on macOS + // 26). Skip the doomed native notification and tell the caller, so the + // renderer can surface an in-app fallback pointing at System Settings. + // The mobile dispatch above is unaffected — paired devices have their + // own notification channel. + return readNotificationAuthorizationStatus().then((authorization) => { + if (authorization === 'denied' || authorization === 'not-determined') { + lastObservedDeliveryOutcome = 'failed' + return { delivered: false, reason: 'blocked-by-system' } + } + return deliverNativeNotification() + }) } ) @@ -564,6 +762,7 @@ export function triggerStartupNotificationRegistration(store: Store): void { // Why: Electron 42 requires code-signed macOS apps for UNNotification // delivery. Unsigned builds fail here instead of producing the permission UI. logNativeNotificationFailure('startup registration', error) + lastObservedDeliveryOutcome = 'failed' cleanup() } diff --git a/src/main/ipc/orca-profile-auth-handlers.test.ts b/src/main/ipc/orca-profile-auth-handlers.test.ts new file mode 100644 index 00000000000..73726dd2b52 --- /dev/null +++ b/src/main/ipc/orca-profile-auth-handlers.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handlers, + createCloudLinkedOrcaProfileMock, + connectCurrentOrcaProfileMock, + getCurrentOrcaProfileAuthStatusMock, + refreshCurrentOrcaProfileAuthMock, + selectCurrentOrcaProfileOrgMock, + signOutCurrentOrcaProfileMock +} = vi.hoisted(() => ({ + handlers: new Map unknown>(), + createCloudLinkedOrcaProfileMock: vi.fn(), + connectCurrentOrcaProfileMock: vi.fn(), + getCurrentOrcaProfileAuthStatusMock: vi.fn(), + refreshCurrentOrcaProfileAuthMock: vi.fn(), + selectCurrentOrcaProfileOrgMock: vi.fn(), + signOutCurrentOrcaProfileMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + exit: vi.fn(), + getPath: () => '/tmp/orca-user-data', + relaunch: vi.fn() + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }) + } +})) + +vi.mock('../tray/system-tray', () => ({ + destroySystemTray: vi.fn() +})) + +vi.mock('../orca-profiles/profile-index-store', () => ({ + createLocalOrcaProfile: vi.fn(), + getOrcaProfileListState: vi.fn(), + seedNewOrcaProfileTelemetryConsent: vi.fn(), + setActiveOrcaProfile: vi.fn() +})) + +vi.mock('../orca-profiles/profile-project-transfer', () => ({ + transferOrcaProfileProject: vi.fn() +})) + +vi.mock('../orca-profiles/profile-cloud-service', () => ({ + createCloudLinkedOrcaProfile: createCloudLinkedOrcaProfileMock, + connectCurrentOrcaProfile: connectCurrentOrcaProfileMock, + getCurrentOrcaProfileAuthStatus: getCurrentOrcaProfileAuthStatusMock, + refreshCurrentOrcaProfileAuth: refreshCurrentOrcaProfileAuthMock, + selectCurrentOrcaProfileOrg: selectCurrentOrcaProfileOrgMock, + signOutCurrentOrcaProfile: signOutCurrentOrcaProfileMock +})) + +import { registerOrcaProfileHandlers } from './orca-profiles' + +describe('registerOrcaProfileHandlers auth channels', () => { + beforeEach(() => { + handlers.clear() + createCloudLinkedOrcaProfileMock.mockReset() + connectCurrentOrcaProfileMock.mockReset() + getCurrentOrcaProfileAuthStatusMock.mockReset() + refreshCurrentOrcaProfileAuthMock.mockReset() + selectCurrentOrcaProfileOrgMock.mockReset() + signOutCurrentOrcaProfileMock.mockReset() + }) + + it('returns auth status for the current profile', async () => { + const status = { + activeProfileId: 'local-default', + configured: false, + state: 'unconfigured', + persistence: 'none' + } + getCurrentOrcaProfileAuthStatusMock.mockReturnValue(status) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) + + await expect(Promise.resolve(handlers.get('orcaProfiles:authStatus')?.(null))).resolves.toBe( + status + ) + expect(getCurrentOrcaProfileAuthStatusMock).toHaveBeenCalledWith('/tmp/orca-user-data') + }) + + it('connects and signs out the current profile through the cloud service', async () => { + const connectResult = { status: 'unconfigured', auth: { activeProfileId: 'local-default' } } + const signOutResult = { status: 'signed-out', auth: { activeProfileId: 'local-default' } } + connectCurrentOrcaProfileMock.mockResolvedValue(connectResult) + signOutCurrentOrcaProfileMock.mockResolvedValue(signOutResult) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) + + await expect( + Promise.resolve(handlers.get('orcaProfiles:connectCurrent')?.(null)) + ).resolves.toBe(connectResult) + await expect( + Promise.resolve(handlers.get('orcaProfiles:signOutCurrent')?.(null)) + ).resolves.toBe(signOutResult) + expect(connectCurrentOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data') + expect(signOutCurrentOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data') + }) + + it('refreshes profile auth through the cloud service', async () => { + const refreshResult = { status: 'refreshed', auth: { activeProfileId: 'local-default' } } + refreshCurrentOrcaProfileAuthMock.mockResolvedValue(refreshResult) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) + + await expect(Promise.resolve(handlers.get('orcaProfiles:refreshAuth')?.(null))).resolves.toBe( + refreshResult + ) + expect(refreshCurrentOrcaProfileAuthMock).toHaveBeenCalledWith('/tmp/orca-user-data') + }) + + it('validates organization selection before calling the cloud service', async () => { + const selectResult = { status: 'selected', auth: { activeProfileId: 'local-default' } } + selectCurrentOrcaProfileOrgMock.mockResolvedValue(selectResult) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) + + await expect( + Promise.resolve(handlers.get('orcaProfiles:selectOrg')?.(null, { orgId: ' org-1 ' })) + ).resolves.toBe(selectResult) + expect(selectCurrentOrcaProfileOrgMock).toHaveBeenCalledWith('/tmp/orca-user-data', 'org-1') + + await expect( + Promise.resolve(handlers.get('orcaProfiles:selectOrg')?.(null, { orgId: ' ' })) + ).rejects.toThrow('invalid_orca_profile_org_selection') + }) + + it('creates cloud-linked profiles with trimmed optional args', async () => { + const createResult = { + status: 'created', + auth: { activeProfileId: 'local-default' }, + activeProfileId: 'local-default', + profiles: [], + profile: { id: 'cloud-1' } + } + createCloudLinkedOrcaProfileMock.mockResolvedValue(createResult) + registerOrcaProfileHandlers({ + flush: vi.fn(), + freezeWrites: vi.fn(), + getSettings: () => ({}) + } as never) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:createCloudLinked')?.(null, { orgId: ' org-1 ', name: ' Acme ' }) + ) + ).resolves.toBe(createResult) + expect(createCloudLinkedOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data', { + orgId: 'org-1', + name: 'Acme' + }) + }) +}) diff --git a/src/main/ipc/orca-profile-org-members-handlers.test.ts b/src/main/ipc/orca-profile-org-members-handlers.test.ts new file mode 100644 index 00000000000..6dc6775a638 --- /dev/null +++ b/src/main/ipc/orca-profile-org-members-handlers.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handlers, + listOrcaProfileOrgMembersMock, + inviteOrcaProfileOrgMemberMock, + revokeOrcaProfileOrgInviteMock, + changeOrcaProfileOrgMemberRoleMock, + removeOrcaProfileOrgMemberMock +} = vi.hoisted(() => ({ + handlers: new Map unknown>(), + listOrcaProfileOrgMembersMock: vi.fn(), + inviteOrcaProfileOrgMemberMock: vi.fn(), + revokeOrcaProfileOrgInviteMock: vi.fn(), + changeOrcaProfileOrgMemberRoleMock: vi.fn(), + removeOrcaProfileOrgMemberMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }) + } +})) + +vi.mock('../orca-profiles/profile-storage-paths', () => ({ + getProfileUserDataPath: () => '/tmp/orca-user-data' +})) + +vi.mock('../orca-profiles/profile-cloud-org-members-service', () => ({ + listOrcaProfileOrgMembers: listOrcaProfileOrgMembersMock, + inviteOrcaProfileOrgMember: inviteOrcaProfileOrgMemberMock, + revokeOrcaProfileOrgInvite: revokeOrcaProfileOrgInviteMock, + changeOrcaProfileOrgMemberRole: changeOrcaProfileOrgMemberRoleMock, + removeOrcaProfileOrgMember: removeOrcaProfileOrgMemberMock +})) + +import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers' + +function invoke(channel: string, args?: unknown): unknown { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`No handler for ${channel}`) + } + return handler({}, args) +} + +describe('registerOrcaProfileOrgMemberHandlers', () => { + beforeEach(() => { + handlers.clear() + listOrcaProfileOrgMembersMock.mockReset().mockResolvedValue({ status: 'ok', roster: {} }) + inviteOrcaProfileOrgMemberMock.mockReset().mockResolvedValue({ status: 'ok' }) + revokeOrcaProfileOrgInviteMock.mockReset().mockResolvedValue({ status: 'ok' }) + changeOrcaProfileOrgMemberRoleMock.mockReset().mockResolvedValue({ status: 'ok' }) + removeOrcaProfileOrgMemberMock.mockReset().mockResolvedValue({ status: 'ok' }) + registerOrcaProfileOrgMemberHandlers() + }) + + it('registers all five org-member channels', () => { + expect([...handlers.keys()].sort()).toEqual( + [ + 'orcaProfiles:orgInviteRevoke', + 'orcaProfiles:orgMemberChangeRole', + 'orcaProfiles:orgMemberInvite', + 'orcaProfiles:orgMemberRemove', + 'orcaProfiles:orgMembersList' + ].sort() + ) + }) + + it('forwards a valid invite to the service with a trimmed email', async () => { + await invoke('orcaProfiles:orgMemberInvite', { + orgId: 'org-1', + email: ' new@example.com ', + role: 'admin' + }) + expect(inviteOrcaProfileOrgMemberMock).toHaveBeenCalledWith('/tmp/orca-user-data', { + orgId: 'org-1', + email: 'new@example.com', + role: 'admin' + }) + }) + + it('rejects an invite with a missing org id', async () => { + await expect( + invoke('orcaProfiles:orgMemberInvite', { email: 'a@b.com', role: 'member' }) + ).rejects.toThrow('invalid_orca_profile_org_selection') + expect(inviteOrcaProfileOrgMemberMock).not.toHaveBeenCalled() + }) + + it('rejects an invite with an unknown role', async () => { + await expect( + invoke('orcaProfiles:orgMemberInvite', { orgId: 'org-1', email: 'a@b.com', role: 'root' }) + ).rejects.toThrow('invalid_orca_org_role') + }) + + it('rejects a role change with a blank user id', async () => { + await expect( + invoke('orcaProfiles:orgMemberChangeRole', { orgId: 'org-1', userId: ' ', role: 'admin' }) + ).rejects.toThrow('invalid_orca_org_member_user') + }) + + it('forwards remove and revoke with validated args', async () => { + await invoke('orcaProfiles:orgMemberRemove', { orgId: 'org-1', userId: 'user-2' }) + expect(removeOrcaProfileOrgMemberMock).toHaveBeenCalledWith('/tmp/orca-user-data', { + orgId: 'org-1', + userId: 'user-2' + }) + await invoke('orcaProfiles:orgInviteRevoke', { orgId: 'org-1', email: 'gone@b.com' }) + expect(revokeOrcaProfileOrgInviteMock).toHaveBeenCalledWith('/tmp/orca-user-data', { + orgId: 'org-1', + email: 'gone@b.com' + }) + }) +}) diff --git a/src/main/ipc/orca-profile-org-members-handlers.ts b/src/main/ipc/orca-profile-org-members-handlers.ts new file mode 100644 index 00000000000..40eab31b7d4 --- /dev/null +++ b/src/main/ipc/orca-profile-org-members-handlers.ts @@ -0,0 +1,128 @@ +import { ipcMain } from 'electron' +import type { + OrcaOrgRole, + OrcaProfileOrgInviteRevokeArgs, + OrcaProfileOrgMemberChangeRoleArgs, + OrcaProfileOrgMemberInviteArgs, + OrcaProfileOrgMemberMutationResult, + OrcaProfileOrgMemberRemoveArgs, + OrcaProfileOrgMembersListArgs, + OrcaProfileOrgMembersListResult +} from '../../shared/orca-profiles' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { + changeOrcaProfileOrgMemberRole, + inviteOrcaProfileOrgMember, + listOrcaProfileOrgMembers, + removeOrcaProfileOrgMember, + revokeOrcaProfileOrgInvite +} from '../orca-profiles/profile-cloud-org-members-service' + +function orgMembersScopedArgs(args: unknown): { orgId: string; record: Record } { + if (!args || typeof args !== 'object') { + throw new Error('invalid_orca_profile_org_selection') + } + const record = args as Record + const orgId = typeof record.orgId === 'string' ? record.orgId.trim() : '' + if (!orgId) { + throw new Error('invalid_orca_profile_org_selection') + } + return { orgId, record } +} + +function orgRoleFromUnknown(value: unknown): OrcaOrgRole { + if (value === 'owner' || value === 'admin' || value === 'member') { + return value + } + throw new Error('invalid_orca_org_role') +} + +function orgEmailFromUnknown(value: unknown): string { + const email = typeof value === 'string' ? value.trim() : '' + if (!email) { + throw new Error('invalid_orca_org_member_email') + } + return email +} + +function orgUserIdFromUnknown(value: unknown): string { + const userId = typeof value === 'string' ? value.trim() : '' + if (!userId) { + throw new Error('invalid_orca_org_member_user') + } + return userId +} + +function orgMemberInviteArgsFromUnknown(args: unknown): OrcaProfileOrgMemberInviteArgs { + const { orgId, record } = orgMembersScopedArgs(args) + return { orgId, email: orgEmailFromUnknown(record.email), role: orgRoleFromUnknown(record.role) } +} + +function orgInviteRevokeArgsFromUnknown(args: unknown): OrcaProfileOrgInviteRevokeArgs { + const { orgId, record } = orgMembersScopedArgs(args) + return { orgId, email: orgEmailFromUnknown(record.email) } +} + +function orgMemberChangeRoleArgsFromUnknown(args: unknown): OrcaProfileOrgMemberChangeRoleArgs { + const { orgId, record } = orgMembersScopedArgs(args) + return { + orgId, + userId: orgUserIdFromUnknown(record.userId), + role: orgRoleFromUnknown(record.role) + } +} + +function orgMemberRemoveArgsFromUnknown(args: unknown): OrcaProfileOrgMemberRemoveArgs { + const { orgId, record } = orgMembersScopedArgs(args) + return { orgId, userId: orgUserIdFromUnknown(record.userId) } +} + +export function registerOrcaProfileOrgMemberHandlers(): void { + ipcMain.handle( + 'orcaProfiles:orgMembersList', + async ( + _event, + rawArgs: OrcaProfileOrgMembersListArgs + ): Promise => + listOrcaProfileOrgMembers(getProfileUserDataPath(), orgMembersScopedArgs(rawArgs).orgId) + ) + + ipcMain.handle( + 'orcaProfiles:orgMemberInvite', + async ( + _event, + rawArgs: OrcaProfileOrgMemberInviteArgs + ): Promise => + inviteOrcaProfileOrgMember(getProfileUserDataPath(), orgMemberInviteArgsFromUnknown(rawArgs)) + ) + + ipcMain.handle( + 'orcaProfiles:orgInviteRevoke', + async ( + _event, + rawArgs: OrcaProfileOrgInviteRevokeArgs + ): Promise => + revokeOrcaProfileOrgInvite(getProfileUserDataPath(), orgInviteRevokeArgsFromUnknown(rawArgs)) + ) + + ipcMain.handle( + 'orcaProfiles:orgMemberChangeRole', + async ( + _event, + rawArgs: OrcaProfileOrgMemberChangeRoleArgs + ): Promise => + changeOrcaProfileOrgMemberRole( + getProfileUserDataPath(), + orgMemberChangeRoleArgsFromUnknown(rawArgs) + ) + ) + + ipcMain.handle( + 'orcaProfiles:orgMemberRemove', + async ( + _event, + rawArgs: OrcaProfileOrgMemberRemoveArgs + ): Promise => + removeOrcaProfileOrgMember(getProfileUserDataPath(), orgMemberRemoveArgsFromUnknown(rawArgs)) + ) +} diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts new file mode 100644 index 00000000000..291a3dfe2f2 --- /dev/null +++ b/src/main/ipc/orca-profiles.test.ts @@ -0,0 +1,321 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handlers, + appExitMock, + appQuitMock, + appRelaunchMock, + destroySystemTrayMock, + createLocalOrcaProfileMock, + getOrcaProfileListStateMock, + seedNewOrcaProfileTelemetryConsentMock, + setActiveOrcaProfileMock, + transferOrcaProfileProjectMock +} = vi.hoisted(() => ({ + handlers: new Map unknown>(), + appExitMock: vi.fn(), + appQuitMock: vi.fn(), + appRelaunchMock: vi.fn(), + destroySystemTrayMock: vi.fn(), + createLocalOrcaProfileMock: vi.fn(), + getOrcaProfileListStateMock: vi.fn(), + seedNewOrcaProfileTelemetryConsentMock: vi.fn(), + setActiveOrcaProfileMock: vi.fn(), + transferOrcaProfileProjectMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + exit: appExitMock, + quit: appQuitMock, + relaunch: appRelaunchMock, + getPath: () => '/tmp/orca-user-data' + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }) + } +})) + +vi.mock('../tray/system-tray', () => ({ + destroySystemTray: destroySystemTrayMock +})) + +vi.mock('../orca-profiles/profile-index-store', () => ({ + createLocalOrcaProfile: createLocalOrcaProfileMock, + getOrcaProfileListState: getOrcaProfileListStateMock, + seedNewOrcaProfileTelemetryConsent: seedNewOrcaProfileTelemetryConsentMock, + setActiveOrcaProfile: setActiveOrcaProfileMock +})) + +function makeStoreMock(flush = vi.fn()): { + flush: typeof flush + freezeWrites: ReturnType + getSettings: () => Record +} { + return { flush, freezeWrites: vi.fn(), getSettings: () => ({}) } +} + +vi.mock('../orca-profiles/profile-project-transfer', () => ({ + transferOrcaProfileProject: transferOrcaProfileProjectMock +})) + +import { registerOrcaProfileHandlers } from './orca-profiles' + +describe('registerOrcaProfileHandlers', () => { + beforeEach(() => { + vi.useFakeTimers() + handlers.clear() + appExitMock.mockReset() + appQuitMock.mockReset() + appRelaunchMock.mockReset() + destroySystemTrayMock.mockReset() + createLocalOrcaProfileMock.mockReset() + getOrcaProfileListStateMock.mockReset() + seedNewOrcaProfileTelemetryConsentMock.mockReset() + setActiveOrcaProfileMock.mockReset() + transferOrcaProfileProjectMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('registers list and create handlers', async () => { + const listState = { + activeProfileId: 'local-default', + profiles: [{ id: 'local-default', name: 'Personal' }] + } + const createState = { + ...listState, + profile: { id: 'local-work', name: 'Work' } + } + getOrcaProfileListStateMock.mockReturnValue(listState) + createLocalOrcaProfileMock.mockReturnValue(createState) + + registerOrcaProfileHandlers(makeStoreMock() as never) + + await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({ + ...listState, + multiProfileUi: false + }) + await expect( + Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(null, { name: 'Work' })) + ).resolves.toBe(createState) + expect(createLocalOrcaProfileMock).toHaveBeenCalledWith({ name: 'Work' }) + }) + + it('reports multiProfileUi when the env flag is set', async () => { + const previous = process.env.ORCA_MULTI_PROFILE_UI + process.env.ORCA_MULTI_PROFILE_UI = '1' + try { + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'local-default', + profiles: [] + }) + registerOrcaProfileHandlers(makeStoreMock() as never) + + await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({ + activeProfileId: 'local-default', + profiles: [], + multiProfileUi: true + }) + } finally { + if (previous === undefined) { + delete process.env.ORCA_MULTI_PROFILE_UI + } else { + process.env.ORCA_MULTI_PROFILE_UI = previous + } + } + }) + + it('marks the target profile active, flushes, and relaunches', async () => { + const flush = vi.fn() + const onBeforeRelaunch = vi.fn() + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'local-default', + profiles: [] + }) + setActiveOrcaProfileMock.mockReturnValue({ + activeProfileId: 'local-work', + profiles: [] + }) + registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) + + const resultPromise = Promise.resolve( + handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }) + ) + + await expect(resultPromise).resolves.toEqual({ status: 'relaunching' }) + expect(setActiveOrcaProfileMock).toHaveBeenCalledWith('local-work') + expect(flush).toHaveBeenCalledOnce() + expect(onBeforeRelaunch).toHaveBeenCalledOnce() + expect(flush.mock.invocationCallOrder[0]).toBeLessThan( + setActiveOrcaProfileMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + expect(appRelaunchMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(150) + + expect(appRelaunchMock).toHaveBeenCalledOnce() + // Why quit, not exit: before-quit/will-quit teardown (scrollback capture, + // PTY kill, daemon checkpoints) must run on a profile switch. + expect(appQuitMock).toHaveBeenCalledOnce() + expect(appExitMock).not.toHaveBeenCalled() + }) + + it('does not mark a profile active when current profile flush fails', async () => { + const flush = vi.fn(() => { + throw new Error('flush_failed') + }) + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'local-default', + profiles: [] + }) + registerOrcaProfileHandlers(makeStoreMock(flush) as never) + + await expect( + Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })) + ).rejects.toThrow('flush_failed') + + expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() + expect(appRelaunchMock).not.toHaveBeenCalled() + }) + + it('does not relaunch when switching to the active profile', async () => { + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'local-default', + profiles: [] + }) + registerOrcaProfileHandlers(makeStoreMock() as never) + + await expect( + Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-default' })) + ).resolves.toEqual({ status: 'already-active' }) + + expect(setActiveOrcaProfileMock).not.toHaveBeenCalled() + expect(appRelaunchMock).not.toHaveBeenCalled() + }) + + it('rejects invalid profile ids', async () => { + registerOrcaProfileHandlers(makeStoreMock() as never) + + await expect( + Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: ' ' })) + ).rejects.toThrow('invalid_orca_profile_id') + }) + + it('transfers projects between inactive profiles after flushing active state', async () => { + const flush = vi.fn() + const result = { + status: 'transferred', + mode: 'copy', + sourceProfileId: 'personal', + targetProfileId: 'work', + sourceRepoId: 'repo-1', + targetRepoId: 'repo-2', + targetProjectId: 'repo:repo-2' + } + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'personal', + profiles: [] + }) + transferOrcaProfileProjectMock.mockReturnValue(result) + registerOrcaProfileHandlers(makeStoreMock(flush) as never) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(null, { + sourceProfileId: ' personal ', + targetProfileId: ' work ', + repoId: ' repo-1 ', + mode: 'copy' + }) + ) + ).resolves.toBe(result) + + expect(flush).toHaveBeenCalledOnce() + expect(transferOrcaProfileProjectMock).toHaveBeenCalledWith( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + '/tmp/orca-user-data' + ) + }) + + it('moves a project out of the active profile and relaunches into the target profile', async () => { + const flush = vi.fn() + const onBeforeRelaunch = vi.fn() + const result = { + status: 'transferred', + mode: 'move', + sourceProfileId: 'personal', + targetProfileId: 'work', + sourceRepoId: 'repo-1', + targetRepoId: 'repo-1', + targetProjectId: 'repo:repo-1' + } + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'personal', + profiles: [] + }) + transferOrcaProfileProjectMock.mockReturnValue(result) + registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch }) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(null, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }) + ) + ).resolves.toEqual({ ...result, willRelaunch: true }) + + expect(onBeforeRelaunch).toHaveBeenCalledOnce() + expect(flush).toHaveBeenCalledOnce() + expect(transferOrcaProfileProjectMock).toHaveBeenCalledWith( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'move' + }, + '/tmp/orca-user-data' + ) + expect(setActiveOrcaProfileMock).toHaveBeenCalledWith('work') + expect(appRelaunchMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(150) + + expect(appRelaunchMock).toHaveBeenCalledOnce() + expect(appQuitMock).toHaveBeenCalledOnce() + expect(appExitMock).not.toHaveBeenCalled() + }) + + it('rejects transfers that would mutate the active target profile offline', async () => { + getOrcaProfileListStateMock.mockReturnValue({ + activeProfileId: 'work', + profiles: [] + }) + registerOrcaProfileHandlers(makeStoreMock() as never) + + await expect( + Promise.resolve( + handlers.get('orcaProfiles:transferProject')?.(null, { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }) + ) + ).rejects.toThrow('active_target_orca_profile_transfer_requires_relaunch') + + expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/orca-profiles.ts b/src/main/ipc/orca-profiles.ts new file mode 100644 index 00000000000..fa6a57859e9 --- /dev/null +++ b/src/main/ipc/orca-profiles.ts @@ -0,0 +1,291 @@ +import { app, ipcMain } from 'electron' +import type { Store } from '../persistence' +import type { + CreateLocalOrcaProfileArgs, + CreateLocalOrcaProfileResult, + CreateCloudLinkedOrcaProfileArgs, + CreateCloudLinkedOrcaProfileResult, + FindOrcaProfileProjectsByPathArgs, + FindOrcaProfileProjectsByPathResult, + OrcaProfileListResult, + RefreshCurrentOrcaProfileAuthResult, + SwitchOrcaProfileArgs, + SwitchOrcaProfileResult, + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult, + ConnectCurrentOrcaProfileResult, + OrcaProfileAuthStatus, + SelectOrcaProfileOrgArgs, + SelectOrcaProfileOrgResult, + SignOutCurrentOrcaProfileResult +} from '../../shared/orca-profiles' +import { + createLocalOrcaProfile, + getOrcaProfileListState, + seedNewOrcaProfileTelemetryConsent, + setActiveOrcaProfile +} from '../orca-profiles/profile-index-store' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope' +import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer' +import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence' +import { normalizeExecutionHostId } from '../../shared/execution-host' +import { + createCloudLinkedOrcaProfile, + connectCurrentOrcaProfile, + getCurrentOrcaProfileAuthStatus, + refreshCurrentOrcaProfileAuth, + selectCurrentOrcaProfileOrg, + signOutCurrentOrcaProfile +} from '../orca-profiles/profile-cloud-service' +import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers' + +type RegisterOrcaProfileHandlersOptions = { + onBeforeRelaunch?: () => void | Promise +} + +function profileIdFromArgs(args: unknown): string { + if ( + !args || + typeof args !== 'object' || + typeof (args as SwitchOrcaProfileArgs).profileId !== 'string' + ) { + throw new Error('invalid_orca_profile_id') + } + const profileId = (args as SwitchOrcaProfileArgs).profileId.trim() + if (!profileId) { + throw new Error('invalid_orca_profile_id') + } + return profileId +} + +function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs { + if (!args || typeof args !== 'object') { + throw new Error('invalid_orca_profile_project_transfer') + } + const candidate = args as TransferOrcaProfileProjectArgs + const sourceProfileId = candidate.sourceProfileId?.trim() + const targetProfileId = candidate.targetProfileId?.trim() + const repoId = candidate.repoId?.trim() + const mode = candidate.mode + if (!sourceProfileId || !targetProfileId || !repoId || (mode !== 'move' && mode !== 'copy')) { + throw new Error('invalid_orca_profile_project_transfer') + } + return { + sourceProfileId, + targetProfileId, + repoId, + mode + } +} + +function findProjectsByPathArgsFromUnknown(args: unknown): FindOrcaProfileProjectsByPathArgs { + if (!args || typeof args !== 'object') { + throw new Error('invalid_orca_profile_project_path') + } + const candidate = args as FindOrcaProfileProjectsByPathArgs + const path = typeof candidate.path === 'string' ? candidate.path.trim() : '' + if (!path) { + throw new Error('invalid_orca_profile_project_path') + } + let executionHostId: FindOrcaProfileProjectsByPathArgs['executionHostId'] = null + if (candidate.executionHostId !== null && candidate.executionHostId !== undefined) { + if (typeof candidate.executionHostId !== 'string') { + throw new Error('invalid_orca_profile_project_path') + } + executionHostId = normalizeExecutionHostId(candidate.executionHostId) + if (!executionHostId) { + throw new Error('invalid_orca_profile_project_path') + } + } + return { + path, + connectionId: + typeof candidate.connectionId === 'string' ? candidate.connectionId.trim() || null : null, + executionHostId, + excludeProfileId: + typeof candidate.excludeProfileId === 'string' + ? candidate.excludeProfileId.trim() || null + : null + } +} + +function orgIdFromUnknown(args: unknown): string { + if (!args || typeof args !== 'object') { + throw new Error('invalid_orca_profile_org_selection') + } + const orgId = (args as SelectOrcaProfileOrgArgs).orgId?.trim() + if (!orgId) { + throw new Error('invalid_orca_profile_org_selection') + } + return orgId +} + +function createCloudLinkedProfileArgsFromUnknown(args: unknown): CreateCloudLinkedOrcaProfileArgs { + if (!args || typeof args !== 'object') { + return {} + } + const candidate = args as CreateCloudLinkedOrcaProfileArgs + const orgId = typeof candidate.orgId === 'string' ? candidate.orgId.trim() : undefined + const name = typeof candidate.name === 'string' ? candidate.name.trim() : undefined + return { + ...(orgId ? { orgId } : {}), + ...(name ? { name } : {}) + } +} + +async function runBeforeProfileRelaunch( + onBeforeRelaunch?: () => void | Promise +): Promise { + try { + await onBeforeRelaunch?.() + } catch (error) { + console.warn( + '[orca-profiles] Pre-relaunch cleanup failed; continuing profile switch:', + error instanceof Error ? error.name : typeof error + ) + } +} + +function scheduleProfileRelaunch(): void { + setTimeout(() => { + app.relaunch() + // Why: app.quit() (not app.exit) so before-quit/will-quit still run — + // renderer scrollback capture, PTY kill, stats flush, and daemon final + // checkpoints must not be skipped on a profile switch. + app.quit() + }, 150) +} + +export function registerOrcaProfileHandlers( + store: Store, + options: RegisterOrcaProfileHandlersOptions = {} +): void { + ipcMain.handle( + 'orcaProfiles:list', + (): OrcaProfileListResult => ({ + ...getOrcaProfileListState(), + multiProfileUi: isMultiProfileUiEnabled() + }) + ) + + ipcMain.handle( + 'orcaProfiles:authStatus', + (): OrcaProfileAuthStatus => getCurrentOrcaProfileAuthStatus(getProfileUserDataPath()) + ) + + ipcMain.handle( + 'orcaProfiles:createLocal', + (_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => { + const result = createLocalOrcaProfile(args) + seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry) + return result + } + ) + + ipcMain.handle( + 'orcaProfiles:switch', + async (_event, args: SwitchOrcaProfileArgs): Promise => { + const profileId = profileIdFromArgs(args) + const current = getOrcaProfileListState() + if (profileId === current.activeProfileId) { + return { status: 'already-active' } + } + + // Why: the current profile must be persisted before the global index + // points startup at the target profile. + await runBeforeProfileRelaunch(options.onBeforeRelaunch) + store.flush() + setActiveOrcaProfile(profileId) + + scheduleProfileRelaunch() + + return { status: 'relaunching' } + } + ) + + ipcMain.handle( + 'orcaProfiles:transferProject', + async ( + _event, + rawArgs: TransferOrcaProfileProjectArgs + ): Promise => { + const args = transferProjectArgsFromUnknown(rawArgs) + const current = getOrcaProfileListState() + if (args.targetProfileId === current.activeProfileId) { + throw new Error('active_target_orca_profile_transfer_requires_relaunch') + } + if (args.mode === 'move' && args.sourceProfileId === current.activeProfileId) { + // Why: transfer before any relaunch side effect so a duplicate-target + // or validation failure cannot strand the app in a quitting state. + // flush→transfer→freeze runs synchronously with no interleaving, and + // the freeze keeps late sync saves from resurrecting the moved + // project from stale memory before the relaunch. + store.flush() + const result = transferOrcaProfileProject(args, getProfileUserDataPath()) + if (result.status === 'transferred') { + store.freezeWrites() + await runBeforeProfileRelaunch(options.onBeforeRelaunch) + setActiveOrcaProfile(args.targetProfileId) + scheduleProfileRelaunch() + return { ...result, willRelaunch: true } + } + return result + } + store.flush() + return transferOrcaProfileProject(args, getProfileUserDataPath()) + } + ) + + ipcMain.handle( + 'orcaProfiles:findProjectProfiles', + (_event, rawArgs: FindOrcaProfileProjectsByPathArgs): FindOrcaProfileProjectsByPathResult => + findOrcaProfileProjectsByPath( + findProjectsByPathArgsFromUnknown(rawArgs), + getProfileUserDataPath() + ) + ) + + ipcMain.handle( + 'orcaProfiles:connectCurrent', + async (): Promise => + connectCurrentOrcaProfile(getProfileUserDataPath()) + ) + + ipcMain.handle( + 'orcaProfiles:createCloudLinked', + async ( + _event, + rawArgs?: CreateCloudLinkedOrcaProfileArgs + ): Promise => { + const result = await createCloudLinkedOrcaProfile( + getProfileUserDataPath(), + createCloudLinkedProfileArgsFromUnknown(rawArgs) + ) + if (result.status === 'created') { + seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry) + } + return result + } + ) + + ipcMain.handle( + 'orcaProfiles:refreshAuth', + async (): Promise => + refreshCurrentOrcaProfileAuth(getProfileUserDataPath()) + ) + + ipcMain.handle( + 'orcaProfiles:signOutCurrent', + async (): Promise => + signOutCurrentOrcaProfile(getProfileUserDataPath()) + ) + + ipcMain.handle( + 'orcaProfiles:selectOrg', + async (_event, rawArgs: SelectOrcaProfileOrgArgs): Promise => + selectCurrentOrcaProfileOrg(getProfileUserDataPath(), orgIdFromUnknown(rawArgs)) + ) + + registerOrcaProfileOrgMemberHandlers() +} diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts new file mode 100644 index 00000000000..03676721df2 --- /dev/null +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -0,0 +1,194 @@ +// Forked (ELECTRON_RUN_AS_NODE) child that hosts all local @parcel/watcher +// subscriptions. Why: watcher.node has native teardown races that fail-fast +// the hosting process (issue #7547, 0xc0000409 on Windows; same class as +// #5377/#6635). Running the native module here turns a watcher fault into a +// contained child crash the host can recover from, instead of killing the app +// and every agent session in it. +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as ParcelWatcher from '@parcel/watcher' + +export type WatcherProcessEvent = { + type: 'create' | 'update' | 'delete' + path: string +} + +export type WatcherProcessSubscribeOptions = { + ignore?: string[] + backend?: string +} + +export type HostToWatcherMessage = + | { op: 'subscribe'; id: number; dir: string; opts: WatcherProcessSubscribeOptions } + | { op: 'unsubscribe'; id: number } + +export type WatcherToHostMessage = + | { op: 'subscribed'; id: number } + | { op: 'subscribe-failed'; id: number; message: string } + | { op: 'events'; id: number; events: WatcherProcessEvent[] } + | { op: 'watch-error'; id: number; message: string } + | { op: 'unsubscribed'; id: number } + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +// Canary self-check. Why: @parcel/watcher can wedge silently — a lock-order +// inversion between Debounce::notify (debounce mutex → watcher mutex) and +// Watcher teardown (watcher mutex → debounce mutex in ~Watcher) deadlocks the +// single process-wide debounce thread, after which every subscription still +// acks but no events are ever delivered. The canary watches a private temp +// dir and touches a file in it; consecutive missed deliveries mean event +// delivery is dead, so exit and let the host respawn a fresh process. +const CANARY_INTERVAL_MS = 10_000 +const CANARY_EVENT_TIMEOUT_MS = 5_000 +const CANARY_MAX_MISSES = 2 + +async function startCanary(hasLiveSubscriptions: () => boolean): Promise { + let canaryDir: string + let lastEventAt = 0 + try { + canaryDir = mkdtempSync(join(tmpdir(), 'orca-watcher-canary-')) + const watcher = await import('@parcel/watcher') + // Why: pin the Windows backend like the main subscriptions do, so the + // canary never probes for Watchman. + const opts = ( + process.platform === 'win32' ? { backend: 'windows' } : {} + ) as ParcelWatcher.Options + await watcher.subscribe( + canaryDir, + (err) => { + if (!err) { + lastEventAt = Date.now() + } + }, + opts + ) + } catch (err) { + process.stderr.write(`[parcel-watcher-process] canary unavailable: ${errorMessage(err)}\n`) + return + } + process.on('exit', () => { + try { + rmSync(canaryDir, { recursive: true, force: true }) + } catch { + // Temp dir cleanup is best-effort. + } + }) + + let misses = 0 + setInterval(() => { + // An idle watcher process can't wedge anything visible; only probe while + // roots are subscribed so an idle child doesn't restart pointlessly. + if (!hasLiveSubscriptions()) { + misses = 0 + return + } + const probedAt = Date.now() + try { + writeFileSync(join(canaryDir, 'canary.txt'), String(probedAt)) + } catch { + return + } + setTimeout(() => { + if (lastEventAt >= probedAt) { + misses = 0 + return + } + misses++ + if (misses >= CANARY_MAX_MISSES) { + process.stderr.write( + '[parcel-watcher-process] event delivery wedged (canary starved); restarting watcher process\n' + ) + process.exit(2) + } + }, CANARY_EVENT_TIMEOUT_MS) + }, CANARY_INTERVAL_MS) +} + +function main(): void { + const send = (message: WatcherToHostMessage): void => { + try { + process.send?.(message) + } catch { + // Host is gone; the disconnect handler below exits this process. + } + } + + // Subscribe promises are kept (not just subscriptions) so an unsubscribe + // that races a still-crawling subscribe awaits it instead of leaking the + // native handle — on Windows a leaked handle keeps the worktree dir locked. + const subscriptions = new Map>() + + const handleSubscribe = async ( + id: number, + dir: string, + opts: WatcherProcessSubscribeOptions + ): Promise => { + try { + const watcher = await import('@parcel/watcher') + const subscription = await watcher.subscribe( + dir, + (err, events) => { + if (err) { + send({ op: 'watch-error', id, message: errorMessage(err) }) + return + } + if (events.length > 0) { + send({ + op: 'events', + id, + events: events.map((event) => ({ type: event.type, path: event.path })) + }) + } + }, + opts as ParcelWatcher.Options + ) + send({ op: 'subscribed', id }) + return subscription + } catch (err) { + subscriptions.delete(id) + send({ op: 'subscribe-failed', id, message: errorMessage(err) }) + return null + } + } + + const handleUnsubscribe = async (id: number): Promise => { + const pending = subscriptions.get(id) + subscriptions.delete(id) + try { + const subscription = await pending + await subscription?.unsubscribe() + } catch (err) { + process.stderr.write( + `[parcel-watcher-process] unsubscribe ${id} failed: ${errorMessage(err)}\n` + ) + } + send({ op: 'unsubscribed', id }) + } + + process.on('message', (message: HostToWatcherMessage) => { + if (!message || typeof message !== 'object') { + return + } + if (message.op === 'subscribe') { + subscriptions.set(message.id, handleSubscribe(message.id, message.dir, message.opts)) + return + } + if (message.op === 'unsubscribe') { + void handleUnsubscribe(message.id) + } + }) + + void startCanary(() => subscriptions.size > 0) + + // Why: if the host dies (or kills us during shutdown), exit immediately — + // process death releases every native watcher handle without running the + // crash-prone napi teardown at all. + process.on('disconnect', () => { + process.exit(0) + }) +} + +main() diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts new file mode 100644 index 00000000000..4df3b666fbb --- /dev/null +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -0,0 +1,286 @@ +// Regression tests for issue #7547: local @parcel/watcher subscriptions run +// in a forked watcher process, and a native watcher crash must be contained — +// respawn, resubscribe, notify interruption — instead of killing the app. +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { forkMock, existsSyncMock, parcelSubscribeMock } = vi.hoisted(() => ({ + forkMock: vi.fn(), + existsSyncMock: vi.fn(), + parcelSubscribeMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ fork: forkMock })) +vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) +vi.mock('@parcel/watcher', () => ({ subscribe: parcelSubscribeMock })) + +import { + disposeWatcherProcess, + resetWatcherProcessForTest, + subscribeViaWatcherProcess +} from './parcel-watcher-process' + +type SentMessage = { op: string; id: number; dir?: string } + +class FakeChild extends EventEmitter { + connected = true + sent: SentMessage[] = [] + stderr = new EventEmitter() + kill = vi.fn(() => { + this.connected = false + }) + send = vi.fn((message: SentMessage) => { + this.sent.push(message) + return true + }) +} + +function currentChild(): FakeChild { + const result = forkMock.mock.results.at(-1) + if (!result) { + throw new Error('fork was not called') + } + return result.value as FakeChild +} + +function ackSubscribe(child: FakeChild, index = -1): number { + const message = child.sent.filter((m) => m.op === 'subscribe').at(index) + if (!message) { + throw new Error('no subscribe message sent') + } + child.emit('message', { op: 'subscribed', id: message.id }) + return message.id +} + +describe('subscribeViaWatcherProcess', () => { + beforeEach(() => { + resetWatcherProcessForTest() + // Why: the client deliberately runs in-process under vitest; these tests + // exercise the forked-process mode, so hide the vitest marker. + vi.stubEnv('VITEST', '') + existsSyncMock.mockReturnValue(true) + forkMock.mockImplementation(() => new FakeChild()) + }) + + afterEach(() => { + disposeWatcherProcess() + vi.unstubAllEnvs() + vi.clearAllMocks() + }) + + it('subscribes through the watcher process and forwards events', async () => { + const callback = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', callback, { ignore: ['**/.git'] }) + const child = currentChild() + expect(child.sent[0]).toMatchObject({ op: 'subscribe', dir: '/repo' }) + const id = ackSubscribe(child) + await promise + + const events = [{ type: 'update', path: '/repo/a.txt' }] + child.emit('message', { op: 'events', id, events }) + expect(callback).toHaveBeenCalledWith(null, events) + }) + + it('forwards watcher errors to the callback', async () => { + const callback = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', callback, {}) + const child = currentChild() + const id = ackSubscribe(child) + await promise + + child.emit('message', { op: 'watch-error', id, message: 'boom' }) + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }), []) + }) + + it('rejects the subscribe when the watcher process reports failure', async () => { + const promise = subscribeViaWatcherProcess('/gone', vi.fn(), {}) + const child = currentChild() + const id = child.sent[0].id + child.emit('message', { op: 'subscribe-failed', id, message: 'Error opening directory' }) + await expect(promise).rejects.toThrow('Error opening directory') + }) + + it('resolves unsubscribe on the child ack', async () => { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const child = currentChild() + const id = ackSubscribe(child, 0) + const subscription = await promise + // Why: hold a second subscription so this unsubscribe exercises the ack + // path instead of the last-subscriber idle kill. + const keepAlivePromise = subscribeViaWatcherProcess('/other', vi.fn(), {}) + ackSubscribe(child) + await keepAlivePromise + + const unsubPromise = subscription.unsubscribe() + expect(child.sent.at(-1)).toEqual({ op: 'unsubscribe', id }) + child.emit('message', { op: 'unsubscribed', id }) + await expect(unsubPromise).resolves.toBeUndefined() + }) + + it('resolves a pending unsubscribe when the child dies', async () => { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child, 0) + const subscription = await promise + const keepAlivePromise = subscribeViaWatcherProcess('/other', vi.fn(), {}) + ackSubscribe(child) + await keepAlivePromise + + const unsubPromise = subscription.unsubscribe() + child.connected = false + child.emit('exit', 3221226505, null) + await expect(unsubPromise).resolves.toBeUndefined() + }) + + it('respawns after a crash, resubscribes, and reports the interruption', async () => { + const callback = vi.fn() + const onInterruption = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', callback, {}, onInterruption) + const first = currentChild() + const id = ackSubscribe(first) + await promise + + // Simulate the 0xc0000409 fail-fast: the watcher process dies. + first.connected = false + first.emit('exit', 3221226505, null) + + const second = currentChild() + expect(second).not.toBe(first) + expect(second.sent[0]).toMatchObject({ op: 'subscribe', id, dir: '/repo' }) + expect(onInterruption).not.toHaveBeenCalled() + + second.emit('message', { op: 'subscribed', id }) + expect(onInterruption).toHaveBeenCalledTimes(1) + + const events = [{ type: 'create', path: '/repo/b.txt' }] + second.emit('message', { op: 'events', id, events }) + expect(callback).toHaveBeenCalledWith(null, events) + }) + + it('completes an in-flight subscribe across a crash-respawn', async () => { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const first = currentChild() + expect(first.sent[0].op).toBe('subscribe') + + first.connected = false + first.emit('exit', 3221226505, null) + + const second = currentChild() + ackSubscribe(second) + await expect(promise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + + it('disables watching after repeated crashes instead of fork-bombing', async () => { + const callback = vi.fn() + const promise = subscribeViaWatcherProcess('/repo', callback, {}) + ackSubscribe(currentChild()) + await promise + + for (let crash = 0; crash < 2; crash++) { + const child = currentChild() + child.connected = false + child.emit('exit', 3221226505, null) + ackSubscribe(currentChild()) + } + expect(forkMock).toHaveBeenCalledTimes(3) + + const last = currentChild() + last.connected = false + last.emit('exit', 3221226505, null) + + expect(forkMock).toHaveBeenCalledTimes(3) + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('crashed repeatedly') }), + [] + ) + }) + + it('kills the idle child after the last unsubscribe and respawns on the next subscribe', async () => { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const first = currentChild() + ackSubscribe(first) + const subscription = await promise + + await expect(subscription.unsubscribe()).resolves.toBeUndefined() + expect(first.kill).toHaveBeenCalledTimes(1) + + // Why: the deliberate idle kill must not count as a crash — no respawn + // and no crash-fuse advance when the killed child's exit event lands. + first.connected = false + first.emit('exit', null, 'SIGTERM') + expect(forkMock).toHaveBeenCalledTimes(1) + + const respawnPromise = subscribeViaWatcherProcess('/repo2', vi.fn(), {}) + expect(forkMock).toHaveBeenCalledTimes(2) + const second = currentChild() + expect(second).not.toBe(first) + expect(second.sent[0]).toMatchObject({ op: 'subscribe', dir: '/repo2' }) + ackSubscribe(second) + await expect(respawnPromise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + + it('kills the idle child when the last pending subscribe fails', async () => { + const promise = subscribeViaWatcherProcess('/gone', vi.fn(), {}) + const first = currentChild() + first.emit('message', { + op: 'subscribe-failed', + id: first.sent[0].id, + message: 'Error opening directory' + }) + await expect(promise).rejects.toThrow('Error opening directory') + expect(first.kill).toHaveBeenCalledTimes(1) + + const respawnPromise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + expect(forkMock).toHaveBeenCalledTimes(2) + ackSubscribe(currentChild()) + await expect(respawnPromise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + + it('keeps the child alive when a subscribe fails while other subscriptions remain', async () => { + const firstPromise = subscribeViaWatcherProcess('/a', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + await firstPromise + + const failingPromise = subscribeViaWatcherProcess('/gone', vi.fn(), {}) + child.emit('message', { op: 'subscribe-failed', id: child.sent.at(-1)!.id, message: 'boom' }) + await expect(failingPromise).rejects.toThrow('boom') + expect(child.kill).not.toHaveBeenCalled() + }) + + it('keeps the child alive while other subscriptions remain', async () => { + const firstPromise = subscribeViaWatcherProcess('/a', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + const first = await firstPromise + const secondPromise = subscribeViaWatcherProcess('/b', vi.fn(), {}) + ackSubscribe(child) + const second = await secondPromise + + const firstUnsub = first.unsubscribe() + expect(child.kill).not.toHaveBeenCalled() + expect(child.sent.at(-1)).toMatchObject({ op: 'unsubscribe' }) + + // The last unsubscribe kills the idle child, which also completes the + // still-pending unsubscribe ack from the first record. + const secondUnsub = second.unsubscribe() + expect(child.kill).toHaveBeenCalledTimes(1) + await expect(firstUnsub).resolves.toBeUndefined() + await expect(secondUnsub).resolves.toBeUndefined() + }) + + it('uses the in-process watcher under vitest', async () => { + vi.stubEnv('VITEST', 'true') + const unsubscribe = vi.fn().mockResolvedValue(undefined) + parcelSubscribeMock.mockResolvedValue({ unsubscribe }) + const callback = vi.fn() + + const subscription = await subscribeViaWatcherProcess('/repo', callback, { + ignore: ['**/.git'] + }) + expect(forkMock).not.toHaveBeenCalled() + expect(parcelSubscribeMock).toHaveBeenCalledWith('/repo', callback, { ignore: ['**/.git'] }) + await subscription.unsubscribe() + expect(unsubscribe).toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts new file mode 100644 index 00000000000..58ad6097011 --- /dev/null +++ b/src/main/ipc/parcel-watcher-process.ts @@ -0,0 +1,346 @@ +// Host side of the out-of-process @parcel/watcher (see +// parcel-watcher-process-entry.ts). Why: native watcher teardown races +// fail-fast the hosting process (issue #7547), so local subscriptions live in +// a disposable forked child; when it crashes the host respawns it and +// resubscribes every live root, and callers are told via onInterruption so +// they can refresh state that changed during the gap. +import { fork, type ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type * as ParcelWatcher from '@parcel/watcher' +import type { + HostToWatcherMessage, + WatcherProcessEvent, + WatcherProcessSubscribeOptions, + WatcherToHostMessage +} from './parcel-watcher-process-entry' + +export type { WatcherProcessEvent, WatcherProcessSubscribeOptions } + +export type WatcherProcessCallback = (err: Error | null, events: WatcherProcessEvent[]) => void +export type WatcherProcessSubscription = { unsubscribe(): Promise } + +// Why: a crash loop (e.g. a root that faults the native watcher on every +// resubscribe) must degrade to "file watching disabled" rather than fork-bomb. +const CRASH_WINDOW_MS = 30_000 +const MAX_CRASHES_PER_WINDOW = 3 + +type SubscriptionRecord = { + id: number + dir: string + opts: WatcherProcessSubscribeOptions + callback: WatcherProcessCallback + onInterruption?: () => void + pendingSubscribe?: { resolve: () => void; reject: (err: Error) => void } +} + +let child: ChildProcess | null = null +let nextSubscriptionId = 1 +let crashTimes: number[] = [] +let shutdownRequested = false +let loggedInProcessFallback = false +const records = new Map() +const pendingUnsubscribes = new Map void>() + +function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null { + try { + return require('electron').app ?? null + } catch { + return null + } +} + +function getWatcherProcessEntryPath(): string { + const app = loadElectronApp() + const appPath = app?.getAppPath() ?? process.cwd() + // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the + // packaged entry must be forked from app.asar.unpacked. + const basePath = app?.isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + return join(basePath, 'out', 'main', 'parcel-watcher-process-entry.js') +} + +function shouldRunInProcess(entryPath: string): boolean { + // Why: vitest suites mock '@parcel/watcher' at the module level; a forked + // child would bypass those mocks (and could execute a stale build output), + // so tests keep the historical in-process path. + if (process.env.VITEST) { + return true + } + if (existsSync(entryPath)) { + return false + } + if (!loggedInProcessFallback) { + loggedInProcessFallback = true + console.warn( + `[parcel-watcher-process] entry not found at ${entryPath}; using in-process watcher (no crash isolation)` + ) + } + return true +} + +async function subscribeInProcess( + dir: string, + callback: WatcherProcessCallback, + opts: WatcherProcessSubscribeOptions +): Promise { + const watcher = await import('@parcel/watcher') + const subscription = await watcher.subscribe( + dir, + callback as ParcelWatcher.SubscribeCallback, + opts as ParcelWatcher.Options + ) + return { unsubscribe: () => subscription.unsubscribe() } +} + +function inCrashCooldown(): boolean { + const now = Date.now() + crashTimes = crashTimes.filter((time) => now - time < CRASH_WINDOW_MS) + return crashTimes.length >= MAX_CRASHES_PER_WINDOW +} + +function sendToChild(proc: ChildProcess, message: HostToWatcherMessage): void { + try { + proc.send(message) + } catch { + // Channel already closed — the child's exit handler recovers. + } +} + +function ensureWatcherProcess(): ChildProcess | null { + if (shutdownRequested) { + return null + } + if (child?.connected) { + return child + } + if (inCrashCooldown()) { + return null + } + let proc: ChildProcess + try { + proc = fork(getWatcherProcessEntryPath(), [], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + }) + } catch (err) { + console.error('[parcel-watcher-process] failed to fork watcher process:', err) + return null + } + proc.stderr?.on('data', (chunk: Buffer) => { + console.error('[parcel-watcher-process]', String(chunk).trimEnd()) + }) + proc.on('message', (message) => { + if (child === proc) { + handleChildMessage(message as WatcherToHostMessage) + } + }) + proc.on('error', () => handleChildGone(proc)) + proc.on('exit', (code, signal) => { + if (child === proc && (code !== 0 || signal)) { + console.error( + `[parcel-watcher-process] watcher process exited (code=${code}, signal=${signal})` + ) + } + handleChildGone(proc) + }) + child = proc + return proc +} + +function handleChildMessage(message: WatcherToHostMessage): void { + if (!message || typeof message !== 'object') { + return + } + if (message.op === 'unsubscribed') { + const resolve = pendingUnsubscribes.get(message.id) + pendingUnsubscribes.delete(message.id) + resolve?.() + return + } + const record = records.get(message.id) + if (!record) { + return + } + if (message.op === 'subscribed') { + const pending = record.pendingSubscribe + record.pendingSubscribe = undefined + if (pending) { + pending.resolve() + } else { + // Reached after a crash-respawn resubscribe: events emitted while the + // watcher process was down are lost, so let the caller refresh. + record.onInterruption?.() + } + return + } + if (message.op === 'subscribe-failed') { + records.delete(message.id) + const pending = record.pendingSubscribe + record.pendingSubscribe = undefined + if (pending) { + pending.reject(new Error(message.message)) + } else { + record.callback(new Error(message.message), []) + } + // Why: a failed last subscribe empties the records map without any + // unsubscribe ever being called — tear down the idle child here too. + killWatcherChildIfIdle() + return + } + if (message.op === 'events') { + record.callback(null, message.events) + return + } + if (message.op === 'watch-error') { + record.callback(new Error(message.message), []) + } +} + +function failAllSubscriptions(err: Error): void { + for (const record of records.values()) { + const pending = record.pendingSubscribe + record.pendingSubscribe = undefined + if (pending) { + pending.reject(err) + } else { + record.callback(err, []) + } + } + records.clear() +} + +function handleChildGone(proc: ChildProcess): void { + if (child !== proc) { + return + } + child = null + // Why: process death released every native handle, so a pending unsubscribe + // is complete by definition — resolve rather than hang worktree deletion. + for (const resolve of pendingUnsubscribes.values()) { + resolve() + } + pendingUnsubscribes.clear() + if (shutdownRequested || records.size === 0) { + return + } + crashTimes.push(Date.now()) + const replacement = ensureWatcherProcess() + if (!replacement) { + console.error( + '[parcel-watcher-process] watcher process crashed repeatedly; disabling file watching' + ) + failAllSubscriptions(new Error('file watcher process crashed repeatedly')) + return + } + console.error( + `[parcel-watcher-process] watcher process crashed; resubscribing ${records.size} root(s)` + ) + for (const record of records.values()) { + sendToChild(replacement, { + op: 'subscribe', + id: record.id, + dir: record.dir, + opts: record.opts + }) + } +} + +// Why: the last record gone leaves the child idle until app shutdown. Killing +// it releases native handles without running watcher.node's crash-prone async +// teardown (same rationale as disposeWatcherProcess) and reclaims the process; +// the next subscribe forks a fresh child. `child = null` before kill so the +// exit event neither counts as a crash nor respawns. Process death also +// completes any still-pending unsubscribe acks. +function killWatcherChildIfIdle(): void { + const proc = child + if (!proc || records.size > 0) { + return + } + child = null + for (const resolve of pendingUnsubscribes.values()) { + resolve() + } + pendingUnsubscribes.clear() + proc.kill() +} + +function makeSubscription(record: SubscriptionRecord): WatcherProcessSubscription { + return { + unsubscribe: (): Promise => { + if (!records.delete(record.id)) { + return Promise.resolve() + } + const proc = child + if (!proc?.connected) { + return Promise.resolve() + } + if (records.size === 0) { + killWatcherChildIfIdle() + return Promise.resolve() + } + return new Promise((resolve) => { + pendingUnsubscribes.set(record.id, resolve) + sendToChild(proc, { op: 'unsubscribe', id: record.id }) + }) + } + } +} + +/** Subscribe to a directory tree via the isolated watcher process. Falls back + * to an in-process @parcel/watcher subscription when the forked entry is not + * available (tests, unbuilt trees). `onInterruption` fires after the watcher + * process crashed and this subscription was transparently re-established — + * events in the gap were lost, so callers should refresh. */ +export function subscribeViaWatcherProcess( + dir: string, + callback: WatcherProcessCallback, + opts: WatcherProcessSubscribeOptions, + onInterruption?: () => void +): Promise { + if (shouldRunInProcess(getWatcherProcessEntryPath())) { + return subscribeInProcess(dir, callback, opts) + } + const record: SubscriptionRecord = { + id: nextSubscriptionId++, + dir, + opts, + callback, + onInterruption + } + return new Promise((resolve, reject) => { + const proc = ensureWatcherProcess() + if (!proc) { + reject(new Error('file watcher process unavailable')) + return + } + records.set(record.id, record) + record.pendingSubscribe = { + resolve: () => resolve(makeSubscription(record)), + reject + } + sendToChild(proc, { op: 'subscribe', id: record.id, dir, opts }) + }) +} + +/** Kill the watcher process at app shutdown. Process death releases native + * handles without running watcher.node's crash-prone async teardown. */ +export function disposeWatcherProcess(): void { + shutdownRequested = true + const proc = child + child = null + for (const resolve of pendingUnsubscribes.values()) { + resolve() + } + pendingUnsubscribes.clear() + records.clear() + proc?.kill() +} + +export function resetWatcherProcessForTest(): void { + disposeWatcherProcess() + shutdownRequested = false + crashTimes = [] + loggedInProcessFallback = false + nextSubscriptionId = 1 +} diff --git a/src/main/ipc/preflight-command-exec.test.ts b/src/main/ipc/preflight-command-exec.test.ts new file mode 100644 index 00000000000..204b8a5f6e1 --- /dev/null +++ b/src/main/ipc/preflight-command-exec.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' + +const { runPreflightCommandInWslMock } = vi.hoisted(() => ({ + runPreflightCommandInWslMock: vi.fn() +})) + +vi.mock('./preflight-wsl-command', () => ({ + runPreflightCommandInWsl: runPreflightCommandInWslMock +})) + +import { isCommandOnPath } from './preflight-command-exec' + +describe('isCommandOnPath', () => { + const sentinel = '__ORCA_PREFLIGHT_COMMAND_PATH__' + + beforeEach(() => { + runPreflightCommandInWslMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses the shared literal lookup and accepts a sentinel-prefixed POSIX path', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + runPreflightCommandInWslMock.mockResolvedValue({ + stdout: `shell startup chatter\n${sentinel}/home/user/.local/bin/codex\n`, + stderr: '' + }) + + const found = await isCommandOnPath('codex', { distro: 'Ubuntu' }) + + expect(found).toBe(true) + expect(runPreflightCommandInWslMock).toHaveBeenCalledOnce() + const [, command] = runPreflightCommandInWslMock.mock.calls[0] as [{ distro: string }, string] + expect(command).toContain( + buildPosixCommandPathLookupScript({ kind: 'literal', value: 'codex' }) + ) + expect(command).toContain( + ['if [ -n "$resolved" ]; then', `printf '${sentinel}%s\\n' "$resolved"`, 'fi'].join('\n') + ) + }) + + it.each([ + ['/absolute/startup/chatter', false], + [`${sentinel}relative/path`, false], + ['codex', false], + ["alias codex='codex --wrapped'", false] + ])('parses WSL lookup output %s as available: %s', async (stdout, expected) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + runPreflightCommandInWslMock.mockResolvedValue({ stdout: `${stdout}\n`, stderr: '' }) + + await expect(isCommandOnPath('codex', { distro: 'Ubuntu' })).resolves.toBe(expected) + }) +}) diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts index b3c3ac990b7..b5fa8dee40a 100644 --- a/src/main/ipc/preflight-command-exec.ts +++ b/src/main/ipc/preflight-command-exec.ts @@ -1,12 +1,14 @@ import { execFile } from 'node:child_process' import path from 'node:path' import { promisify } from 'node:util' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { buildLocalPreflightEnv } from './preflight-local-env' import { runPreflightCommandInWsl } from './preflight-wsl-command' import type { WslPreflightTarget } from './preflight-wsl-agent-detection' const execFileAsync = promisify(execFile) export const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 +const WSL_COMMAND_PATH_SENTINEL = '__ORCA_PREFLIGHT_COMMAND_PATH__' export type PreflightCommandResult = { stdout: string; stderr: string } @@ -81,8 +83,27 @@ export async function isCommandOnPath( const finder = process.platform === 'win32' ? 'where' : 'which' try { const { stdout } = wslTarget - ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) + ? // Why: preflight must validate the executable on PATH, not a shell alias or function. + await execCommandInWsl( + wslTarget, + [ + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', + `printf '${WSL_COMMAND_PATH_SENTINEL}%s\\n' "$resolved"`, + 'fi' + ].join('\n') + ) : await execLocalPreflightCommand(finder, [command]) + if (wslTarget) { + // Why: WSL startup chatter can contain unrelated absolute paths. + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith(WSL_COMMAND_PATH_SENTINEL)) + .map((line) => line.slice(WSL_COMMAND_PATH_SENTINEL.length)) + .some((line) => path.posix.isAbsolute(line)) + } + return stdout .split(/\r?\n/) .map((line) => line.trim()) diff --git a/src/main/ipc/preflight-wsl-agent-detection.test.ts b/src/main/ipc/preflight-wsl-agent-detection.test.ts index 5c544ef5c63..a1f9d7cfda5 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.test.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.test.ts @@ -16,6 +16,8 @@ vi.mock('child_process', () => { }) import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' +import { escapeWslShCommandForWindows } from '../../shared/wsl-login-shell-command' function lastShCommandPayload(): string { const call = execFileAsyncMock.mock.calls.at(-1) @@ -47,6 +49,20 @@ describe('detectWslCommandsOnPath', () => { expect(payload).toContain('fi\ndone') }) + it('uses the shared alias- and function-neutral PATH lookup', async () => { + execFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude', 'codex']) + + const payload = lastShCommandPayload() + const lookupScript = buildPosixCommandPathLookupScript({ + kind: 'shell-variable', + name: 'cmd' + }) + expect(payload).toContain(escapeWslShCommandForWindows(lookupScript)) + expect(payload).not.toContain('type -P') + }) + it('parses detected commands from prefixed stdout', async () => { execFileAsyncMock.mockResolvedValue({ stdout: diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index ed7d3f5399b..be1f216ed9c 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import path from 'node:path' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { buildWslLoginShellCommand, escapeWslShCommandForWindows @@ -24,13 +25,15 @@ export async function detectWslCommandsOnPath( } const commandList = uniqueCommands.map(shellQuote).join(' ') - // Why: join with newlines, not spaces. zsh treats `fi done` as a parse error - // (it needs a separator before `done`); the login shell may be zsh, so a - // space-joined script silently fails for every agent. Newlines are valid - // statement separators in every POSIX shell and zsh. + const lookupScript = buildPosixCommandPathLookupScript({ + kind: 'shell-variable', + name: 'cmd' + }) + // Newlines keep the loop valid in zsh and every POSIX shell used here. const script = [ `for cmd in ${commandList}; do`, - 'if resolved=$(command -v "$cmd" 2>/dev/null); then', + lookupScript, + 'if [ -n "$resolved" ]; then', `printf '${WSL_AGENT_DETECTION_PREFIX}%s\\t%s\\n' "$cmd" "$resolved";`, 'fi', 'done' diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 944ee1b4418..e1f391af79d 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -492,6 +492,58 @@ describe('preflight', () => { await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'cursor']) }) + it('does not report Claude Agent Teams when only the Orca shim is present', async () => { + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'which') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'orca') { + return { stdout: '/Applications/Orca.app/Contents/MacOS/orca\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents()).resolves.toEqual([]) + }) + + it('reports Claude Agent Teams when both Orca and Claude are present', async () => { + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'which') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'claude') { + return { stdout: '/Users/test/.local/bin/claude\n' } + } + if (String(args[0]) === 'orca') { + return { stdout: '/Applications/Orca.app/Contents/MacOS/orca\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'claude-agent-teams']) + }) + + it('does not report Claude Agent Teams on native Windows', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'where') { + throw new Error(`unexpected command ${String(command)}`) + } + if (String(args[0]) === 'claude') { + return { stdout: '/mock/windows/npm/claude.cmd\n' } + } + if (String(args[0]) === 'orca') { + return { stdout: '/mock/windows/programs/orca.cmd\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents()).resolves.toEqual(['claude']) + }) + it('detects agents via the install-dir resolver when which fails (stripped GUI PATH)', async () => { // Why: cold GUI launches can run detection before shell-PATH hydration // adds user install dirs, so `which` can miss runnable CLIs. @@ -634,6 +686,28 @@ describe('preflight', () => { expect(hydrateShellPathMock).not.toHaveBeenCalled() }) + it('does not report Claude Agent Teams from WSL agent detection', async () => { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (command, args) => { + if (command !== 'wsl.exe') { + throw new Error(`unexpected command ${String(command)}`) + } + const script = String(args[5]) + expect(script).not.toContain("'orca'") + expect(script).not.toContain("'orca-dev'") + expect(script).not.toContain("'orca-ide'") + if (script.includes("'claude'")) { + return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' } + } + throw new Error('not found') + }) + + await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude']) + }) + it('detects Mistral Vibe from the installed vibe executable', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index cc14ca490b6..6a33e112fa2 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -1,5 +1,4 @@ import { ipcMain } from 'electron' -import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { PathSource, ShellHydrationFailureReason } from '../../shared/types' import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path' import { getAzureDevOpsAuthStatus } from '../azure-devops/client' @@ -22,6 +21,11 @@ import { detectRemoteWindowsTerminalCapabilities, type RemoteWindowsTerminalCapabilities } from './preflight-remote-windows-terminal-capabilities' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from './tui-agent-detection-commands' export type PreflightStatus = { git: { installed: boolean } @@ -60,13 +64,6 @@ export function _resetPreflightCache(): void { cached = null } -const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => - getTuiAgentDetectCommands(config).map((cmd) => ({ - id, - cmd - })) -) - function uniqueAgentIds(ids: Iterable): string[] { return [...new Set(ids)] } @@ -92,16 +89,17 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext): if (wslTarget) { const foundCommands = await detectWslCommandsOnPath( wslTarget, - KNOWN_AGENT_COMMANDS.map(({ cmd }) => cmd) - ) - return uniqueAgentIds( - KNOWN_AGENT_COMMANDS.filter(({ cmd }) => foundCommands.has(cmd)).map(({ id }) => id) + getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, 'wsl') ) + return resolveDetectedTuiAgentIds(KNOWN_TUI_AGENT_DETECTION_COMMANDS, foundCommands, 'wsl') } + const probeCommands = getTuiAgentDetectionProbeCommands( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + process.platform + ) const pathChecks = await Promise.all( - KNOWN_AGENT_COMMANDS.map(async ({ id, cmd }) => ({ - id, + probeCommands.map(async (cmd) => ({ cmd, installedOnPath: await isCommandOnPath(cmd) })) @@ -110,11 +108,16 @@ export async function detectInstalledAgents(context?: PreflightRuntimeContext): // Why: PATH may still be unhydrated on a cold GUI launch; bulk resolution // computes user install dirs once instead of blocking once per missed CLI. const installDirCommands = detectCommandsInInstallDirs(missedCommands) - const checks = pathChecks.map(({ id, cmd, installedOnPath }) => ({ - id, - installed: installedOnPath || installDirCommands.has(cmd) - })) - return uniqueAgentIds(checks.filter((c) => c.installed).map((c) => c.id)) + const foundCommands = new Set( + pathChecks + .filter(({ cmd, installedOnPath }) => installedOnPath || installDirCommands.has(cmd)) + .map(({ cmd }) => cmd) + ) + return resolveDetectedTuiAgentIds( + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + foundCommands, + process.platform + ) } export async function detectInstalledAgentsWithShellPathHydration( @@ -181,7 +184,7 @@ export async function detectRemoteAgents(args: { connectionId: string }): Promis return [] } const result = (await mux.request('preflight.detectAgents', { - commands: KNOWN_AGENT_COMMANDS + commands: KNOWN_TUI_AGENT_DETECTION_COMMANDS })) as { agents: string[] } return uniqueAgentIds(result.agents) } diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 8631fc62b8f..9b4216d924d 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -18,11 +18,13 @@ const expectedOmpStatusExtension = posix.join( 'extensions', 'orca-agent-status.ts' ) -const expectedAttributionShimDir = join( - '/tmp/orca-user-data', - 'orca-terminal-attribution', - isWindowsHost ? 'win32' : 'posix' -) +function expectedAttributionShimDir(): string { + return join( + '/tmp/orca-user-data', + 'orca-terminal-attribution', + process.platform === 'win32' ? 'win32' : 'posix' + ) +} const { handleMock, @@ -259,6 +261,7 @@ describe('registerPtyHandlers', () => { const savedOrcaClaudeAgentStatusSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS const savedProcessPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const savedDisableMacosLoginShell = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL + const savedOrcaUserDataPath = process.env.ORCA_USER_DATA_PATH beforeEach(() => { // Why: most PTY spawn tests assert POSIX shell behavior; Windows-specific @@ -331,6 +334,10 @@ describe('registerPtyHandlers', () => { handlers.delete(channel) }) getPathMock.mockReturnValue('/tmp/orca-user-data') + // Why: shell-ready wrapper roots resolve from ORCA_USER_DATA_PATH (main + // canonicalizes it to app.getPath('userData') at startup before any spawn); + // mirror that here so ZDOTDIR/wrapper assertions match the mocked userData. + process.env.ORCA_USER_DATA_PATH = '/tmp/orca-user-data' existsSyncMock.mockReturnValue(true) statSyncMock.mockReturnValue({ isDirectory: () => true, mode: 0o755 }) readFileSyncMock.mockReturnValue('') @@ -385,6 +392,11 @@ describe('registerPtyHandlers', () => { } else { delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL } + if (savedOrcaUserDataPath !== undefined) { + process.env.ORCA_USER_DATA_PATH = savedOrcaUserDataPath + } else { + delete process.env.ORCA_USER_DATA_PATH + } if (savedOpenCodeConfigDir !== undefined) { process.env.OPENCODE_CONFIG_DIR = savedOpenCodeConfigDir } else { @@ -1152,7 +1164,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_GIT_COMMIT_TRAILER).toBe('Co-authored-by: Orca ') expect(env.ORCA_GH_PR_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋') expect(env.ORCA_GH_ISSUE_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋') - expect(env.PATH).toContain(expectedAttributionShimDir) + expect(env.PATH).toContain(expectedAttributionShimDir()) }) it('skips git/gh attribution shims when attribution is disabled', async () => { @@ -1164,7 +1176,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined() expect(env.ORCA_GH_PR_FOOTER).toBeUndefined() expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined() - expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir) + expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir()) }) it('prepends git/gh attribution shims for daemon-backed local PTYs', async () => { @@ -1193,7 +1205,7 @@ describe('registerPtyHandlers', () => { const env = daemonSpawn.mock.calls.at(-1)![0].env expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1') - expect(env.PATH).toContain(expectedAttributionShimDir) + expect(env.PATH).toContain(expectedAttributionShimDir()) }) it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => { @@ -1609,6 +1621,47 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('threads the validated pane identity into registerPty for a runtime-created daemon PTY (#7587)', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + tabId?: string + leafId?: string + env?: Record + }): Promise<{ id: string }> + } + const leafId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ + cols: 80, + rows: 24, + worktreeId: 'wt-runtime', + tabId: 'tab-1', + leafId + }) + + // Why: runtime-created spawns (e.g. the mobile-create materialize path) + // must thread the same {tabId, leafId} so the catch-path rescue can find + // and keep their live PTY (#7587). + expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-runtime', null, { + tabId: 'tab-1', + leafId + }) + }) + it('uses the owning project WSL runtime for runtime-created daemon PTYs', async () => { await withWin32Platform(async () => { _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) @@ -1758,7 +1811,7 @@ describe('registerPtyHandlers', () => { const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin') - expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir) + expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir()) expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined() expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() expect(spawnOptions.envToDelete).toEqual( @@ -1788,7 +1841,7 @@ describe('registerPtyHandlers', () => { enableGitHubAttribution: true })) expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1') - expect(env.PATH).toContain(expectedAttributionShimDir) + expect(env.PATH).toContain(expectedAttributionShimDir()) }) it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => { @@ -1809,7 +1862,7 @@ describe('registerPtyHandlers', () => { ) expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin') - expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir) + expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir()) expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined() expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() expect(spawnOptions.envToDelete).toEqual( @@ -1926,7 +1979,7 @@ describe('registerPtyHandlers', () => { enableGitHubAttribution: false })) expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() - expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir) + expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir()) }) it('does not mutate the caller-provided args.env on the daemon path', async () => { @@ -3311,6 +3364,124 @@ describe('registerPtyHandlers', () => { } }) + // Why: the unsent main->renderer backlog was only bounded by renderer ACKs. A + // background-throttled/frozen renderer (Win/Linux) stops ACKing while a busy + // pane keeps producing, so the per-pty pending string grew at raw PTY rate + // (MB->GB). The cap trims it to the most-recent span and flags droppedBacklog + // exactly once so the renderer rebuilds the dropped span from the main snapshot. + it('caps the unsent pending backlog and flags droppedBacklog once when the renderer never acks', async () => { + vi.useFakeTimers() + let seq = 0 + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn((_id: string, data: string) => { + seq += data.length + return seq + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-cap'), + registerPreAllocatedHandleForPty: vi.fn() + } + try { + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + undefined, + { awaitLocalPtyStartup: () => Promise.resolve() } + ) + const pendingSpawn = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'backlog-cap-session' + }) as Promise<{ id: string }> + await Promise.resolve() + const daemon = installObservableDaemonTestProvider() + rebindLocalProviderListeners() + const result = await pendingSpawn + + // Never ack: in-flight pins at the high-water and the flush gates, so the + // unsent backlog would grow unbounded. Emit far more than the 2 MB cap. + const HUGE = 'x'.repeat(5 * 1024 * 1024) + daemon.emitData(result.id, HUGE) + await vi.advanceTimersByTimeAsync(50) + + const dataSends = mainWindow.webContents.send.mock.calls.filter( + (call) => call[0] === 'pty:data' && (call[1] as { id: string }).id === result.id + ) + expect(dataSends.length).toBeGreaterThan(0) + // Sanity bound only: with no acks the sent total is already gated by the + // pre-existing in-flight high-water caps, so this holds independent of the + // trim. The actual retained-backlog cap is proven by droppedBacklog below. + const totalChars = dataSends.reduce( + (sum, call) => sum + (call[1] as { data: string }).data.length, + 0 + ) + expect(totalChars).toBeLessThan(3 * 1024 * 1024) + // The trim signals the renderer exactly once, on the first emitted chunk. + expect((dataSends[0][1] as { droppedBacklog?: boolean }).droppedBacklog).toBe(true) + expect( + dataSends.filter( + (call) => (call[1] as { droppedBacklog?: boolean }).droppedBacklog === true + ) + ).toHaveLength(1) + } finally { + vi.useRealTimers() + } + }) + + // Why: the cap and its flag must never fire in the common case (renderer keeps + // up), so ordinary small output carries no droppedBacklog. + it('does not flag droppedBacklog for ordinary small output under the cap', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 12), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-small'), + registerPreAllocatedHandleForPty: vi.fn() + } + try { + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + undefined, + { awaitLocalPtyStartup: () => Promise.resolve() } + ) + const pendingSpawn = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'small-output-session' + }) as Promise<{ id: string }> + await Promise.resolve() + const daemon = installObservableDaemonTestProvider() + rebindLocalProviderListeners() + const result = await pendingSpawn + + daemon.emitData(result.id, 'small output') + await vi.advanceTimersByTimeAsync(50) + + const dataSends = mainWindow.webContents.send.mock.calls.filter( + (call) => call[0] === 'pty:data' && (call[1] as { id: string }).id === result.id + ) + expect(dataSends.length).toBeGreaterThan(0) + for (const call of dataSends) { + expect((call[1] as { droppedBacklog?: boolean }).droppedBacklog).toBeUndefined() + } + } finally { + vi.useRealTimers() + } + }) + it('waits for the desktop startup barrier before runtime local spawns resolve the provider', async () => { const barrier = makeDeferred() const runtime = { @@ -4128,6 +4299,67 @@ describe('registerPtyHandlers', () => { ) }) + it('threads the validated pane identity into registerPty for a renderer PTY spawn (#7587)', async () => { + const leafId = '88888888-8888-4888-8888-888888888888' + const runtime = { + setPtyController: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'term_seam'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + cwd: '/repo', + tabId: 'tab-1', + leafId, + worktreeId: 'wt-1' + }) + + // Why: this is the load-bearing wiring for #7587 — the runtime can only back a + // stalled mobile create from a live spawn if the spawn threads {tabId, leafId}. + expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-1', null, { + tabId: 'tab-1', + leafId + }) + }) + + it('omits the pane identity from registerPty when the leafId is not a terminal leaf (#7587)', async () => { + const runtime = { + setPtyController: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'term_seam'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + await handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + cwd: '/repo', + tabId: 'tab-1', + leafId: 'pane:1', + worktreeId: 'wt-1' + }) + + // Why: legacy numeric pane ids (`pane:N`) are not terminal leaf ids, so the + // spawn seam must not fabricate a binding for them (registerPty would ignore + // it anyway); this pins that the seam passes a clean `undefined`. + expect(runtime.registerPty).toHaveBeenCalledWith(expect.any(String), 'wt-1', null, undefined) + }) + it('refreshes native Agent Teams env when captured teammate mode lives in launch args', async () => { const leafId = '11111111-1111-4111-8111-111111111111' const runtime = { @@ -5590,9 +5822,11 @@ describe('registerPtyHandlers', () => { const env = spawnCall[2].env as Record expect(spawnCall[0]).toBe('wsl.exe') expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') + expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') expect(env.WSLENV?.split(':')).toEqual( expect.arrayContaining([ 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_USER_DATA_PATH/p', 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', @@ -5602,6 +5836,42 @@ describe('registerPtyHandlers', () => { ) }) + it('forces managed ORCA_USER_DATA_PATH for WSL spawns even when the caller provides a stale root', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const runtime = { + setPtyController: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'term_wsl'), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + try { + registerPtyHandlers(mainWindow as never, runtime as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + shellOverride: 'wsl.exe', + env: { + ORCA_USER_DATA_PATH: '/tmp/stale-orca-user-data' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const spawnCall = spawnMock.mock.calls.at(-1)! + const env = spawnCall[2].env as Record + expect(spawnCall[0]).toBe('wsl.exe') + expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') + }) + describe('Windows UTF-8 code page', () => { let originalPlatform: string let originalComspec: string | undefined @@ -6046,6 +6316,98 @@ describe('registerPtyHandlers', () => { expect(options.cwd).toBe('/tmp/floating-notes') }) + it('falls back to the worktree root when a saved local cwd no longer exists', async () => { + registerPtyHandlers(mainWindow as never) + // Why: issue #7239 reproduced in a Japanese-named worktree; the fallback + // must return the selected worktree path verbatim. + const worktreePath = '/Users/motoki/orca/workspaces/nakamuramotoki/Fableと議論' + const missingCwd = `${worktreePath}/deleted-folder` + statSyncMock.mockImplementation((target: string) => { + if (target === missingCwd) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: missingCwd, + cwdFallback: 'worktree', + worktreeId: `repo-1::${worktreePath}` + })) as { startupCwdFallback?: { kind: string; cwd: string } } + + const [, , options] = spawnMock.mock.calls.at(-1) as [string, string[], { cwd: string }] + expect(options.cwd).toBe(worktreePath) + expect(result.startupCwdFallback).toEqual({ kind: 'worktree', cwd: worktreePath }) + }) + + it('keeps a missing cwd unchanged without the fallback flag', async () => { + registerPtyHandlers(mainWindow as never) + existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder') + statSyncMock.mockImplementation((target: string) => { + if (target === '/repo/app/deleted-folder') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + // Why: without the renderer opt-in the provider still surfaces its normal + // missing-directory error — API/runtime callers keep exact cwd semantics. + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/repo/app/deleted-folder', + worktreeId: 'repo-1::/repo/app' + }) + ).rejects.toThrow('Working directory "/repo/app/deleted-folder" does not exist.') + + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('spawns at an existing outside-worktree cwd without falling back (#7685)', async () => { + registerPtyHandlers(mainWindow as never) + + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/repo/app-other', + cwdFallback: 'worktree', + worktreeId: 'repo-1::/repo/app' + })) as { startupCwdFallback?: unknown } + + const [, , options] = spawnMock.mock.calls.at(-1) as [string, string[], { cwd: string }] + expect(options.cwd).toBe('/repo/app-other') + expect(result.startupCwdFallback).toBeUndefined() + }) + + it('ignores the cwd fallback flag for session reattach spawns', async () => { + registerPtyHandlers(mainWindow as never) + existsSyncMock.mockImplementation((target: string) => target !== '/repo/app/deleted-folder') + statSyncMock.mockImplementation((target: string) => { + if (target === '/repo/app/deleted-folder') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { isDirectory: () => true, mode: 0o755 } + }) + + // Why: a reattach must keep the session's exact cwd; remapping it would + // silently detach the restored terminal from its recorded state. + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/repo/app/deleted-folder', + cwdFallback: 'worktree', + sessionId: 'session-1', + worktreeId: 'repo-1::/repo/app' + }) + ).rejects.toThrow('Working directory "/repo/app/deleted-folder" does not exist.') + + expect(spawnMock).not.toHaveBeenCalled() + }) + it('rejects missing WSL worktree cwd instead of validating only the fallback Windows cwd', async () => { const originalPlatform = process.platform const originalUserProfile = process.env.USERPROFILE diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 7ceb7551698..6848e411c06 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5,6 +5,7 @@ boundary. Splitting it by line count would scatter tightly coupled terminal process behavior across files without a cleaner ownership seam. */ import { join, delimiter } from 'node:path' import { randomUUID } from 'node:crypto' +import { statSync } from 'node:fs' import { type BrowserWindow, type IpcMainEvent, @@ -82,8 +83,11 @@ import { parsePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' -import { resolveTerminalStartupCwdForWorkspace } from '../../shared/terminal-startup-cwd' -import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath' +import { + resolveTerminalStartupCwdForWorkspace, + type TerminalStartupCwdMissingDirFallback +} from '../../shared/terminal-startup-cwd' +import { isWslUncPath } from '../../shared/wsl-paths' import { clearMigrationUnsupportedPty, clearMigrationUnsupportedPtysForPaneKey @@ -910,15 +914,14 @@ export function buildPtyHostEnv( baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath } - // Why: in dev mode the `orca` CLI defaults to the production userData - // path, which routes status updates to the packaged Orca instead of this - // dev instance. Injecting ORCA_USER_DATA_PATH ensures CLI calls from - // agents running inside dev terminals reach the correct runtime. We also - // prepend the dev CLI launcher directory to PATH so `orca` resolves to - // the dev build (which supports ORCA_USER_DATA_PATH) instead of the - // production binary at /usr/local/bin/orca. - if (!opts.isPackaged) { + // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. + if (opts.isWsl) { + baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath + } else if (!opts.isPackaged) { baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath + } + // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. + if (!opts.isPackaged) { const devCliBin = join(opts.userDataPath, 'cli', 'bin') const inheritedPath = readInheritedPath(baseEnv) // Why: avoid a trailing delimiter when PATH is empty — some shells @@ -1364,6 +1367,10 @@ export function registerPtyHandlers( data: string startSeq?: number containsBackgroundOutput?: boolean + /** Set once this pty's unsent backlog was trimmed past the cap; rides the + * next payload so the renderer rebuilds the dropped span from the main + * headless snapshot (see appendPendingPtyData / dataCallback). */ + droppedBacklog?: boolean } type PtyDataPayload = { @@ -1372,6 +1379,7 @@ export function registerPtyHandlers( seq?: number rawLength?: number background?: boolean + droppedBacklog?: boolean } const pendingData = new Map() @@ -1385,6 +1393,15 @@ export function registerPtyHandlers( const PTY_BATCH_FLUSH_MAX_WRITES = 2 const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 + // Why: the in-flight caps above bound only SENT-but-unacked bytes; the unsent + // `pendingData` backlog is drained only when the renderer ACKs. A background- + // throttled/frozen renderer (Win/Linux — macOS disables throttling) stops + // ACKing while a busy pane keeps producing, so without this the per-pty queue + // grows at raw PTY throughput (MB->GB). Cap it (matching the daemon 2 MB + // pendingOutput and renderer 2 MB scheduler caps); the trimmed span is + // recovered from the main headless buffer via the renderer's hidden-output + // snapshot restore, flagged by droppedBacklog — so no output is lost. + const PENDING_DATA_MAX_CHARS = 2 * 1024 * 1024 const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024 // Why: active panes need a bounded lane through old hidden bulk output so a // keystroke redraw can reach the renderer before every background ACK lands. @@ -1503,7 +1520,8 @@ export function registerPtyHandlers( id: string, data: string, startSeq: number | undefined, - containsBackgroundOutput: boolean | undefined + containsBackgroundOutput: boolean | undefined, + droppedBacklog?: boolean ): PtyDataPayload { const payload: PtyDataPayload = { id, data } if (typeof startSeq === 'number') { @@ -1513,6 +1531,9 @@ export function registerPtyHandlers( if (containsBackgroundOutput === true) { payload.background = true } + if (droppedBacklog === true) { + payload.droppedBacklog = true + } return payload } @@ -1584,6 +1605,28 @@ export function registerPtyHandlers( return [...active, ...background] } + // Why: bound the unsent backlog to the most-recent PENDING_DATA_MAX_CHARS, + // advancing startSeq by the dropped-char count (same arithmetic flushPendingData + // uses when it slices) so the remaining tail's seq stays correct. Flags + // droppedBacklog so the next payload tells the renderer to rebuild the dropped + // span from the main headless snapshot. A no-op when under the cap (the common + // case: the renderer is ACKing and pendingData stays tiny). + function capPendingPtyData(pending: PendingPtyData): PendingPtyData { + if (pending.data.length <= PENDING_DATA_MAX_CHARS) { + return pending + } + const trimmed = pending.data.slice(pending.data.length - PENDING_DATA_MAX_CHARS) + const droppedChars = pending.data.length - trimmed.length + const next: PendingPtyData = { data: trimmed, droppedBacklog: true } + if (typeof pending.startSeq === 'number') { + next.startSeq = pending.startSeq + droppedChars + } + if (pending.containsBackgroundOutput === true) { + next.containsBackgroundOutput = true + } + return next + } + function appendPendingPtyData( existing: PendingPtyData | undefined, data: string, @@ -1593,27 +1636,29 @@ export function registerPtyHandlers( ): PendingPtyData { const nextContainsBackgroundOutput = existing?.containsBackgroundOutput === true || containsBackgroundOutput + // Carry a prior drop flag forward until it actually rides an outgoing payload. + const inheritedDropped = existing?.droppedBacklog === true + const base: PendingPtyData = { data: '' } if (!preservesSeq) { - return { - data: (existing?.data ?? '') + data, - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + base.data = (existing?.data ?? '') + data + } else if (!existing) { + base.data = data + if (typeof startSeq === 'number') { + base.startSeq = startSeq + } + } else { + base.data = existing.data + data + if (typeof existing.startSeq === 'number') { + base.startSeq = existing.startSeq } } - if (!existing) { - return { - data, - ...(typeof startSeq === 'number' ? { startSeq } : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) - } + if (nextContainsBackgroundOutput) { + base.containsBackgroundOutput = true } - const next: PendingPtyData = { - data: existing.data + data, - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + if (inheritedDropped) { + base.droppedBacklog = true } - if (typeof existing.startSeq === 'number') { - next.startSeq = existing.startSeq - } - return next + return capPendingPtyData(base) } function schedulePendingDataFlush(delayMs: number): void { @@ -1654,9 +1699,18 @@ export function registerPtyHandlers( } pendingData.set(id, nextPending) } + // Why: the drop flag rides the first emitted chunk (renderer restore is + // idempotent) and is intentionally not copied onto `remaining` above, so a + // single backlog trim signals the renderer exactly once. sendPtyDataToRenderer( id, - makePtyDataPayload(id, chunk, pending.startSeq, pending.containsBackgroundOutput) + makePtyDataPayload( + id, + chunk, + pending.startSeq, + pending.containsBackgroundOutput, + pending.droppedBacklog + ) ) writes++ } @@ -1720,7 +1774,8 @@ export function registerPtyHandlers( payload.id, remaining.data, remaining.startSeq, - remaining.containsBackgroundOutput + remaining.containsBackgroundOutput, + remaining.droppedBacklog ) ) pendingData.delete(payload.id) @@ -1833,7 +1888,8 @@ export function registerPtyHandlers( ...(typeof pending.startSeq === 'number' ? { seq: pending.startSeq + nextData.length, rawLength: nextData.length } : {}), - ...(pending.containsBackgroundOutput === true ? { background: true } : {}) + ...(pending.containsBackgroundOutput === true ? { background: true } : {}), + ...(pending.droppedBacklog === true ? { droppedBacklog: true } : {}) }) return } @@ -1990,21 +2046,33 @@ export function registerPtyHandlers( assertFolderWorkspacePathUsable(status) } - const resolveGuardedPtySpawnCwd = ( + const resolvePtySpawnStartupCwd = ( worktreeId: string | undefined, cwd: string | undefined, - connectionId?: string | null + missingDirFallback?: TerminalStartupCwdMissingDirFallback ): string | undefined => resolveTerminalStartupCwdForWorkspace({ workspaceId: worktreeId, requestedCwd: cwd, + missingDirFallback, resolveFolderWorkspacePath: (folderWorkspaceId) => - store?.getFolderWorkspace(folderWorkspaceId)?.folderPath, - // Why: realpath only makes sense on the local filesystem; SSH worktree - // paths live on the remote host. - canonicalizePath: localTerminalCwdCanonicalizer(connectionId) + store?.getFolderWorkspace(folderWorkspaceId)?.folderPath }) + const localStartupCwdDirectoryExists = (path: string): boolean => { + // Why: Win32 statSync on \\wsl.localhost 9P shares can falsely report + // ENOENT for directories that exist on the Linux side; never fall back on + // that signal — the provider's WSL-aware validation decides instead. + if (isWslUncPath(path)) { + return true + } + try { + return statSync(path).isDirectory() + } catch { + return false + } + } + // Why: the runtime controller must route through getProviderForPty() so that // CLI commands (terminal.send, terminal.stop) work for both local and remote PTYs. // Hardcoding localProvider.getPtyProcess() would silently fail for remote PTYs. @@ -2015,7 +2083,7 @@ export function registerPtyHandlers( await startupPromise } await assertFolderWorkspacePtyPathUsable(args.worktreeId) - const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId) + const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { @@ -2301,7 +2369,20 @@ export function registerPtyHandlers( runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle) } if (args.worktreeId) { - runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null) + runtime?.registerPty( + result.id, + args.worktreeId, + args.connectionId ?? null, + // Why: thread the validated pane identity so main can back a pending + // mobile create from this live spawn even if graph-sync stalls (#7587). + // Bound tabId like the sibling metadataPaneKey/spawnOptions.tabId here. + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + metadataLeafId !== null + ? { tabId: args.tabId, leafId: metadataLeafId } + : undefined + ) } if (isClaudeLaunch) { markClaudePtySpawned(result.id) @@ -2553,6 +2634,7 @@ export function registerPtyHandlers( seq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { return null @@ -2581,6 +2663,10 @@ export function registerPtyHandlers( cols: number rows: number cwd?: string + // Why: fresh local renderer spawns opt into recovering a saved cwd + // whose directory was deleted (#7239); reattach/remote callers must + // keep exact cwd semantics, so the flag alone is not sufficient. + cwdFallback?: 'worktree' env?: Record envToDelete?: string[] command?: string @@ -2624,7 +2710,26 @@ export function registerPtyHandlers( await startupPromise } await assertFolderWorkspacePtyPathUsable(args.worktreeId) - const cwd = resolveGuardedPtySpawnCwd(args.worktreeId, args.cwd, args.connectionId) + // Why: honor the fallback only for fresh local spawns even if a caller + // sends the flag — reattach must keep the session's exact cwd and + // remote/SSH paths cannot probe the local filesystem meaningfully. + const allowMissingCwdFallback = + !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' + let didFallbackToWorkspaceRootCwd = false + const cwd = resolvePtySpawnStartupCwd( + args.worktreeId, + args.cwd, + allowMissingCwdFallback + ? { + directoryExists: localStartupCwdDirectoryExists, + onFallbackToWorkspaceRoot: () => { + didFallbackToWorkspaceRootCwd = true + } + } + : undefined + ) + const startupCwdFallback = + didFallbackToWorkspaceRootCwd && cwd ? ({ kind: 'worktree', cwd } as const) : undefined spawnTiming.mark('preflight') const provider = getProvider(args.connectionId) const isClaudeLaunch = !args.connectionId && isClaudeLaunchCommand(args.command) @@ -3180,7 +3285,21 @@ export function registerPtyHandlers( args.worktreeId.length > 0 && args.worktreeId.length <= 512 ) { - runtime?.registerPty(result.id, args.worktreeId, args.connectionId ?? null) + runtime?.registerPty( + result.id, + args.worktreeId, + args.connectionId ?? null, + // Why: pass the validated pane identity so a mobile create waiting on + // this renderer tab can publish its surface main-side when graph-sync + // is throttled, instead of destroying the live PTY (#7587). Bound the + // untrusted tabId like the sibling metadataPaneKey/spawnOptions.tabId. + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + metadataLeafId !== null + ? { tabId: args.tabId, leafId: metadataLeafId } + : undefined + ) } if (isClaudeLaunch) { markClaudePtySpawned(result.id) @@ -3272,7 +3391,10 @@ export function registerPtyHandlers( ...result, ...(!result.isReattach && effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } - : {}) + : {}), + // Why: a daemon-retry race can surface isReattach even for a minted + // session id, and a reattach must never claim its cwd was remapped. + ...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {}) } return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response) } catch (err) { diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 0b7c4e07146..ad836b8e178 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { + getPathMock, + listEnvironmentsMock, + callRuntimeEnvironmentMock, registerCliHandlersMock, registerPreflightHandlersMock, registerClaudeUsageHandlersMock, @@ -28,6 +31,7 @@ const { registerRuntimeEnvironmentHandlersMock, registerEphemeralVmHandlersMock, registerAiVaultHandlersMock, + registerOrcaProfileHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, registerAgentTrustHandlersMock, @@ -57,6 +61,9 @@ const { registerEmulatorFrameStreamHandlersMock, registerEmulatorVideoStreamHandlersMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/test/user-data'), + listEnvironmentsMock: vi.fn(() => []), + callRuntimeEnvironmentMock: vi.fn(), registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), registerClaudeUsageHandlersMock: vi.fn(), @@ -84,6 +91,7 @@ const { registerRuntimeEnvironmentHandlersMock: vi.fn(), registerEphemeralVmHandlersMock: vi.fn(), registerAiVaultHandlersMock: vi.fn(), + registerOrcaProfileHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), registerAgentTrustHandlersMock: vi.fn(), @@ -114,6 +122,20 @@ const { registerEmulatorVideoStreamHandlersMock: vi.fn() })) +vi.mock('electron', () => ({ + app: { + getPath: getPathMock + } +})) + +vi.mock('../../shared/runtime-environment-store', () => ({ + listEnvironments: listEnvironmentsMock +})) + +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: callRuntimeEnvironmentMock +})) + vi.mock('./onboarding', () => ({ registerOnboardingHandlers: registerOnboardingHandlersMock })) @@ -263,6 +285,10 @@ vi.mock('./ai-vault', () => ({ registerAiVaultHandlers: registerAiVaultHandlersMock })) +vi.mock('./orca-profiles', () => ({ + registerOrcaProfileHandlers: registerOrcaProfileHandlersMock +})) + vi.mock('./codex-accounts', () => ({ registerCodexAccountHandlers: registerCodexAccountHandlersMock })) @@ -326,6 +352,11 @@ import { registerCoreHandlers } from './register-core-handlers' describe('registerCoreHandlers', () => { beforeEach(() => { + getPathMock.mockReset() + getPathMock.mockReturnValue('/test/user-data') + listEnvironmentsMock.mockReset() + listEnvironmentsMock.mockReturnValue([]) + callRuntimeEnvironmentMock.mockReset() registerCliHandlersMock.mockReset() registerPreflightHandlersMock.mockReset() registerClaudeUsageHandlersMock.mockReset() @@ -353,6 +384,7 @@ describe('registerCoreHandlers', () => { registerRuntimeEnvironmentHandlersMock.mockReset() registerEphemeralVmHandlersMock.mockReset() registerAiVaultHandlersMock.mockReset() + registerOrcaProfileHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() registerAgentTrustHandlersMock.mockReset() @@ -382,7 +414,7 @@ describe('registerCoreHandlers', () => { registerEmulatorVideoStreamHandlersMock.mockReset() }) - it('passes the store through to handler registrars that need it', () => { + it('passes the store through to handler registrars that need it', async () => { const store = { marker: 'store' } const runtime = { marker: 'runtime', getAgentBrowserBridge: () => null } const stats = { marker: 'stats' } @@ -415,6 +447,14 @@ describe('registerCoreHandlers', () => { { getAdditionalAiVaultCodexHomePaths, onBeforeRelaunch } ) + const aiVaultOptions = registerAiVaultHandlersMock.mock.calls[0]?.[0] + expect(aiVaultOptions).toBeDefined() + + callRuntimeEnvironmentMock.mockResolvedValueOnce({ + ok: true, + result: { sessions: 'bad-shape' } + }) + expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) @@ -443,6 +483,7 @@ describe('registerCoreHandlers', () => { expect(registerWorkspacePortHandlersMock).toHaveBeenCalledWith(store) expect(registerLocalhostWorktreeLabelHandlersMock).toHaveBeenCalledWith(store) expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store) + expect(registerOrcaProfileHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) expect(registerSessionHandlersMock).toHaveBeenCalledWith(store) expect(registerUIHandlersMock).toHaveBeenCalledWith(store) expect(registerEmulatorFrameStreamHandlersMock).toHaveBeenCalled() @@ -451,9 +492,14 @@ describe('registerCoreHandlers', () => { expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime) expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store) expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store) - expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({ - getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths - }) + expect(registerAiVaultHandlersMock).toHaveBeenCalledWith( + expect.objectContaining({ + getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths, + getActiveRuntimeAiVaultHostInfos: expect.any(Function), + scanRuntimeAiVaultSessions: expect.any(Function) + }) + ) + expect(aiVaultOptions.getActiveRuntimeAiVaultHostInfos()).toEqual([]) expect(registerNativeChatHandlersMock).toHaveBeenCalled() expect(registerCliHandlersMock).toHaveBeenCalled() expect(registerPreflightHandlersMock).toHaveBeenCalled() @@ -466,6 +512,40 @@ describe('registerCoreHandlers', () => { expect(registerBrowserHandlersMock).toHaveBeenCalled() expect(registerFilesystemWatcherHandlersMock).toHaveBeenCalled() expect(registerSpeechHandlersMock).toHaveBeenCalledWith(store) + + await expect( + aiVaultOptions.scanRuntimeAiVaultSessions( + 'env-123', + { + limit: 10, + scopePaths: ['/workspace'] + }, + { timeoutMs: 3000 } + ) + ).resolves.toEqual({ + sessions: [], + issues: [ + expect.objectContaining({ + executionHostId: 'runtime:env-123', + agent: 'codex', + path: 'env-123', + message: expect.stringContaining('Invalid aiVault.listSessions response') + }) + ], + scannedAt: expect.any(String) + }) + expect(callRuntimeEnvironmentMock).toHaveBeenCalledWith( + '/test/user-data', + 'env-123', + 'aiVault.listSessions', + { + limit: 10, + force: undefined, + scopePaths: ['/workspace'], + executionHostId: 'runtime:env-123' + }, + 3000 + ) }) it('only registers IPC handlers once but always updates web contents id', () => { diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 50dbd0a8ff4..2946204f517 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -1,3 +1,4 @@ +import { app } from 'electron' import { registerAppHandlers } from './app' import { registerCliHandlers } from './cli' import { registerPreflightHandlers } from './preflight' @@ -49,6 +50,7 @@ import { registerUIHandlers, setTrustedUIRendererWebContentsId } from './ui' import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream' import { registerEmulatorVideoStreamHandlers } from './emulator-video-stream' import { registerSpeechHandlers } from './speech' +import { registerOrcaProfileHandlers } from './orca-profiles' import { registerCodexAccountHandlers } from './codex-accounts' import { registerAgentHookHandlers } from './agent-hooks' import { registerAgentTrustHandlers } from './agent-trust' @@ -69,6 +71,10 @@ import type { AutomationService } from '../automations/service' import type { AgentAwakeService } from '../agent-awake-service' import type { CrashReportStore } from '../crash-reporting/crash-report-store' import type { KeybindingService } from '../keybindings/keybinding-service' +import { + getSavedRuntimeAiVaultHostInfos, + scanRuntimeAiVaultSessions +} from '../ai-vault/runtime-session-scanner' let registered = false @@ -151,6 +157,9 @@ export function registerCoreHandlers( registerKeybindingHandlers(keybindings) } registerTelemetryHandlers(store) + registerOrcaProfileHandlers(store, { + onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch + }) registerBrowserHandlers() registerShellHandlers() registerPetHandlers() @@ -171,7 +180,11 @@ export function registerCoreHandlers( registerRuntimeEnvironmentHandlers(store) registerEphemeralVmHandlers(store) registerAiVaultHandlers({ - getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths + getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, + getActiveRuntimeAiVaultHostInfos: () => + getSavedRuntimeAiVaultHostInfos(app.getPath('userData')), + scanRuntimeAiVaultSessions: async (environmentId, args, options) => + scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options) }) registerNativeChatHandlers() registerClipboardHandlers(store) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index e34c43821f8..c5d1c8f2a9c 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -1118,6 +1118,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:remove') + ipcMain.removeHandler('repos:removeForHost') ipcMain.removeHandler('repos:reorder') ipcMain.removeHandler('repos:update') ipcMain.removeHandler('projects:list') @@ -1926,6 +1927,22 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v notifyReposChanged(mainWindow) }) + // Why: forget a project on a single execution host without disturbing the + // same repo id on other hosts (local or a re-added SSH target). Used by the + // SSH-workspace forget flow when a host is removed/disconnected. + ipcMain.handle( + 'repos:removeForHost', + async (_event, args: { repoId: string; hostId: string }) => { + const hostId = normalizeExecutionHostId(args.hostId) + if (!hostId) { + throw new Error(`Invalid host ID: ${args.hostId}`) + } + store.removeProjectForHost(args.repoId, hostId) + invalidateAuthorizedRootsCache() + notifyReposChanged(mainWindow) + } + ) + ipcMain.handle( 'repos:update', ( diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 2e1b842904d..b3afc70b4a5 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -321,6 +321,23 @@ describe('registerShellHandlers', () => { ]) }) + it.runIf(process.platform === 'win32')( + 'forwards WSL remote arguments with spaces through the Windows launcher shim', + async () => { + const workspacePath = '\\\\wsl.localhost\\Ubuntu Preview\\home\\Ada Lovelace\\project' + const codeShim = 'C:\\Tools\\CODE.CMD' + resolveCliCommandMock.mockReturnValueOnce(codeShim) + const handler = getHandler('shell:openInExternalEditor') + + await expect(handler({}, workspacePath, 'code')).resolves.toEqual({ ok: true }) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(codeShim, [ + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + } + ) + it('shows the Windows console for NeoVim executable launchers on Windows', async () => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts index 4ca47c3aa63..6d04e8c7b0f 100644 --- a/src/main/ipc/ssh-browse.test.ts +++ b/src/main/ipc/ssh-browse.test.ts @@ -25,6 +25,16 @@ function createMockChannel(): EventEmitter & { stderr: EventEmitter } { }) } +// Recover the PowerShell script from a `powershell.exe ... -EncodedCommand ` +// command so tests can assert on the actual (UTF-16LE) payload sent to the host. +function decodeEncodedCommand(command: string): string { + const match = /-EncodedCommand (\S+)/.exec(command) + if (!match) { + throw new Error(`no -EncodedCommand in: ${command}`) + } + return Buffer.from(match[1], 'base64').toString('utf16le') +} + describe('registerSshBrowseHandler', () => { let handler: BrowseHandler @@ -88,6 +98,275 @@ describe('registerSshBrowseHandler', () => { expect(exec).toHaveBeenCalledWith("cd '/tmp/it'\\''s here' && pwd && command ls -1Ap") }) + it('falls back to PowerShell when a Windows SSH shell rejects POSIX exec', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users/alice' }) + await Promise.resolve() + posixChannel.stderr.emit( + 'data', + Buffer.from('"exec" no se reconoce como un comando interno o externo') + ) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + // Windows OpenSSH exec emits CRLF; the parser must strip \r so directories + // aren't misclassified as files with a stray carriage return in the name. + // The script emits a forward-slash resolvedPath (the -replace '\\','/' line) + // so the renderer's parentPath/joinPath, which only split on `/`, still work. + windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\nDesktop/\r\nnotes.txt\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: 'C:/Users/alice', + entries: [ + { name: 'Desktop', isDirectory: true }, + { name: 'notes.txt', isDirectory: false } + ] + }) + expect(exec).toHaveBeenCalledTimes(2) + expect(exec).toHaveBeenNthCalledWith(1, "cd 'C:/Users/alice' && pwd && command ls -1Ap") + expect(exec.mock.calls[1]?.[0]).toMatch(/^powershell\.exe /) + expect(exec.mock.calls[1]?.[1]).toEqual({ wrapCommand: false }) + + // Decode the -EncodedCommand payload so an accidental switch from the + // single-quote-escaped PowerShell literal to raw interpolation (an injection + // regression) is caught, and to lock in the UTF-8 output pin. + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + expect(script).toContain('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8') + expect(script).toContain("$dir = 'C:/Users/alice'") + expect(script).toContain('Get-ChildItem -LiteralPath $resolved -Force') + // resolvedPath must be emitted with forward slashes so the renderer's + // parentPath/joinPath (which only split on `/`) keep working on Windows. + expect(script).toContain("Write-Output ($resolved -replace '\\\\', '/')") + }) + + it('falls back for a non-English cmd.exe reject (exit 1, localized stderr)', async () => { + // Regression: real Windows OpenSSH + cmd.exe forwards exit 1 (not 9009) with + // localized stderr. The old 9009/English-string trigger silently missed this, + // so German/Japanese/etc. hosts never fell back. Keying off the non-zero exit + // of a RemoteBrowseError fixes it regardless of OS display language. + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users' }) + await Promise.resolve() + // Japanese cmd.exe "not recognized" text — matches none of the removed English + // /Spanish substrings, and exit 1 is not the removed 9009 sentinel. + posixChannel.stderr.emit( + 'data', + Buffer.from("'exec' は、内部コマンドとして認識されていません。") + ) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from('C:/Users\r\nAdmin/\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: 'C:/Users', + entries: [{ name: 'Admin', isDirectory: true }] + }) + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('escapes single quotes in the PowerShell literal path', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: "C:/O'Brien" }) + await Promise.resolve() + // A cmd.exe reject exits 1 over SSH (its 9009 ERRORLEVEL never crosses the + // process boundary) with localized German stderr. The fallback must still fire, + // since it keys off the non-zero exit, not the (localized, unmatchable) text. + posixChannel.stderr.emit('data', Buffer.from('Der Befehl "exec" ist falsch geschrieben')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from("C:/O'Brien\r\n")) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: "C:/O'Brien", + entries: [] + }) + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + // Single quote must be doubled inside the PowerShell literal, not passed raw. + expect(script).toContain("$dir = 'C:/O''Brien'") + }) + + it('expands ~ to $HOME in the PowerShell fallback (the default browse path)', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) + await Promise.resolve() + posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users/alice', entries: [] }) + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + // ~ must expand to $HOME, not be passed literally to Set-Location. + expect(script).toContain('$dir = $HOME') + }) + + // The renderer rebuilds forward-slash Windows paths with POSIX helpers: the + // breadcrumb prepends a spurious leading '/' before the drive, and "Up" from a + // first-level dir yields a bare drive letter. Both must be rooted for + // Set-Location, or navigation lands in the drive-relative cwd / errors. + it.each([ + { dirPath: '/C:/Users', expected: "$dir = 'C:/Users'" }, + { dirPath: 'C:', expected: "$dir = 'C:/'" }, + // Combined strip + root, so a future refactor can't break the ordering. + { dirPath: '/C:', expected: "$dir = 'C:/'" } + ])( + 'roots the Windows drive path $dirPath in the PowerShell fallback', + async ({ dirPath, expected }) => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath }) + await Promise.resolve() + posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from('C:/Users\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users', entries: [] }) + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + expect(script).toContain(expected) + } + ) + + it('surfaces the original POSIX failure when the PowerShell retry shows the host is not Windows', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/root/secret' }) + await Promise.resolve() + // A genuine POSIX permission failure exits non-zero; it's indistinguishable + // from a Windows shell reject without probing, so the fallback is attempted... + posixChannel.stderr.emit('data', Buffer.from('ls: /root/secret: Permission denied')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + // ...but on a POSIX host the login shell can't find powershell.exe (exit 127), + // so the original permission error is surfaced, never masked by the retry. + windowsChannel.stderr.emit('data', Buffer.from('bash: powershell.exe: command not found')) + windowsChannel.emit('exit', 127) + windowsChannel.emit('close') + + await expect(resultPromise).rejects.toThrow('Permission denied') + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('surfaces the PowerShell error when the Windows fallback runs but fails', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/missing' }) + await Promise.resolve() + posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.stderr.emit('data', Buffer.from('Cannot find path C:/missing')) + windowsChannel.emit('exit', 1) + windowsChannel.emit('close') + + // PowerShell exited non-127, so it genuinely ran on a Windows host — its error + // is the real cause. Surface it rather than the cmd.exe "not recognized" prose. + await expect(resultPromise).rejects.toThrow('Cannot find path') + }) + + it('surfaces the original POSIX error when the fallback shows powershell.exe is missing', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/opt/exec' }) + await Promise.resolve() + // A non-zero POSIX failure triggers the fallback probe on any host... + posixChannel.stderr.emit('data', Buffer.from('exec: command not found')) + posixChannel.emit('exit', 127) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.stderr.emit('data', Buffer.from('sh: powershell.exe: not found')) + windowsChannel.emit('exit', 127) + windowsChannel.emit('close') + + // The original POSIX failure is the real one — don't mask it with the + // misleading "powershell.exe: not found" from the doomed retry. + await expect(resultPromise).rejects.toThrow('exec: command not found') + }) + it('rejects and detaches listeners when the browse channel errors', async () => { const channel = createMockChannel() const exec = vi.fn().mockResolvedValue(channel) @@ -101,6 +380,9 @@ describe('registerSshBrowseHandler', () => { channel.emit('error', new Error('remote disconnected')) await expect(resultPromise).rejects.toThrow('remote disconnected') + // A transport failure is not a RemoteBrowseError, so it must not trigger a + // pointless PowerShell retry. + expect(exec).toHaveBeenCalledTimes(1) expect(channel.listenerCount('data')).toBe(0) expect(channel.listenerCount('exit')).toBe(0) expect(channel.listenerCount('close')).toBe(0) diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index 83afbd9de01..2729cd59ab7 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -1,5 +1,7 @@ import { ipcMain } from 'electron' import type { SshConnectionManager } from '../ssh/ssh-connection' +import type { SshExecOptions } from '../ssh/ssh-connection-utils' +import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' export type RemoteDirEntry = { name: string @@ -8,6 +10,33 @@ export type RemoteDirEntry = { const SSH_BROWSE_TIMEOUT_MS = 15_000 +// Why: a POSIX login shell that can't find powershell.exe exits 127 (the POSIX +// "command not found" convention, identical across sh/bash/zsh and locales). It's +// the locale-independent signal that the Windows fallback never actually ran, so +// the original POSIX failure — not the doomed retry — is the real error. +// +// Note: cmd.exe's ERRORLEVEL for an unrecognized command is 9009, but that value +// never crosses cmd.exe's process boundary. sshd forwards cmd.exe's *process* exit +// code, which is 1 — verified on real Windows OpenSSH + cmd.exe over both the ssh2 +// and system-ssh transports. So a Windows host rejecting Orca's POSIX `exec` +// wrapper is detected by "the remote command ran and exited non-zero" +// (RemoteBrowseError), not by a magic exit code or localized stderr text. +const POSIX_COMMAND_NOT_FOUND_EXIT = 127 + +// Carries the raw exit code so the fallback can (a) recognize that the remote +// command actually ran and failed — the locale-independent trigger for the +// Windows retry — and (b) tell a POSIX "powershell.exe not found" (127) apart +// from a genuine PowerShell error, without parsing localized shell prose. +class RemoteBrowseError extends Error { + constructor( + message: string, + readonly exitCode: number | null + ) { + super(message) + this.name = 'RemoteBrowseError' + } +} + // Why: the relay's fs.readDir enforces workspace root ACLs, which aren't // registered until a repo is added. This handler uses a raw SSH exec channel // to list directories, allowing the user to browse the remote filesystem @@ -32,156 +61,229 @@ export function registerSshBrowseHandler( throw new Error(`SSH connection "${args.targetId}" not found`) } - // Why: using one line per entry preserves filenames containing spaces. - // `command ls` bypasses user aliases/functions like `ls='eza ...'`. - // The -1 flag outputs one entry per line. The -p flag appends / to directories. - // We resolve ~ and get the absolute path via `cd && pwd`. - // `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission - // denied after a readable `cd ... && pwd`) propagates as a non-zero exit - // code rather than being indistinguishable from an empty directory. - const command = `cd ${shellEscape(args.dirPath)} && pwd && command ls -1Ap` - const channel = await conn.exec(command) - - return new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' - let exitCode: number | null = null - let settled = false - let timeout: ReturnType | null = null - - const cleanup = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - channel.off('data', onStdoutData) - channel.stderr.off('data', onStderrData) - channel.off('exit', onExit) - channel.off('close', onClose) - channel.off('error', onError) - channel.stderr.off('error', onError) + try { + return await browseWithPosixShell(conn, args.dirPath) + } catch (posixError) { + // Why: a Windows login shell (cmd.exe/PowerShell) rejects Orca's POSIX + // `exec` wrapper, and the only locale-independent signal for that is "the + // remote command executed and exited non-zero" (RemoteBrowseError). Its + // stderr prose is localized, and cmd.exe's 9009 ERRORLEVEL never reaches + // us (sshd forwards process exit 1). Transport errors/timeouts aren't + // RemoteBrowseErrors, so a dropped connection is never retried as Windows. + if (!(posixError instanceof RemoteBrowseError)) { + throw posixError } - const rejectOnce = (error: Error): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) + try { + return await browseWithWindowsPowerShell(conn, args.dirPath) + } catch (fallbackError) { + // Why: if the login shell couldn't find powershell.exe (exit 127) the + // host isn't Windows — surface the original POSIX failure rather than a + // misleading "powershell.exe: not found". Otherwise PowerShell genuinely + // ran and its error (e.g. "Cannot find path") is the real cause. + throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError } - const closeChannel = (): void => { - const closable = channel as { close?: () => void; destroy?: () => void } - try { - if (typeof closable.close === 'function') { - closable.close() - } else if (typeof closable.destroy === 'function') { - closable.destroy() - } - } catch { - /* best effort */ - } - } - const onTimeout = (): void => { - // Why: remote browsing runs before a relay workspace root exists, so - // it cannot rely on relay request deadlines. Bound this raw exec - // channel directly to keep Add Remote Project from hanging forever. - rejectOnce(new Error('Remote directory listing timed out')) - closeChannel() - } - const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve(result) - } - - const onStdoutData = (data: Buffer): void => { - stdout += data.toString() - } - const onStderrData = (data: Buffer): void => { - stderr += data.toString() - } - // `exit` fires before `close`; capture the code so we can distinguish - // a failed `ls` that still produced `pwd` output from an empty listing. - const onExit = (code: number | null): void => { - exitCode = code - } - const onError = (error: Error): void => { - rejectOnce(error) - } - const onClose = (): void => { - // A null exitCode means the server closed the channel without - // sending an exit-status message (or signalled termination). We - // can't assume success — falling back to "empty stdout = empty - // directory" is exactly the bug the exit-code branch was added to - // fix. Treat any non-zero OR null exit as a failure when stderr - // has content, and otherwise require stdout to contain at least - // the resolved `pwd` line before accepting the result. - if (exitCode !== 0) { - const msg = - stderr.trim() || - (exitCode === null - ? 'Remote listing failed (channel closed without exit status)' - : `Remote listing failed (exit ${exitCode})`) - rejectOnce(new Error(msg)) - return - } - if (stderr.trim() && !stdout.trim()) { - rejectOnce(new Error(stderr.trim())) - return - } - - const lines = stdout.trim().split('\n') - if (lines.length === 0) { - rejectOnce(new Error('Empty response from remote')) - return - } - - const resolvedPath = lines[0] - const entries: RemoteDirEntry[] = [] - - for (let i = 1; i < lines.length; i++) { - const line = lines[i] - if (!line || line === './' || line === '../') { - continue - } - if (line.endsWith('/')) { - entries.push({ name: line.slice(0, -1), isDirectory: true }) - } else { - entries.push({ name: line, isDirectory: false }) - } - } - - // Sort: directories first, then alphabetical - entries.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - - resolveOnce({ entries, resolvedPath }) - } - - channel.on('data', onStdoutData) - channel.stderr.on('data', onStderrData) - channel.on('exit', onExit) - channel.on('close', onClose) - // Why: SSH exec streams emit `error` on transport loss; without a - // scoped listener, a disappearing remote can become process-fatal. - channel.on('error', onError) - channel.stderr.on('error', onError) - timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) + } } ) } +type SshBrowseConnection = NonNullable> + +function browseWithPosixShell( + conn: SshBrowseConnection, + dirPath: string +): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> { + // Why: using one line per entry preserves filenames containing spaces. + // `command ls` bypasses user aliases/functions like `ls='eza ...'`. + // The -1 flag outputs one entry per line. The -p flag appends / to directories. + // We resolve ~ and get the absolute path via `cd && pwd`. + // `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission + // denied after a readable `cd ... && pwd`) propagates as a non-zero exit + // code rather than being indistinguishable from an empty directory. + return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`) +} + +function browseWithWindowsPowerShell( + conn: SshBrowseConnection, + dirPath: string +): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> { + const script = [ + "$ErrorActionPreference = 'Stop'", + // Why: Windows PowerShell 5.1 writes redirected stdout in the legacy OEM + // code page, but runBrowseCommand decodes as UTF-8; pin UTF-8 output so + // non-ASCII names (e.g. C:\Users\José, CJK, Cyrillic) don't come back mojibake. + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + `$dir = ${powerShellPathExpression(dirPath)}`, + 'Set-Location -LiteralPath $dir', + '$resolved = (Get-Location).ProviderPath', + // Why: the renderer's parentPath/joinPath only split on `/`, so a native + // backslash path (C:\Users\alice) breaks "Up" and mixes separators. Emit a + // forward-slash resolvedPath (matching the POSIX branch) while keeping the + // native $resolved for Get-ChildItem -LiteralPath. + "Write-Output ($resolved -replace '\\\\', '/')", + 'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {', + " if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }", + '}' + ].join('; ') + + return runBrowseCommand(conn, powerShellCommand(script), { wrapCommand: false }) +} + +async function runBrowseCommand( + conn: SshBrowseConnection, + command: string, + options?: SshExecOptions +): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> { + const channel = options ? await conn.exec(command, options) : await conn.exec(command) + + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let exitCode: number | null = null + let settled = false + let timeout: ReturnType | null = null + + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('exit', onExit) + channel.off('close', onClose) + channel.off('error', onError) + channel.stderr.off('error', onError) + } + const rejectOnce = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const closeChannel = (): void => { + const closable = channel as { close?: () => void; destroy?: () => void } + try { + if (typeof closable.close === 'function') { + closable.close() + } else if (typeof closable.destroy === 'function') { + closable.destroy() + } + } catch { + /* best effort */ + } + } + const onTimeout = (): void => { + // Why: remote browsing runs before a relay workspace root exists, so + // it cannot rely on relay request deadlines. Bound this raw exec + // channel directly to keep Add Remote Project from hanging forever. + rejectOnce(new Error('Remote directory listing timed out')) + closeChannel() + } + const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve(result) + } + + const onStdoutData = (data: Buffer): void => { + stdout += data.toString() + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString() + } + // `exit` fires before `close`; capture the code so we can distinguish + // a failed `ls` that still produced `pwd` output from an empty listing. + const onExit = (code: number | null): void => { + exitCode = code + } + const onError = (error: Error): void => { + rejectOnce(error) + } + const onClose = (): void => { + // A null exitCode means the server closed the channel without + // sending an exit-status message (or signalled termination). We + // can't assume success — falling back to "empty stdout = empty + // directory" is exactly the bug the exit-code branch was added to + // fix. Treat any non-zero OR null exit as a failure when stderr + // has content, and otherwise require stdout to contain at least + // the resolved `pwd` line before accepting the result. + if (exitCode !== 0) { + const msg = + stderr.trim() || + (exitCode === null + ? 'Remote listing failed (channel closed without exit status)' + : `Remote listing failed (exit ${exitCode})`) + rejectOnce(new RemoteBrowseError(msg, exitCode)) + return + } + if (stderr.trim() && !stdout.trim()) { + rejectOnce(new Error(stderr.trim())) + return + } + + // Why: Windows OpenSSH exec emits CRLF, so split on \r?\n — otherwise a + // trailing \r defeats the endsWith('/') dir check and leaves a stray CR + // in every name. + const lines = stdout.trim().split(/\r?\n/) + if (lines.length === 0) { + rejectOnce(new Error('Empty response from remote')) + return + } + + const resolvedPath = lines[0] + const entries: RemoteDirEntry[] = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + if (!line || line === './' || line === '../') { + continue + } + if (line.endsWith('/')) { + entries.push({ name: line.slice(0, -1), isDirectory: true }) + } else { + entries.push({ name: line, isDirectory: false }) + } + } + + // Sort: directories first, then alphabetical + entries.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + + resolveOnce({ entries, resolvedPath }) + } + + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('exit', onExit) + channel.on('close', onClose) + // Why: SSH exec streams emit `error` on transport loss; without a + // scoped listener, a disappearing remote can become process-fatal. + channel.on('error', onError) + channel.stderr.on('error', onError) + timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + }) +} + +// Why: a POSIX login shell that can't find powershell.exe exits 127, marking the +// Windows fallback as "never ran" — the host isn't Windows, so the original POSIX +// failure, not the doomed retry, is the error worth surfacing. +function isPosixCommandNotFound(error: unknown): boolean { + return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT +} + // Why: prevent shell injection in the directory path. Single-quote wrapping // with escaped internal single quotes is the safest approach for sh/bash. // Tilde must be expanded by the shell, so paths starting with ~ use $HOME @@ -199,3 +301,24 @@ function shellEscape(s: string): string { function shellEscapeRaw(s: string): string { return `'${s.replace(/'/g, "'\\''")}'` } + +function powerShellPathExpression(s: string): string { + if (s === '~') { + return '$HOME' + } + if (s.startsWith('~/') || s.startsWith('~\\')) { + return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}` + } + return powerShellLiteral(normalizeWindowsDrivePath(s)) +} + +// Why: browse emits forward-slash Windows paths, so the renderer rebuilds them +// with POSIX helpers — the breadcrumb prepends a spurious leading '/' before the +// drive (/C:/Users) and "Up" from a first-level dir yields a bare drive letter +// (C:). Both are wrong for Set-Location: a leading '/' means the current drive's +// root, and 'C:' is drive-relative (the process cwd), not 'C:\'. Normalize both +// back to a rooted drive path here so navigation lands where the user clicked. +function normalizeWindowsDrivePath(s: string): string { + const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '') + return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped +} diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index a1db12efa51..0c5a51d9ae2 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -50,7 +50,8 @@ const { onRequest: vi.fn().mockReturnValue(() => {}), onDispose: vi.fn().mockReturnValue(() => {}), request: vi.fn().mockResolvedValue({}), - notify: vi.fn() + notify: vi.fn(), + probeLiveness: vi.fn().mockResolvedValue(false) }, mockPtyProvider: { onData: vi.fn(), @@ -316,6 +317,7 @@ describe('SSH IPC handlers', () => { mockMux.isDisposed.mockReset().mockReturnValue(false) mockMux.onNotification.mockReset() mockMux.onDispose.mockReset().mockReturnValue(() => {}) + mockMux.probeLiveness.mockReset().mockResolvedValue(false) mockPtyProvider.onData.mockReset() mockPtyProvider.onExit.mockReset() mockPtyProvider.onReplay.mockReset() @@ -778,6 +780,39 @@ describe('SSH IPC handlers', () => { expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7) }) + it('mirrors SSH state broadcasts onto the runtime client-event stream', async () => { + const runtime = { + onPtyData: vi.fn(), + onPtyExit: vi.fn(), + notifySshStateChanged: vi.fn() + } + registerSshHandlers(mockStore as never, () => mockWindow as never, runtime as never) + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue({}) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + // Why: paired remote clients only learn SSH state through this hook — + // without it their reconnect overlays never clear (STA-1468). + expect(runtime.notifySshStateChanged).toHaveBeenCalledWith( + 'ssh-1', + expect.objectContaining({ targetId: 'ssh-1', status: 'connected' }) + ) + }) + it('preserves active port forwards and live connections across handler re-registration', async () => { const target: SshTarget = { id: 'ssh-1', @@ -1368,7 +1403,7 @@ describe('SSH IPC handlers', () => { expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1') }) - it('forces active SSH sessions to reconnect when the system resumes from sleep', async () => { + it('reconnects on system resume when the relay liveness probe fails', async () => { const target: SshTarget = { id: 'ssh-1', label: 'Server', @@ -1386,6 +1421,7 @@ describe('SSH IPC handlers', () => { error: null, reconnectAttempt: 0 }) + mockMux.probeLiveness.mockResolvedValue(false) await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) @@ -1394,7 +1430,77 @@ describe('SSH IPC handlers', () => { resumeListener() - expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1') + await vi.waitFor(() => expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1')) + // Why: a failed first probe gets one retry before teardown (slow post-wake network). + expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2) + }) + + it('skips reconnect on system resume when the relay link is still alive', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockMux.probeLiveness.mockResolvedValue(true) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1] + expect(resumeListener).toBeTypeOf('function') + + resumeListener() + + await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(1)) + // Let the async resume handler settle before asserting no teardown happened. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockConnectionManager.reconnect).not.toHaveBeenCalled() + }) + + it('does not reconnect after resume when the target was disconnected during the probe', async () => { + const target: SshTarget = { + id: 'ssh-1', + label: 'Server', + host: 'example.com', + port: 22, + username: 'deploy' + } + const conn = {} + mockSshStore.getTarget.mockReturnValue(target) + mockConnectionManager.connect.mockResolvedValue(conn) + mockConnectionManager.getConnection.mockReturnValue(conn) + mockConnectionManager.getState.mockReturnValue({ + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + mockMux.probeLiveness.mockResolvedValue(false) + + await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' }) + + const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1] + expect(resumeListener).toBeTypeOf('function') + + resumeListener() + // Why: the probe window is seconds long; a user disconnect during it must + // win — reconnecting afterwards would resurrect the torn-down target. + mockConnectionManager.getConnection.mockReturnValue(undefined) + + await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(mockConnectionManager.reconnect).not.toHaveBeenCalled() }) it('extends active relay grace while the system is suspending', async () => { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index ff676112218..8b5756bff38 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -51,6 +51,7 @@ let currentRuntime: OrcaRuntimeService | undefined const SSH_IPC_CHANNELS = [ 'ssh:listTargets', + 'ssh:listRemovedTargetLabels', 'ssh:addTarget', 'ssh:updateTarget', 'ssh:removeTarget', @@ -89,6 +90,16 @@ export function getRegisteredSshState(targetId: string): SshConnectionState | un return registeredGetSshState?.(targetId) } +/** Public targets for runtime RPC clients — same list the desktop renderer gets. */ +export function listRegisteredSshTargets(): SshTarget[] { + return sshStore?.listTargets() ?? [] +} + +/** Removed-target id → last known label, for ghost-host display on paired clients. */ +export function listRegisteredRemovedSshTargetLabels(): Record { + return sshStore?.listRemovedTargetLabels() ?? {} +} + export async function disconnectRegisteredSshTarget(targetId: string): Promise { if (!connectionManager) { return @@ -234,13 +245,14 @@ function broadcastSshState( if (isRuntimeOwnedSshTargetId(targetId)) { return } + const enrichedState = withSshRemotePlatform(targetId, state) const win = getMainWindow() if (win && !win.isDestroyed()) { - win.webContents.send('ssh:state-changed', { - targetId, - state: withSshRemotePlatform(targetId, state) - }) + win.webContents.send('ssh:state-changed', { targetId, state: enrichedState }) } + // Why: paired remote clients have no ssh:state-changed IPC; without this + // their terminals keep a stale reconnect overlay after the host connects. + currentRuntime?.notifySshStateChanged?.(targetId, enrichedState) } function withSshRemotePlatform(targetId: string, state: SshConnectionState): SshConnectionState { @@ -430,6 +442,24 @@ function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null) }) } +// Why: macOS can resume the process before the network stack is back up, so +// a failed first probe gets one retry before the link is declared dead (#7773). +const RESUME_PROBE_TIMEOUT_MS = 5_000 +const RESUME_PROBE_ATTEMPTS = 2 + +async function isRelayLinkAliveAfterResume(session: SshRelaySession): Promise { + const mux = session.getMux() + if (!mux || mux.isDisposed()) { + return false + } + for (let attempt = 0; attempt < RESUME_PROBE_ATTEMPTS; attempt++) { + if (await mux.probeLiveness(RESUME_PROBE_TIMEOUT_MS)) { + return true + } + } + return false +} + function registerPowerMonitorReconnect(): void { powerMonitorUnsubscribe?.() const onSuspend = (): void => { @@ -438,19 +468,35 @@ function registerPowerMonitorReconnect(): void { } } const onResume = (): void => { - for (const targetId of activeSessions.keys()) { - const conn = connectionManager?.getConnection(targetId) + for (const [targetId, session] of activeSessions) { + const manager = connectionManager + const conn = manager?.getConnection(targetId) if (!conn) { continue } - const reconnect = connectionManager?.reconnect(targetId) - void reconnect?.catch((err) => { - console.warn( - `[ssh] Failed to reconnect ${targetId} after system resume: ${ - err instanceof Error ? err.message : String(err) - }` - ) - }) + void (async () => { + // Why: unconditional reconnect on every wake tore down live sessions + // and flashed the reconnect overlay (#7773). Only reconnect targets + // whose relay link actually died during sleep. + if (await isRelayLinkAliveAfterResume(session)) { + return + } + // Why: the probe can take ~10s. If the user disconnected or the + // session/connection was replaced meanwhile, reconnecting would + // resurrect a connection that was intentionally torn down. + if (activeSessions.get(targetId) !== session || manager?.getConnection(targetId) !== conn) { + return + } + try { + await manager?.reconnect(targetId) + } catch (err) { + console.warn( + `[ssh] Failed to reconnect ${targetId} after system resume: ${ + err instanceof Error ? err.message : String(err) + }` + ) + } + })() } } powerMonitor.on('suspend', onSuspend) @@ -695,12 +741,35 @@ export function registerSshHandlers( // ── Target CRUD ──────────────────────────────────────────────────── + // Why: SSH target add/import can re-adopt workspaces orphaned on a removed + // target id (see ssh-target-readoption). When that re-points repos, the + // renderer must refresh its repo list to surface the reattached workspaces. + function notifyReposChangedIfReadopted(): void { + if (!sshStore || sshStore.lastReadoptedRepoCount <= 0) { + return + } + sshStore.lastReadoptedRepoCount = 0 + const win = getCurrentMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('repos:changed') + } + } + ipcMain.handle('ssh:listTargets', () => { return sshStore!.listTargets() }) + ipcMain.handle('ssh:listRemovedTargetLabels', () => { + return sshStore!.listRemovedTargetLabels() + }) + ipcMain.handle('ssh:addTarget', (_event, args: { target: Omit }) => { - return sshStore!.addTarget(args.target) + const target = sshStore!.addTarget(args.target) + // Why: re-adding a removed host can re-adopt orphaned workspaces (re-point + // repos/worktrees off the dead id). Refresh the renderer's repo list so the + // reattached workspaces move from grey ghosts back onto the live host. + notifyReposChangedIfReadopted() + return target }) ipcMain.handle( @@ -714,8 +783,10 @@ export function registerSshHandlers( await removeRegisteredSshTarget(args.id) }) - ipcMain.handle('ssh:importConfig', () => { - return sshStore!.importFromSshConfig() + ipcMain.handle('ssh:importConfig', (_event, args?: { reAdopt?: boolean }) => { + const result = sshStore!.importFromSshConfig(args) + notifyReposChangedIfReadopted() + return result }) // ── Connection lifecycle ─────────────────────────────────────────── diff --git a/src/main/ipc/tui-agent-detection-commands.test.ts b/src/main/ipc/tui-agent-detection-commands.test.ts new file mode 100644 index 00000000000..b6d536f732b --- /dev/null +++ b/src/main/ipc/tui-agent-detection-commands.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + getTuiAgentDetectionProbeCommands, + KNOWN_TUI_AGENT_DETECTION_COMMANDS, + resolveDetectedTuiAgentIds +} from './tui-agent-detection-commands' + +describe('tui agent detection commands', () => { + it('requires Claude before reporting Claude Agent Teams', () => { + const commands = KNOWN_TUI_AGENT_DETECTION_COMMANDS.filter( + (command) => command.id === 'claude-agent-teams' + ) + + expect(commands).toEqual([ + { + id: 'claude-agent-teams', + cmd: 'orca', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32', 'wsl'] + }, + { + id: 'claude-agent-teams', + cmd: 'orca-dev', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32', 'wsl'] + }, + { + id: 'claude-agent-teams', + cmd: 'orca-ide', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32', 'wsl'] + } + ]) + expect(getTuiAgentDetectionProbeCommands(commands, 'linux')).toEqual([ + 'orca', + 'claude', + 'orca-dev', + 'orca-ide' + ]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca']), 'linux')).toEqual([]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca', 'claude']), 'linux')).toEqual([ + 'claude-agent-teams' + ]) + expect(getTuiAgentDetectionProbeCommands(commands, 'win32')).toEqual([]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca', 'claude']), 'win32')).toEqual([]) + expect(getTuiAgentDetectionProbeCommands(commands, 'wsl')).toEqual([]) + expect(resolveDetectedTuiAgentIds(commands, new Set(['orca-ide', 'claude']), 'wsl')).toEqual([]) + }) +}) diff --git a/src/main/ipc/tui-agent-detection-commands.ts b/src/main/ipc/tui-agent-detection-commands.ts new file mode 100644 index 00000000000..dc5d5909543 --- /dev/null +++ b/src/main/ipc/tui-agent-detection-commands.ts @@ -0,0 +1,77 @@ +import type { TuiAgent } from '../../shared/types' +import { + getTuiAgentDetectCommands, + TUI_AGENT_CONFIG, + type TuiAgentConfig, + type TuiAgentDetectionRuntime +} from '../../shared/tui-agent-config' + +export type TuiAgentDetectionCommand = { + id: TuiAgent + cmd: string + requiredCommands?: readonly string[] + unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] +} + +export const KNOWN_TUI_AGENT_DETECTION_COMMANDS = buildTuiAgentDetectionCommands() + +function buildTuiAgentDetectionCommands(): TuiAgentDetectionCommand[] { + return Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) => + getTuiAgentDetectCommands(config).map((cmd) => + buildTuiAgentDetectionCommand(id as TuiAgent, cmd, config) + ) + ) +} + +function buildTuiAgentDetectionCommand( + id: TuiAgent, + cmd: string, + config: TuiAgentConfig +): TuiAgentDetectionCommand { + return { + id, + cmd, + ...(config.detectRequiredCommands?.length + ? { requiredCommands: config.detectRequiredCommands } + : {}), + ...(config.detectUnsupportedRuntimes?.length + ? { unsupportedRuntimes: config.detectUnsupportedRuntimes } + : {}) + } +} + +export function getTuiAgentDetectionProbeCommands( + commands: readonly TuiAgentDetectionCommand[], + runtime: TuiAgentDetectionRuntime +): string[] { + return [ + ...new Set( + commands + .filter((command) => !isDetectionUnsupportedInRuntime(command, runtime)) + .flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])]) + ) + ] +} + +export function resolveDetectedTuiAgentIds( + commands: readonly TuiAgentDetectionCommand[], + foundCommands: ReadonlySet, + runtime: TuiAgentDetectionRuntime +): TuiAgent[] { + const detected = commands + .filter( + (command) => + !isDetectionUnsupportedInRuntime(command, runtime) && + foundCommands.has(command.cmd) && + (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) + ) + .map(({ id }) => id) + return [...new Set(detected)] +} + +export function isDetectionUnsupportedInRuntime( + command: TuiAgentDetectionCommand, + runtime: TuiAgentDetectionRuntime +): boolean { + return command.unsupportedRuntimes?.includes(runtime) === true +} diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts new file mode 100644 index 00000000000..72f20291f4a --- /dev/null +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -0,0 +1,163 @@ +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { Repo, Worktree } from '../../shared/types' +import { resolveWorkspaceCleanupActivityWorktree } from './workspace-cleanup-activity' + +const REPO: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1 +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-1::/repo-feature', + repoId: 'repo-1', + path: '/repo-feature', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false, + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'in-progress', + ...overrides + } +} + +describe('resolveWorkspaceCleanupActivityWorktree', () => { + it('uses local worktree filesystem metadata when persisted activity is missing', async () => { + const statPath = vi.fn(async (targetPath: string) => ({ + mtimeMs: targetPath.endsWith('.git') ? 20_000 : 10_000 + })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree(REPO, makeWorktree(), statPath) + + expect(statPath).toHaveBeenCalledWith('/repo-feature') + expect(statPath).toHaveBeenCalledWith(path.join('/repo-feature', '.git')) + expect(worktree.lastActivityAt).toBe(20_000) + }) + + it('uses linked worktree gitdir metadata when the .git pointer is stale', async () => { + const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') + const gitDirHeadPath = path.join(gitDirPath, 'HEAD') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + '/repo-feature': 10_000, + [path.join('/repo-feature', '.git')]: 20_000, + [gitDirPath]: 50_000, + [gitDirHeadPath]: 60_000, + [gitDirLogsHeadPath]: 70_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => `gitdir: ${gitDirPath}\n`) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(path.join('/repo-feature', '.git')) + expect(statPath).toHaveBeenCalledWith(gitDirPath) + expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(worktree.lastActivityAt).toBe(70_000) + }) + + it('resolves relative linked worktree gitdir pointers from the worktree path', async () => { + const gitDirPath = path.resolve('/repo-feature', '.repo/gitdir') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => ({ + mtimeMs: targetPath === gitDirLogsHeadPath ? 40_000 : 10_000 + })) + const readTextFile = vi.fn(async () => 'gitdir: .repo/gitdir\n') + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(worktree.lastActivityAt).toBe(40_000) + }) + + it('converts WSL linked worktree gitdir pointers before reading metadata', async () => { + const worktreePath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo-feature` + const gitDirPath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo\.git\worktrees\repo-feature` + const gitDirHeadPath = path.join(gitDirPath, 'HEAD') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + [worktreePath]: 10_000, + [path.join(worktreePath, '.git')]: 20_000, + [gitDirPath]: 50_000, + [gitDirHeadPath]: 60_000, + [gitDirLogsHeadPath]: 70_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => 'gitdir: /home/me/repo/.git/worktrees/repo-feature\n') + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree({ path: worktreePath }), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(path.join(worktreePath, '.git')) + expect(statPath).toHaveBeenCalledWith(gitDirPath) + expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(statPath).not.toHaveBeenCalledWith('/home/me/repo/.git/worktrees/repo-feature') + expect(worktree.lastActivityAt).toBe(70_000) + }) + + it('keeps persisted activity when it is newer than local metadata', async () => { + const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree({ lastActivityAt: 30_000 }), + statPath + ) + + expect(worktree.lastActivityAt).toBe(30_000) + }) + + it('does not stat remote worktree paths', async () => { + const statPath = vi.fn(async () => ({ mtimeMs: 20_000 })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + { ...REPO, connectionId: 'ssh-1' }, + makeWorktree({ createdAt: 10_000 }), + statPath + ) + + expect(statPath).not.toHaveBeenCalled() + expect(worktree.lastActivityAt).toBe(10_000) + }) +}) diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts new file mode 100644 index 00000000000..9026b4464d9 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -0,0 +1,126 @@ +import { lstat, readFile } from 'node:fs/promises' +import path from 'node:path' +import type { Repo, Worktree } from '../../shared/types' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' + +type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> +type ReadTextFile = (targetPath: string) => Promise + +export function getPersistedWorkspaceCleanupActivityAt( + worktree: Pick +): number { + const persistedActivityAt = Number.isFinite(worktree.lastActivityAt) ? worktree.lastActivityAt : 0 + const createdAt = Number.isFinite(worktree.createdAt) ? (worktree.createdAt ?? 0) : 0 + return Math.max(persistedActivityAt, createdAt) +} + +export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Worktree): Worktree { + const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) + if (persistedActivityAt <= worktree.lastActivityAt) { + return worktree + } + return { ...worktree, lastActivityAt: persistedActivityAt } +} + +export async function resolveWorkspaceCleanupActivityWorktree( + repo: Repo, + worktree: Worktree, + statPath: StatPath = statLocalPath, + readTextFile: ReadTextFile = readLocalTextFile +): Promise { + const activityAt = await resolveWorkspaceCleanupActivityAt(repo, worktree, statPath, readTextFile) + if (activityAt <= worktree.lastActivityAt) { + return worktree + } + return { ...worktree, lastActivityAt: activityAt } +} + +async function statLocalPath(targetPath: string): Promise<{ mtimeMs: number }> { + const stats = await lstat(targetPath) + return { mtimeMs: Number(stats.mtimeMs) } +} + +async function readLocalTextFile(targetPath: string): Promise { + return readFile(targetPath, 'utf8') +} + +async function resolveWorkspaceCleanupActivityAt( + repo: Repo, + worktree: Worktree, + statPath: StatPath, + readTextFile: ReadTextFile +): Promise { + const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) + if (repo.connectionId) { + return persistedActivityAt + } + + const filesystemActivityAt = await getNewestLocalWorktreeStatMtime( + worktree.path, + statPath, + readTextFile + ) + return Math.max(persistedActivityAt, filesystemActivityAt) +} + +// Why: best-effort only. Win32 stat over \\wsl.localhost (9P) can falsely +// report ENOENT (see wslUncDirectoryExists), so a failed stat degrades to the +// persisted activity timestamp instead of blocking or mislabeling the row. +async function getNewestLocalWorktreeStatMtime( + worktreePath: string, + statPath: StatPath, + readTextFile: ReadTextFile +): Promise { + const gitPath = path.join(worktreePath, '.git') + const gitDirPath = await readLocalWorktreeGitDir(worktreePath, gitPath, readTextFile) + const gitDirMtimePromises = gitDirPath + ? [ + readMtime(gitDirPath, statPath), + readMtime(path.join(gitDirPath, 'HEAD'), statPath), + readMtime(path.join(gitDirPath, 'logs', 'HEAD'), statPath) + ] + : [] + const mtimes = await Promise.all([ + readMtime(worktreePath, statPath), + readMtime(gitPath, statPath), + ...gitDirMtimePromises + ]) + return Math.max(0, ...mtimes) +} + +async function readLocalWorktreeGitDir( + worktreePath: string, + gitPath: string, + readTextFile: ReadTextFile +): Promise { + try { + const contents = await readTextFile(gitPath) + const match = /^gitdir:\s*(.+)\s*$/im.exec(contents) + if (!match) { + return null + } + const gitDir = match[1]?.trim() + if (!gitDir) { + return null + } + // Why: linked worktrees keep mutable git state outside the worktree; the + // pointer file mtime alone can miss recent external commits. + const wslWorktree = parseWslUncPath(worktreePath) + if (wslWorktree && gitDir.startsWith('/')) { + return toWindowsWslPath(gitDir, wslWorktree.distro) + } + return path.isAbsolute(gitDir) ? gitDir : path.resolve(worktreePath, gitDir) + } catch { + return null + } +} + +async function readMtime(targetPath: string, statPath: StatPath): Promise { + try { + const stats = await statPath(targetPath) + return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0 + } catch { + return 0 + } +} diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 33866e6cd73..a9b6ff704ca 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -3,7 +3,7 @@ import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' -import type { GitWorktreeInfo, Repo } from '../../shared/types' +import type { GitWorktreeInfo, Repo, Worktree } from '../../shared/types' import { mergeWorktree } from './worktree-logic' import { splitWorktreeId } from '../../shared/worktree-id' import type { @@ -13,6 +13,11 @@ import type { WorkspaceCleanupScanProgress, WorkspaceCleanupScanResult } from '../../shared/workspace-cleanup' +import { + getPersistedWorkspaceCleanupActivityAt, + resolvePersistedWorkspaceCleanupActivityWorktree, + resolveWorkspaceCleanupActivityWorktree +} from './workspace-cleanup-activity' import { buildWorkspaceCleanupCandidate, buildWorkspaceCleanupCandidateFromError, @@ -124,48 +129,92 @@ async function scanRepoWorkspaces( return { scannedAt, candidates, errors: [] } } - const worktrees = gitWorktrees - .map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - }) - .filter((worktree) => { - if (targetWorktreeId) { - return worktree.id === targetWorktreeId - } - return ( - !repoIsFolder && - !worktree.isMainWorktree && - isWorkspaceInactiveForCleanup(worktree, scannedAt) + const mergedWorktrees = gitWorktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + const meta = store.getWorktreeMeta(worktreeId) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + }) + const candidateWorktrees = targetWorktreeId + ? mergedWorktrees.filter((worktree) => worktree.id === targetWorktreeId) + : mergedWorktrees.filter((worktree) => + shouldResolveBroadWorkspaceCleanupActivity(repoIsFolder, worktree, scannedAt) ) - }) - - onWorktreesDiscovered?.(worktrees.length) - - const candidates = await mapWorkspaceCleanupWithConcurrency( - worktrees, + // Why: fs stat has no cancellation, so on a hung network/WSL mount every + // timed-out row would abandon more threadpool work. After the first timeout, + // stop statting this repo and use persisted activity only. + let activityStatsUnavailable = false + const candidatesWithSkipped = await mapWorkspaceCleanupWithConcurrency( + candidateWorktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { + // Why: externally-created worktrees can miss Orca activity stamps; local + // filesystem metadata is a conservative guard before suggesting deletion. + const worktreeWithActivity = activityStatsUnavailable + ? resolvePersistedWorkspaceCleanupActivityWorktree(worktree) + : await resolveCleanupActivityWithTimeout(repo, worktree, () => { + activityStatsUnavailable = true + }) + if (!targetWorktreeId && !isWorkspaceInactiveForCleanup(worktreeWithActivity, scannedAt)) { + return null + } + onWorktreesDiscovered?.(1) const candidate = await buildWorkspaceCleanupCandidate({ repo, - worktree, + worktree: worktreeWithActivity, scannedAt, provider, - skipGit: skipGitWorktreeIds.has(worktree.id), + skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id), forceGitCheck: Boolean(targetWorktreeId) }).catch((error) => { console.error('Workspace cleanup candidate scan failed', error) - return buildWorkspaceCleanupCandidateFromError(repo, worktree, scannedAt) + return buildWorkspaceCleanupCandidateFromError(repo, worktreeWithActivity, scannedAt) }) onCandidateScanned?.(candidate) return candidate } ) + const candidates = candidatesWithSkipped.filter( + (candidate): candidate is WorkspaceCleanupCandidate => candidate !== null + ) return { scannedAt, candidates, errors } } +async function resolveCleanupActivityWithTimeout( + repo: Repo, + worktree: Worktree, + onActivityStatsUnavailable: () => void +): Promise { + try { + return await withWorkspaceCleanupTimeout( + () => resolveWorkspaceCleanupActivityWorktree(repo, worktree), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out reading worktree activity.' + ) + } catch (error) { + onActivityStatsUnavailable() + console.warn('Workspace cleanup activity scan failed', error) + return resolvePersistedWorkspaceCleanupActivityWorktree(worktree) + } +} + +function shouldResolveBroadWorkspaceCleanupActivity( + repoIsFolder: boolean, + worktree: Worktree, + scannedAt: number +): boolean { + if (repoIsFolder || worktree.isMainWorktree) { + return false + } + return isWorkspaceInactiveForCleanup( + { + isArchived: worktree.isArchived, + lastActivityAt: getPersistedWorkspaceCleanupActivityAt(worktree) + }, + scannedAt + ) +} + async function listCleanupGitWorktrees( repo: Repo, repoIsFolder: boolean diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index af084713a0b..1d02e59db29 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ipcMain } from 'electron' import type { Store } from '../persistence' @@ -11,6 +12,8 @@ import type { import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup' const { + lstatMock, + readFileMock, listRepoWorktreesMock, getStatusMock, gitExecFileAsyncMock, @@ -18,6 +21,8 @@ const { getSshPtyProviderMock, listRegisteredPtysMock } = vi.hoisted(() => ({ + lstatMock: vi.fn(), + readFileMock: vi.fn(), listRepoWorktreesMock: vi.fn(), getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -26,6 +31,11 @@ const { listRegisteredPtysMock: vi.fn() })) +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + readFile: readFileMock +})) + vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), @@ -92,6 +102,22 @@ function buildWorktreeIds(repoId: string, count: number): string[] { return worktreeIds } +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: 'Feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + ...overrides + } +} + function makeStore( options: { baseRef?: string @@ -123,6 +149,8 @@ describe('workspace cleanup scan', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(NOW) + lstatMock.mockReset() + readFileMock.mockReset() listRepoWorktreesMock.mockReset() getStatusMock.mockReset() gitExecFileAsyncMock.mockReset() @@ -130,6 +158,8 @@ describe('workspace cleanup scan', () => { getSshPtyProviderMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) + lstatMock.mockResolvedValue({ mtimeMs: 0 }) + readFileMock.mockRejectedValue(new Error('not a gitdir pointer')) vi.mocked(ipcMain.handle).mockReset() vi.mocked(ipcMain.removeHandler).mockReset() listRepoWorktreesMock.mockResolvedValue([ @@ -199,7 +229,7 @@ describe('workspace cleanup scan', () => { expect(progress[0]).toMatchObject({ scanId: 'scan-1', scannedWorktreeCount: 0, - totalWorktreeCount: 2, + totalWorktreeCount: 1, candidates: [] }) const candidateProgress = progress.filter( @@ -222,6 +252,55 @@ describe('workspace cleanup scan', () => { }) }) + it('emits ready rows while another activity metadata read is stalled', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature-a', + head: 'abc123', + branch: 'refs/heads/feature-a', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-feature-b', + head: 'def456', + branch: 'refs/heads/feature-b', + isBare: false, + isMainWorktree: false + } + ]) + lstatMock.mockImplementation((targetPath: string) => { + if (targetPath.startsWith('/repo-feature-a')) { + return new Promise(() => undefined) + } + return Promise.resolve({ mtimeMs: 0 }) + }) + const progress: WorkspaceCleanupScanProgress[] = [] + + const scanPromise = scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + + await vi.waitFor(() => { + expect( + progress.some((event) => event.candidates[0]?.worktreeId === 'repo-1::/repo-feature-b') + ).toBe(true) + }) + expect(progress.at(-1)).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + + await vi.advanceTimersByTimeAsync(8_000) + const result = await scanPromise + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual( + expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b']) + ) + }) + it('keeps raw scan errors out of renderer-facing results', async () => { listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed')) @@ -273,18 +352,10 @@ describe('workspace cleanup scan', () => { it('uses direct metadata lookup for focused disconnected remote preflight', async () => { const targetWorktreeId = 'repo-1::/remote/repo-feature' - const targetMeta: WorktreeMeta = { + const targetMeta = makeWorktreeMeta({ displayName: 'Remote Feature', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000 - } + }) const getWorktreeMeta = vi.fn((worktreeId: string) => worktreeId === targetWorktreeId ? targetMeta : undefined ) @@ -313,6 +384,35 @@ describe('workspace cleanup scan', () => { }) }) + it('stats only the requested worktree during focused local preflight scans', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature-a', + head: 'abc123', + branch: 'refs/heads/feature-a', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-feature-b', + head: 'def456', + branch: 'refs/heads/feature-b', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeId: 'repo-1::/repo-feature-b' + }) + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]?.worktreeId).toBe('repo-1::/repo-feature-b') + expect(lstatMock).toHaveBeenCalledTimes(2) + expect(lstatMock).toHaveBeenCalledWith('/repo-feature-b') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-feature-b', '.git')) + }) + it('scans connected remote workspaces through the SSH git provider', async () => { const provider = { listWorktrees: vi.fn().mockResolvedValue([ @@ -385,6 +485,116 @@ describe('workspace cleanup scan', () => { expect(result.candidates).toEqual([]) }) + it('stats only broad-scan rows that remain possible cleanup candidates', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo', + head: 'main123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-old', + head: 'old123', + branch: 'refs/heads/old', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-recent', + head: 'recent123', + branch: 'refs/heads/recent', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-new-created', + head: 'created123', + branch: 'refs/heads/created', + isBare: false, + isMainWorktree: false + } + ]) + const metadataByWorktreeId: Record = { + 'repo-1::/repo-old': makeWorktreeMeta({ + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }), + 'repo-1::/repo-recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }), + 'repo-1::/repo-new-created': makeWorktreeMeta({ + createdAt: NOW - 1_000, + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }) + } + const store = { + ...makeStore(), + getWorktreeMeta: (worktreeId: string) => metadataByWorktreeId[worktreeId] + } as unknown as Store + + const result = await scanWorkspaceCleanup(store) + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo-1::/repo-old' + ]) + expect(lstatMock).toHaveBeenCalledTimes(2) + expect(lstatMock).toHaveBeenCalledWith('/repo-old') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-old', '.git')) + expect(getStatusMock).toHaveBeenCalledTimes(1) + }) + + it('falls back when broad-scan activity metadata stalls', async () => { + lstatMock.mockReturnValue(new Promise(() => undefined)) + const progress: unknown[] = [] + + const scanPromise = scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + await vi.advanceTimersByTimeAsync(8_000) + + const result = await scanPromise + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]?.worktreeId).toBe('repo-1::/repo-feature') + expect(progress[0]).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 0, + totalWorktreeCount: 1 + }) + expect(progress.at(-1)).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + }) + + it('stops statting a repo after the first activity metadata timeout', async () => { + listRepoWorktreesMock.mockResolvedValue( + ['/repo-a', '/repo-b', '/repo-c', '/repo-d'].map((worktreePath, index) => ({ + path: worktreePath, + head: `head-${index}`, + branch: `refs/heads/branch-${index}`, + isBare: false, + isMainWorktree: false + })) + ) + lstatMock.mockReturnValue(new Promise(() => undefined)) + + const scanPromise = scanWorkspaceCleanup(makeStore()) + await vi.advanceTimersByTimeAsync(8_000) + const result = await scanPromise + + expect(result.candidates).toHaveLength(4) + expect(lstatMock).not.toHaveBeenCalledWith('/repo-d') + expect(lstatMock).not.toHaveBeenCalledWith(path.join('/repo-d', '.git')) + }) + it('includes focused remove preflight rows even when they are recent', async () => { const result = await scanWorkspaceCleanup( makeStore({ diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index e9f9edfcdae..43c94325789 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -7946,4 +7946,80 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).not.toHaveBeenCalled() expect(createSetupRunnerScriptMock).not.toHaveBeenCalled() }) + + describe('worktrees:forgetLocal', () => { + it('forgets a workspace pinned to a removed SSH target without touching the provider', async () => { + const repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-dead', + worktreeBaseRef: null + } + const ptyProvider = {} as never + const worktreeId = 'repo-1::/workspace/feature-wt' + store.getRepo.mockReturnValue(repo) + getLocalPtyProviderMock.mockReturnValue(ptyProvider) + // Why: a removed/disconnected SSH target has no live provider; forgetLocal + // must not reach for one. + getSshGitProviderMock.mockReturnValue(undefined) + + const result = await handlers['worktrees:forgetLocal'](null, { worktreeId }) + + expect(result).toEqual({}) + expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(worktreeId, { + runtime: runtimeStub, + localProvider: ptyProvider, + onPtyStopped: clearProviderPtyStateMock + }) + expect(runtimeStub.clearOptimisticReconcileToken).toHaveBeenCalledWith(worktreeId) + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId) + expect(advertisedUrlWatcherForgetWorktreeMock).toHaveBeenCalledWith(worktreeId) + expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith(worktreeId) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + // The whole point: local-only cleanup never dispatches to the SSH provider. + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + + it('throws when the repo is missing', async () => { + store.getRepo.mockReturnValue(undefined) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId: 'repo-gone::/workspace/feature-wt' + }) + ).rejects.toThrow(/Repo not found/) + + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('rejects forgetting a folder project root', async () => { + const repo = { + id: 'repo-folder', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' as const + } + store.getRepo.mockReturnValue(repo) + + await expect( + handlers['worktrees:forgetLocal'](null, { + worktreeId: `${repo.id}::${repo.path}` + }) + ).rejects.toThrow(/project root workspace/) + + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 8b966e2b537..d3ed835646a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -931,6 +931,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:resolvePrBase') ipcMain.removeHandler('worktrees:resolveMrBase') ipcMain.removeHandler('worktrees:remove') + ipcMain.removeHandler('worktrees:forgetLocal') ipcMain.removeHandler('worktrees:forceDeletePreservedBranch') ipcMain.removeHandler('worktrees:updateMeta') ipcMain.removeHandler('worktrees:listLineage') @@ -1807,6 +1808,67 @@ export function registerWorktreeHandlers( } ) + // Why: forget-locally drops a workspace from Orca without any remote Git or + // filesystem work. It exists so a workspace pinned to a removed/disconnected + // SSH target — whose provider is gone and whose `worktrees:remove` therefore + // throws at requireSshGitProvider before any cleanup runs — can still be + // cleared from the app. It never touches the remote: no worktree registration, + // no branches, no files are deleted there. + ipcMain.handle( + 'worktrees:forgetLocal', + async (_event, args: { worktreeId: string }): Promise => { + // Why: share the removal in-flight map (not a separate one) so a concurrent + // worktrees:remove and worktrees:forgetLocal on the same id cannot both + // mutate metadata. A forget takes no force/skipArchive options. + const optionsKey = 'forget-local' + const inFlight = worktreeRemovalsInFlight.get(args.worktreeId) + if (inFlight) { + if (inFlight.optionsKey === optionsKey) { + return inFlight.promise + } + throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) + } + + const forget = (async (): Promise => { + const { repoId } = parseWorktreeId(args.worktreeId) + const repo = store.getRepo(repoId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } + if (isFolderRepo(repo) && args.worktreeId === getFolderWorkspaceRootId(repo)) { + throw new Error( + 'Cannot delete the project root workspace. Remove the folder project instead.' + ) + } + + // Why: best-effort PTY sweep. killAllProcessesForWorktree resolves + // synchronously for a dead SSH relay (the provider tombstones the lease + // and returns without awaiting the remote), so this never hangs. + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + localProvider: getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState + }).catch((err) => { + console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) + }) + + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState(store, args.worktreeId) + preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + notifyWorktreesChanged(mainWindow, repoId) + return {} + })() + worktreeRemovalsInFlight.set(args.worktreeId, { optionsKey, promise: forget }) + try { + return await forget + } finally { + if (worktreeRemovalsInFlight.get(args.worktreeId)?.promise === forget) { + worktreeRemovalsInFlight.delete(args.worktreeId) + } + } + } + ) + ipcMain.handle( 'worktrees:forceDeletePreservedBranch', async ( diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts index df5b310fcef..6f0fc39acb6 100644 --- a/src/main/native-chat/transcript-read-cache.test.ts +++ b/src/main/native-chat/transcript-read-cache.test.ts @@ -20,7 +20,8 @@ vi.mock('./transcript-reader', async (importOriginal) => { import { isTextBlock } from '../../shared/native-chat-types' import { clearNativeChatTranscriptCache, - readNativeChatTranscriptCached + readNativeChatTranscriptCached, + setNativeChatTranscriptCacheMaxBytesForTests } from './transcript-read-cache' let tempRoots: string[] = [] @@ -46,8 +47,26 @@ async function seedSession(sessionId: string, turns: number): Promise { return filePath } +// Writes a transcript file at an explicit path whose on-disk size is ~`bytes` +// (padded via one big user message), returning the path. Read it back by passing +// the path as `transcriptPath` so resolution doesn't depend on process.env.HOME. +async function seedBigFile(name: string, bytes: number): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-bytes-')) + tempRoots.push(root) + const filePath = join(root, `${name}.jsonl`) + const record = { + type: 'user', + uuid: `u-${name}`, + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'x'.repeat(Math.max(1, bytes)) } + } + await writeFile(filePath, jsonLines([record])) + return filePath +} + beforeEach(() => { clearNativeChatTranscriptCache() + setNativeChatTranscriptCacheMaxBytesForTests() readSpy.mockClear() }) @@ -148,4 +167,59 @@ describe('readNativeChatTranscriptCached', () => { // Distinct files must not share a cached parse object. expect(c).not.toBe(a) }) + + // Why: each cached entry is a FULL unwindowed transcript parse; heavy agent + // sessions are tens of MB, so the count-only cap let 50 entries retain multiple + // GB in the one process serving desktop + every paired client. The byte budget + // evicts the oldest large entries while always keeping the most-recent. + it('evicts the oldest entry once total cached bytes exceed the budget', async () => { + // ~4 KB per file, budget 9 KB → holds 2, a third read evicts the oldest. + setNativeChatTranscriptCacheMaxBytesForTests(9 * 1024) + const fileA = await seedBigFile('big-a', 4 * 1024) + const fileB = await seedBigFile('big-b', 4 * 1024) + const fileC = await seedBigFile('big-c', 4 * 1024) + + await readNativeChatTranscriptCached('claude', 'sa', fileA) // spy 1 + await readNativeChatTranscriptCached('claude', 'sb', fileB) // spy 2 + await readNativeChatTranscriptCached('claude', 'sc', fileC) // spy 3, evicts A + expect(readSpy).toHaveBeenCalledTimes(3) + + // C is the most-recent → still cached (no re-read). + await readNativeChatTranscriptCached('claude', 'sc', fileC) + expect(readSpy).toHaveBeenCalledTimes(3) + + // A was evicted by the byte cap → this re-reads (and evicts B in turn). + await readNativeChatTranscriptCached('claude', 'sa', fileA) + expect(readSpy).toHaveBeenCalledTimes(4) + + // B is now evicted → also re-reads. + await readNativeChatTranscriptCached('claude', 'sb', fileB) + expect(readSpy).toHaveBeenCalledTimes(5) + }) + + it('keeps a single active transcript larger than the whole budget cached', async () => { + // A lone entry over budget must NOT be dropped, else every read re-parses. + setNativeChatTranscriptCacheMaxBytesForTests(1024) + const big = await seedBigFile('huge', 8 * 1024) + await readNativeChatTranscriptCached('claude', 'huge', big) + await readNativeChatTranscriptCached('claude', 'huge', big) + expect(readSpy).toHaveBeenCalledTimes(1) + }) + + it('does not evict small entries under the default budget (no regression for typical use)', async () => { + const files = await Promise.all([ + seedBigFile('s1', 512), + seedBigFile('s2', 512), + seedBigFile('s3', 512) + ]) + for (const [i, file] of files.entries()) { + await readNativeChatTranscriptCached('claude', `sess-${i}`, file) + } + expect(readSpy).toHaveBeenCalledTimes(3) + // Re-read all three: every one is still cached (byte budget never triggered). + for (const [i, file] of files.entries()) { + await readNativeChatTranscriptCached('claude', `sess-${i}`, file) + } + expect(readSpy).toHaveBeenCalledTimes(3) + }) }) diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index b62a1a1142a..9f4df5bde87 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -21,6 +21,9 @@ type CachedTranscript = { result: ReadTranscriptResult /** mtime of the resolved file when cached; a newer mtime invalidates it. */ mtimeMs: number + /** On-disk byte size of the resolved file — a cheap, monotonic proxy for this + * entry's parsed memory footprint, used to bound the cache by total bytes. */ + bytes: number } const cache = new Map() @@ -28,19 +31,36 @@ const cache = new Map() // Why: cap the cache so a long-lived process browsing many sessions can't grow // it unbounded. Map preserves insertion order, so evicting the first key drops // the oldest entry (a simple LRU once re-inserts bump recency; see setCached). -// Entry-count cap is fine for v1; a byte-aware cap is the follow-up if profiling -// shows RSS pressure now that one process serves many remote clients. const MAX_CACHE_ENTRIES = 50 +// Why: a heavy Claude/Codex coding session's JSONL is routinely tens of MB (tool +// results embed whole file contents, command output, and diffs), and each cached +// entry is the full unwindowed parse. The count cap alone let 50 such entries +// retain multiple GB in the one process that now serves desktop + every paired +// web/mobile client. Bound total cached file bytes too; we always keep the most- +// recent entry (see setCached) so an active transcript is never re-parsed on +// every read, which caps the regression to extra re-parses only past this budget. +const MAX_CACHE_BYTES = 128 * 1024 * 1024 +// Overridable only from tests so the byte-eviction path can be exercised without +// writing hundreds of MB of fixtures; production always uses MAX_CACHE_BYTES. +let maxCacheBytes = MAX_CACHE_BYTES function setCached(key: string, value: CachedTranscript): void { // Re-insert moves the key to the most-recent position for LRU eviction. cache.delete(key) cache.set(key, value) - while (cache.size > MAX_CACHE_ENTRIES) { + let totalBytes = 0 + for (const entry of cache.values()) { + totalBytes += entry.bytes + } + // Evict oldest until within BOTH caps, but never drop the most-recent entry + // (cache.size > 1): a single active transcript larger than the whole budget + // must stay cached or every read would re-parse the full file. + while (cache.size > 1 && (cache.size > MAX_CACHE_ENTRIES || totalBytes > maxCacheBytes)) { const oldest = cache.keys().next().value if (oldest === undefined) { break } + totalBytes -= cache.get(oldest)?.bytes ?? 0 cache.delete(oldest) } } @@ -49,11 +69,12 @@ function cacheKey(agent: AgentType, filePath: string): string { return `${agent}:${filePath}` } -async function fileMtimeMs(filePath: string): Promise { +async function fileStat(filePath: string): Promise<{ mtimeMs: number; bytes: number }> { try { - return (await stat(filePath)).mtimeMs + const stats = await stat(filePath) + return { mtimeMs: stats.mtimeMs, bytes: stats.size } } catch { - return Number.NaN + return { mtimeMs: Number.NaN, bytes: 0 } } } @@ -74,7 +95,7 @@ export async function readNativeChatTranscriptCached( } const key = cacheKey(agent, filePath) - const mtimeMs = await fileMtimeMs(filePath) + const { mtimeMs, bytes } = await fileStat(filePath) const cached = cache.get(key) if (cached && Number.isFinite(mtimeMs) && cached.mtimeMs === mtimeMs) { // Bump recency so a frequently-read session survives eviction. @@ -84,7 +105,7 @@ export async function readNativeChatTranscriptCached( const result = await readNativeChatTranscript(agent, sessionId, { filePath }) if (Number.isFinite(mtimeMs)) { - setCached(key, { result, mtimeMs }) + setCached(key, { result, mtimeMs, bytes }) } return result } @@ -93,3 +114,8 @@ export async function readNativeChatTranscriptCached( export function clearNativeChatTranscriptCache(): void { cache.clear() } + +/** Test-only: override the byte budget (pass no arg to restore the default). */ +export function setNativeChatTranscriptCacheMaxBytesForTests(bytes?: number): void { + maxCacheBytes = bytes ?? MAX_CACHE_BYTES +} diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts index d532711828a..347e1025893 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -140,6 +140,53 @@ describe('bundle — collection', () => { expect(bundle.payload).not.toContain('"name":"old"') }) + it('merges the daemon lifecycle log, bounded by the same lookback', () => { + const daemonFile = join(dir, 'daemon.log') + writeFileSync(traceFile, makeNDJSON([makeSpan({ name: 'recent' })])) + writeFileSync( + daemonFile, + makeNDJSON([ + { src: 'daemon', ts: new Date().toISOString(), pid: 1, event: 'startup' }, + { + src: 'daemon', + // 1h ago — outside the 30m window, must be dropped. + ts: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + pid: 1, + event: 'session-exited' + } + ]) + ) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + daemonLogFilePath: daemonFile, + daemonLogMaxFiles: 3, + lookbackMinutes: 30, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.payload).toContain('"event":"startup"') + expect(bundle.payload).toContain('"name":"recent"') + expect(bundle.payload).not.toContain('"event":"session-exited"') + }) + + it('collects no daemon log lines when no daemon log path is given', () => { + writeFileSync(traceFile, makeNDJSON([makeSpan({ name: 'recent' })])) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.payload).not.toContain('"src":"daemon"') + }) + it('runs the redactor on the merged payload (belt-and-suspenders)', () => { // Simulate a sink-write bug that leaked a secret through. The bundle // pass should still strip it. diff --git a/src/main/observability/bundle.ts b/src/main/observability/bundle.ts index 812e354d51f..34011d9e4aa 100644 --- a/src/main/observability/bundle.ts +++ b/src/main/observability/bundle.ts @@ -39,6 +39,10 @@ const DEFAULT_LOOKBACK_MINUTES = 30 export type CollectBundleOptions = { readonly traceFilePath: string readonly maxFiles: number + /** Detached-daemon lifecycle log. Its rotated family is merged into the + * bundle so daemon-side failures are diagnosable from a field report. */ + readonly daemonLogFilePath?: string + readonly daemonLogMaxFiles?: number readonly lookbackMinutes?: number readonly appVersion: string readonly platform: string @@ -95,7 +99,8 @@ function* readLinesNewestFirst(text: string): Iterable { */ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { const lookbackMs = (opts.lookbackMinutes ?? DEFAULT_LOOKBACK_MINUTES) * 60 * 1000 - const cutoffNanos = BigInt(Date.now() - lookbackMs) * 1_000_000n + const cutoffMs = Date.now() - lookbackMs + const cutoffNanos = BigInt(cutoffMs) * 1_000_000n const bundleSubmissionId = generateBundleSubmissionId() const header: BundleHeader = { bundle_submission_id: bundleSubmissionId, @@ -123,7 +128,15 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { // older than the cutoff in an older file we can stop entirely. We don't // optimize that yet; the worst case (10 × 10 MB = 100 MB scan) takes // <1 s on a modern SSD and bundles are user-initiated, not hot-path. - const files = listRotatedFiles(opts.traceFilePath, opts.maxFiles) + // Trace spans first (the primary payload), then the daemon lifecycle log. + // Daemon records carry an ISO `ts` instead of `endTimeUnixNano`; both are + // filtered by the same lookback below. + const files = [ + ...listRotatedFiles(opts.traceFilePath, opts.maxFiles), + ...(opts.daemonLogFilePath + ? listRotatedFiles(opts.daemonLogFilePath, opts.daemonLogMaxFiles ?? opts.maxFiles) + : []) + ] outer: for (const file of files) { let text: string try { @@ -153,7 +166,11 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { continue } - const record = parsed as { startTimeUnixNano?: string; endTimeUnixNano?: string } + const record = parsed as { + startTimeUnixNano?: string + endTimeUnixNano?: string + ts?: string + } // Filter by end-time, not start-time. A long-lived span started 35 // minutes ago but ending inside the lookback is exactly what we want // in the bundle for diagnosing "session crashed at minute 32." @@ -166,6 +183,13 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { // Non-numeric end-time — keep it; better to over-include than to // drop a record we couldn't classify. } + } else if (typeof record.ts === 'string') { + // Daemon lifecycle lines timestamp with an ISO `ts`; bound them by the + // same lookback window. Unparseable timestamps are kept (over-include). + const tsMs = Date.parse(record.ts) + if (Number.isFinite(tsMs) && tsMs < cutoffMs) { + continue + } } // Run the redactor a SECOND TIME over the parsed shape, in server mode. diff --git a/src/main/observability/index.ts b/src/main/observability/index.ts index 9ab216d50e1..791653d272d 100644 --- a/src/main/observability/index.ts +++ b/src/main/observability/index.ts @@ -28,15 +28,14 @@ // import isolation rule above. The cost of one duplicated array vs. // punching a hole in the architecture is trivially worth it. -import { app } from 'electron' -import { homedir, platform } from 'node:os' -import { join } from 'node:path' import { createLocalFileSink, DEFAULT_MAX_FILES, getRotatedFamilySize, type LocalFileSink } from './local-file-sink' +import { getDaemonLogFilePath, getTraceFilePath } from './logs-directory' +import { DAEMON_LOG_MAX_FILES } from '../daemon/daemon-file-log' import { collectBundle as _collectBundle, type CollectBundleOptions, @@ -128,30 +127,9 @@ export function resolveObservabilityConsent(): ObservabilityConsent { } } -/** Path for the trace NDJSON file. macOS conventional location is - * `~/Library/Application Support/Orca/logs/main.trace.ndjson`; we resolve - * the same intent on Windows / Linux via Electron's `userData` dir. The - * function falls back to homedir when Electron is not available (tests). - */ -export function getTraceFilePath(): string { - let userData: string - try { - userData = app.getPath('userData') - } catch { - // Tests — Electron's `app` may not be initialized. Use a sensible - // OS-conventional fallback so unit tests can construct the path - // without spinning up the full Electron runtime. - const home = homedir() - if (platform() === 'darwin') { - userData = join(home, 'Library', 'Application Support', 'Orca') - } else if (platform() === 'win32') { - userData = join(process.env.APPDATA ?? home, 'Orca') - } else { - userData = join(home, '.config', 'Orca') - } - } - return join(userData, 'logs', 'main.trace.ndjson') -} +// Re-exported so existing importers of the trace path keep working; the +// resolution now lives in one place alongside the daemon log path. +export { getTraceFilePath } from './logs-directory' // ── Module-level state ─────────────────────────────────────────────────── @@ -237,6 +215,11 @@ export function collectDiagnosticBundle( return _collectBundle({ traceFilePath: getTraceFilePath(), maxFiles: DEFAULT_MAX_FILES, + // Why: the detached daemon writes its lifecycle log to a separate file, so + // the bundle collector must be pointed at it explicitly — it does not glob + // the logs directory. + daemonLogFilePath: getDaemonLogFilePath(), + daemonLogMaxFiles: DAEMON_LOG_MAX_FILES, ...meta }) } diff --git a/src/main/observability/logs-directory.ts b/src/main/observability/logs-directory.ts new file mode 100644 index 00000000000..94a1b7baae8 --- /dev/null +++ b/src/main/observability/logs-directory.ts @@ -0,0 +1,41 @@ +// Single source of truth for the app's logs directory and the files inside it. +// macOS convention is `~/Library/Application Support/Orca/logs/`; Windows and +// Linux resolve the same intent via Electron's `userData` dir. Falls back to a +// homedir-derived path when Electron's `app` is unavailable (unit tests). + +import { app } from 'electron' +import { homedir, platform } from 'node:os' +import { join } from 'node:path' + +function getUserDataDir(): string { + try { + return app.getPath('userData') + } catch { + // Tests — Electron's `app` may not be initialized. Use an OS-conventional + // fallback so callers can resolve the path without the Electron runtime. + const home = homedir() + if (platform() === 'darwin') { + return join(home, 'Library', 'Application Support', 'Orca') + } + if (platform() === 'win32') { + return join(process.env.APPDATA ?? home, 'Orca') + } + return join(home, '.config', 'Orca') + } +} + +export function getLogsDirectory(): string { + return join(getUserDataDir(), 'logs') +} + +/** NDJSON trace file written by the main-process error-tracking sink. */ +export function getTraceFilePath(): string { + return join(getLogsDirectory(), 'main.trace.ndjson') +} + +/** NDJSON lifecycle log written by the detached daemon process. Shared here so + * the daemon fork (which passes it as `--log-file`) and the bundle collector + * (which reads it) agree on one path. */ +export function getDaemonLogFilePath(): string { + return join(getLogsDirectory(), 'daemon.log') +} diff --git a/src/main/orca-profiles/profile-cloud-auth-config.test.ts b/src/main/orca-profiles/profile-cloud-auth-config.test.ts new file mode 100644 index 00000000000..d18ff159fd5 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-auth-config.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest' +import { + allowsPlaintextOrcaCloudSession, + getOrcaCloudAuthConfig, + isOrcaCloudDevAuthEnabled +} from './profile-cloud-auth-config' + +vi.mock('electron', () => ({ + app: { + isPackaged: false + } +})) + +describe('Orca cloud auth config', () => { + it('reports unconfigured without both API URL and client ID', () => { + expect(getOrcaCloudAuthConfig({})).toEqual({ + configured: false, + setupMessage: 'Orca Cloud sign-in is not configured for this build.' + }) + }) + + it('builds default desktop auth endpoints from the API URL', () => { + const state = getOrcaCloudAuthConfig({ + ORCA_CLOUD_API_URL: 'https://orca-cloud.example/', + ORCA_CLOUD_CLIENT_ID: 'desktop-client' + }) + + expect(state).toEqual({ + configured: true, + config: { + apiBaseUrl: 'https://orca-cloud.example', + authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize', + sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session', + refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh', + capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities', + profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', + orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', + logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + clientId: 'desktop-client', + scope: 'openid profile email offline_access' + } + }) + }) + + it('allows loopback HTTP endpoints for local desktop auth development', () => { + const state = getOrcaCloudAuthConfig({ + ORCA_CLOUD_API_URL: 'http://localhost:4100', + ORCA_CLOUD_CLIENT_ID: 'desktop-client' + }) + + expect(state.configured).toBe(true) + }) + + it('rejects loopback HTTP endpoints in packaged builds', () => { + expect( + getOrcaCloudAuthConfig( + { + ORCA_CLOUD_API_URL: 'http://localhost:4100', + ORCA_CLOUD_CLIENT_ID: 'desktop-client' + }, + true + ) + ).toMatchObject({ configured: false }) + + const httpsState = getOrcaCloudAuthConfig( + { + ORCA_CLOUD_API_URL: 'https://orca-cloud.example', + ORCA_CLOUD_CLIENT_ID: 'desktop-client' + }, + true + ) + expect(httpsState.configured).toBe(true) + }) + + it('rejects non-HTTPS non-loopback API URLs', () => { + expect( + getOrcaCloudAuthConfig({ + ORCA_CLOUD_API_URL: 'http://orca-cloud.example', + ORCA_CLOUD_CLIENT_ID: 'desktop-client' + }) + ).toMatchObject({ configured: false }) + }) + + it('allows dev plaintext sessions only outside production', () => { + expect( + allowsPlaintextOrcaCloudSession({ + ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1', + NODE_ENV: 'development' + }) + ).toBe(true) + expect( + allowsPlaintextOrcaCloudSession({ + ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1', + NODE_ENV: 'production' + }) + ).toBe(false) + }) + + it('ignores dev flags in packaged builds even without NODE_ENV', () => { + // Why: packaged main bundles never define NODE_ENV, so packaged-ness must + // gate the escape hatches on its own. + expect(allowsPlaintextOrcaCloudSession({ ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1' }, true)).toBe( + false + ) + expect(isOrcaCloudDevAuthEnabled({ ORCA_CLOUD_DEV_AUTH: '1' }, true)).toBe(false) + }) + + it('allows local dev auth only outside production', () => { + expect( + isOrcaCloudDevAuthEnabled({ + ORCA_CLOUD_DEV_AUTH: '1', + NODE_ENV: 'development' + }) + ).toBe(true) + expect( + isOrcaCloudDevAuthEnabled({ + ORCA_CLOUD_DEV_AUTH: '1', + NODE_ENV: 'production' + }) + ).toBe(false) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-auth-config.ts b/src/main/orca-profiles/profile-cloud-auth-config.ts new file mode 100644 index 00000000000..15f9c2e6d24 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-auth-config.ts @@ -0,0 +1,115 @@ +import { app } from 'electron' + +export type OrcaCloudAuthConfig = { + apiBaseUrl: string + authorizeEndpoint: string + sessionEndpoint: string + refreshEndpoint: string + capabilitiesEndpoint: string + profileEndpoint: string + orgEndpoint: string + logoutEndpoint: string + clientId: string + scope: string +} + +const DEFAULT_SCOPE = 'openid profile email offline_access' + +// Why: packaged main bundles never define NODE_ENV, so packaged-ness is the +// only reliable production signal for gating dev-only auth escape hatches. +function isPackagedOrcaBuild(): boolean { + try { + return app?.isPackaged === true + } catch { + return false + } +} + +function cleanUrl(value: string | undefined, allowLoopbackHttp: boolean): string | null { + const trimmed = value?.trim() + if (!trimmed) { + return null + } + try { + const parsed = new URL(trimmed) + const loopbackHost = + parsed.hostname === '127.0.0.1' || + parsed.hostname === 'localhost' || + parsed.hostname === '[::1]' + if (parsed.protocol !== 'https:' && !(loopbackHost && allowLoopbackHttp)) { + return null + } + return parsed.toString().replace(/\/$/, '') + } catch { + return null + } +} + +function endpoint(baseUrl: string, path: string): string { + return new URL(path, `${baseUrl}/`).toString() +} + +export function getOrcaCloudAuthConfig( + env: NodeJS.ProcessEnv = process.env, + packaged: boolean = isPackagedOrcaBuild() +): { configured: true; config: OrcaCloudAuthConfig } | { configured: false; setupMessage: string } { + // Why: loopback HTTP endpoints are a local-development convenience only; + // packaged builds must not accept plain-HTTP token endpoints via env vars. + const allowLoopbackHttp = !packaged + const cleanEndpointUrl = (value: string | undefined): string | null => + cleanUrl(value, allowLoopbackHttp) + const apiBaseUrl = cleanEndpointUrl(env.ORCA_CLOUD_API_URL) + const clientId = env.ORCA_CLOUD_CLIENT_ID?.trim() + if (!apiBaseUrl || !clientId) { + return { + configured: false, + setupMessage: 'Orca Cloud sign-in is not configured for this build.' + } + } + + const authBaseUrl = cleanEndpointUrl(env.ORCA_CLOUD_AUTH_URL) ?? apiBaseUrl + return { + configured: true, + config: { + apiBaseUrl, + authorizeEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_AUTHORIZE_URL) ?? + endpoint(authBaseUrl, '/v1/desktop/auth/authorize'), + sessionEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_SESSION_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/session'), + refreshEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_REFRESH_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/refresh'), + capabilitiesEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_CAPABILITIES_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/capabilities'), + profileEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_PROFILE_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/profile'), + orgEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_ORG_URL) ?? endpoint(apiBaseUrl, '/v1/desktop/auth/org'), + logoutEndpoint: + cleanEndpointUrl(env.ORCA_CLOUD_LOGOUT_URL) ?? + endpoint(apiBaseUrl, '/v1/desktop/auth/logout'), + clientId, + scope: env.ORCA_CLOUD_AUTH_SCOPE?.trim() || DEFAULT_SCOPE + } + } +} + +export function allowsPlaintextOrcaCloudSession( + env: NodeJS.ProcessEnv = process.env, + packaged: boolean = isPackagedOrcaBuild() +): boolean { + return ( + env.ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION === '1' && env.NODE_ENV !== 'production' && !packaged + ) +} + +export function isOrcaCloudDevAuthEnabled( + env: NodeJS.ProcessEnv = process.env, + packaged: boolean = isPackagedOrcaBuild() +): boolean { + return env.ORCA_CLOUD_DEV_AUTH === '1' && env.NODE_ENV !== 'production' && !packaged +} diff --git a/src/main/orca-profiles/profile-cloud-auth-status.ts b/src/main/orca-profiles/profile-cloud-auth-status.ts new file mode 100644 index 00000000000..400498d223e --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-auth-status.ts @@ -0,0 +1,56 @@ +import type { OrcaProfileAuthStatus } from '../../shared/orca-profiles' +import type { ActiveOrcaProfileState } from './profile-index-store' +import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config' +import { readOrcaCloudSession } from './profile-cloud-session-store' + +export function getOrcaProfileAuthStatusFromProfile( + active: ActiveOrcaProfileState, + userDataPath: string +): OrcaProfileAuthStatus { + const configState = getOrcaCloudAuthConfig() + const devAuthEnabled = isOrcaCloudDevAuthEnabled() + const configured = configState.configured || devAuthEnabled + const cloud = active.profile.cloud + if (!cloud) { + return { + activeProfileId: active.profile.id, + configured, + state: configured ? 'local' : 'unconfigured', + persistence: 'none', + setupMessage: configured ? undefined : configState.setupMessage + } + } + + const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (!configured) { + return { + activeProfileId: active.profile.id, + configured: false, + state: 'unconfigured', + persistence: session.status === 'found' ? session.persistence : 'none', + cloud, + credentialError: session.status === 'decrypt-failed' ? session.error : undefined, + setupMessage: configState.setupMessage + } + } + if (session.status === 'found') { + return { + activeProfileId: active.profile.id, + configured, + state: 'connected', + persistence: session.persistence, + cloud, + organizations: session.session.organizations, + capabilities: session.session.capabilities + } + } + + return { + activeProfileId: active.profile.id, + configured, + state: 'reconnect-required', + persistence: 'none', + cloud, + credentialError: session.status === 'decrypt-failed' ? session.error : undefined + } +} diff --git a/src/main/orca-profiles/profile-cloud-client.test.ts b/src/main/orca-profiles/profile-cloud-client.test.ts new file mode 100644 index 00000000000..f24ba0bb5bf --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-client.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import type { OrcaCloudSession } from './profile-cloud-session-store' +import { + createOrcaCloudProfile, + exchangeOrcaCloudAuthCode, + refreshOrcaCloudCapabilities, + refreshOrcaCloudSession, + selectOrcaCloudOrg +} from './profile-cloud-client' + +const fetchMock = vi.fn() + +const config: OrcaCloudAuthConfig = { + apiBaseUrl: 'https://orca-cloud.example', + authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize', + sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session', + refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh', + capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities', + profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', + orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', + logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + clientId: 'desktop-client', + scope: 'openid profile email offline_access' +} + +const session: OrcaCloudSession = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: 999, + capabilities: { flags: { share: true }, refreshedAt: 1 } +} + +function mockFetchJson(value: unknown): void { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => value + }) +} + +describe('Orca cloud client', () => { + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + it('normalizes session exchange organizations', async () => { + mockFetchJson({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: 999, + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com' + }, + organizations: [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: '', name: 'Ignored' } + ], + capabilities: { + flags: { share: true }, + refreshedAt: 123 + } + }) + + await expect( + exchangeOrcaCloudAuthCode(config, { + code: 'code', + codeVerifier: 'verifier', + nonce: 'nonce', + redirectUri: 'http://127.0.0.1:4100/auth/callback', + state: 'state', + localProfileId: 'local-default' + }) + ).resolves.toMatchObject({ + organizations: [{ orgId: 'org-1', name: 'Acme', role: 'Admin' }] + }) + expect(fetchMock).toHaveBeenCalledWith( + config.sessionEndpoint, + expect.objectContaining({ + body: JSON.stringify({ + code: 'code', + codeVerifier: 'verifier', + nonce: 'nonce', + redirectUri: 'http://127.0.0.1:4100/auth/callback', + state: 'state', + localProfileId: 'local-default' + }) + }) + ) + }) + + it('normalizes organization selection response metadata', async () => { + mockFetchJson({ + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + activeOrgId: 'org-2', + activeOrgName: 'Personal' + }, + organizations: [ + { orgId: 'org-1', name: 'Acme' }, + { orgId: 'org-2', name: 'Personal' } + ], + capabilities: { + flags: { share: false, sso: true }, + refreshedAt: 456 + } + }) + + await expect(selectOrcaCloudOrg(config, session, 'org-2')).resolves.toEqual({ + cloud: expect.objectContaining({ activeOrgId: 'org-2', activeOrgName: 'Personal' }), + organizations: [ + { orgId: 'org-1', name: 'Acme', role: undefined }, + { orgId: 'org-2', name: 'Personal', role: undefined } + ], + capabilities: { flags: { share: false, sso: true }, refreshedAt: 456 } + }) + expect(fetchMock).toHaveBeenCalledWith( + config.orgEndpoint, + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'Bearer access-token' }) + }) + ) + }) + + it('creates cloud profiles with a profile-scoped session response', async () => { + mockFetchJson({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresAt: 1000, + cloud: { + cloudProfileId: 'cloud-profile-2', + userId: 'user-1', + email: 'nina@example.com', + activeOrgId: 'org-1', + activeOrgName: 'Acme' + }, + organizations: [{ orgId: 'org-1', name: 'Acme' }], + capabilities: { + flags: { share: true }, + refreshedAt: 789 + } + }) + + await expect( + createOrcaCloudProfile(config, session, { orgId: 'org-1', name: 'Acme' }) + ).resolves.toMatchObject({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-2' }), + organizations: [{ orgId: 'org-1', name: 'Acme', role: undefined }] + }) + expect(fetchMock).toHaveBeenCalledWith( + config.profileEndpoint, + expect.objectContaining({ + body: JSON.stringify({ orgId: 'org-1', name: 'Acme' }) + }) + ) + }) + + it('refreshes session material without exposing refresh tokens in URLs', async () => { + mockFetchJson({ + accessToken: 'rotated-access-token', + refreshToken: 'rotated-refresh-token', + expiresAt: 2000, + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com' + }, + capabilities: { + flags: { share: true }, + refreshedAt: 999 + } + }) + + await expect(refreshOrcaCloudSession(config, session)).resolves.toMatchObject({ + accessToken: 'rotated-access-token', + refreshToken: 'rotated-refresh-token' + }) + expect(fetchMock).toHaveBeenCalledWith( + config.refreshEndpoint, + expect.objectContaining({ + body: JSON.stringify({ refreshToken: 'refresh-token' }) + }) + ) + }) + + it('refreshes capability flags and optional org metadata with the current access token', async () => { + mockFetchJson({ + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com' + }, + organizations: [], + capabilities: { + flags: { share: false, team: true }, + refreshedAt: 1001 + } + }) + + await expect(refreshOrcaCloudCapabilities(config, session)).resolves.toEqual({ + cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-1' }), + organizations: [], + capabilities: { + flags: { share: false, team: true }, + refreshedAt: 1001 + } + }) + expect(fetchMock).toHaveBeenCalledWith( + config.capabilitiesEndpoint, + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'Bearer access-token' }) + }) + ) + }) + + it('trims cloud metadata and drops blank active org fields', async () => { + mockFetchJson({ + cloud: { + cloudProfileId: ' cloud-profile-1 ', + userId: ' user-1 ', + email: ' nina@example.com ', + displayName: ' Nina ', + activeOrgId: ' ', + activeOrgName: '' + }, + capabilities: { + flags: {}, + refreshedAt: 1002 + } + }) + + await expect(refreshOrcaCloudCapabilities(config, session)).resolves.toMatchObject({ + cloud: { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + displayName: 'Nina', + activeOrgId: undefined, + activeOrgName: undefined + } + }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-client.ts b/src/main/orca-profiles/profile-cloud-client.ts new file mode 100644 index 00000000000..81d88f72e22 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-client.ts @@ -0,0 +1,263 @@ +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaProfileCloudSummary +} from '../../shared/orca-profiles' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import type { OrcaCloudSession } from './profile-cloud-session-store' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +type ExchangeCodeArgs = { + code: string + codeVerifier: string + nonce: string + redirectUri: string + state: string + localProfileId: string +} + +type CreateCloudProfileArgs = { + orgId?: string + name?: string +} + +type SelectOrgResponse = { + cloud: OrcaProfileCloudSummary + organizations?: OrcaCloudOrgSummary[] + capabilities: OrcaCloudCapabilities +} + +type CapabilityRefreshResponse = { + cloud?: OrcaProfileCloudSummary + organizations?: OrcaCloudOrgSummary[] + capabilities: OrcaCloudCapabilities +} + +export class OrcaCloudRequestError extends Error { + // Why: `errorCode` carries the server's JSON `{error}` discriminator (e.g. + // 'already_member', 'cannot_remove_self') so callers can distinguish the + // precise 4xx cause without re-reading the response body. + constructor( + public readonly statusCode: number, + public readonly errorCode?: string + ) { + super(`orca_cloud_request_failed_${statusCode}`) + this.name = 'OrcaCloudRequestError' + } +} + +function assertString(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new Error(`invalid_orca_cloud_${field}`) + } + const trimmed = value.trim() + if (!trimmed) { + throw new Error(`invalid_orca_cloud_${field}`) + } + return trimmed +} + +function optionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + return trimmed || undefined +} + +function assertNumber(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`invalid_orca_cloud_${field}`) + } + return value +} + +function normalizeCapabilities(value: unknown): OrcaCloudCapabilities { + if (!value || typeof value !== 'object') { + return { flags: {}, refreshedAt: Date.now() } + } + const record = value as Record + const rawFlags = record.flags + const flags: Record = {} + if (rawFlags && typeof rawFlags === 'object' && !Array.isArray(rawFlags)) { + for (const [key, flag] of Object.entries(rawFlags)) { + if (typeof flag === 'boolean') { + flags[key] = flag + } + } + } + return { + flags, + refreshedAt: + typeof record.refreshedAt === 'number' && Number.isFinite(record.refreshedAt) + ? record.refreshedAt + : Date.now() + } +} + +function normalizeOrganizations(value: unknown): OrcaCloudOrgSummary[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + const organizations: OrcaCloudOrgSummary[] = [] + for (const item of value) { + if (!item || typeof item !== 'object') { + continue + } + const record = item as Record + if (typeof record.orgId !== 'string' || typeof record.name !== 'string') { + continue + } + const orgId = record.orgId.trim() + const name = record.name.trim() + if (!orgId || !name) { + continue + } + organizations.push({ + orgId, + name, + role: typeof record.role === 'string' && record.role.trim() ? record.role.trim() : undefined + }) + } + return organizations +} + +function normalizeCloudSummary(value: unknown): OrcaProfileCloudSummary { + if (!value || typeof value !== 'object') { + throw new Error('invalid_orca_cloud_profile') + } + const record = value as Record + return { + cloudProfileId: assertString(record.cloudProfileId, 'profile_id'), + userId: assertString(record.userId, 'user_id'), + email: assertString(record.email, 'email'), + displayName: optionalString(record.displayName), + activeOrgId: optionalString(record.activeOrgId), + activeOrgName: optionalString(record.activeOrgName), + linkedAt: + typeof record.linkedAt === 'number' && Number.isFinite(record.linkedAt) + ? record.linkedAt + : Date.now() + } +} + +function normalizeSessionResponse(value: unknown): OrcaCloudSessionExchangeResponse { + if (!value || typeof value !== 'object') { + throw new Error('invalid_orca_cloud_session') + } + const record = value as Record + return { + accessToken: assertString(record.accessToken, 'access_token'), + refreshToken: assertString(record.refreshToken, 'refresh_token'), + expiresAt: assertNumber(record.expiresAt, 'expires_at'), + cloud: normalizeCloudSummary(record.cloud), + organizations: normalizeOrganizations(record.organizations), + capabilities: normalizeCapabilities(record.capabilities) + } +} + +const CLOUD_REQUEST_TIMEOUT_MS = 30_000 + +async function postJson(url: string, body: unknown, accessToken?: string): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}) + }, + body: JSON.stringify(body), + // Why: these are fixed first-party token endpoints; following a redirect + // would re-send refresh tokens/code verifiers to another origin, and a + // stalled server must not hang the renderer's awaited IPC call forever. + redirect: 'error', + signal: AbortSignal.timeout(CLOUD_REQUEST_TIMEOUT_MS) + }) + if (!response.ok) { + throw new OrcaCloudRequestError(response.status) + } + return (await response.json()) as T +} + +export async function exchangeOrcaCloudAuthCode( + config: OrcaCloudAuthConfig, + args: ExchangeCodeArgs +): Promise { + return normalizeSessionResponse( + await postJson(config.sessionEndpoint, { + code: args.code, + codeVerifier: args.codeVerifier, + nonce: args.nonce, + redirectUri: args.redirectUri, + state: args.state, + localProfileId: args.localProfileId + }) + ) +} + +export async function refreshOrcaCloudCapabilities( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession +): Promise { + const response = await postJson<{ + cloud?: unknown + organizations?: unknown + capabilities: unknown + }>(config.capabilitiesEndpoint, {}, session.accessToken) + return { + cloud: response.cloud === undefined ? undefined : normalizeCloudSummary(response.cloud), + organizations: normalizeOrganizations(response.organizations), + capabilities: normalizeCapabilities(response.capabilities) + } +} + +export async function refreshOrcaCloudSession( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession +): Promise { + return normalizeSessionResponse( + await postJson(config.refreshEndpoint, { + refreshToken: session.refreshToken + }) + ) +} + +export async function createOrcaCloudProfile( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + args: CreateCloudProfileArgs +): Promise { + return normalizeSessionResponse( + await postJson( + config.profileEndpoint, + { + orgId: args.orgId, + name: args.name + }, + session.accessToken + ) + ) +} + +export async function selectOrcaCloudOrg( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + orgId: string +): Promise { + const response = await postJson<{ + cloud: unknown + organizations?: unknown + capabilities: unknown + }>(config.orgEndpoint, { orgId }, session.accessToken) + return { + cloud: normalizeCloudSummary(response.cloud), + organizations: normalizeOrganizations(response.organizations), + capabilities: normalizeCapabilities(response.capabilities) + } +} + +export async function revokeOrcaCloudSession( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession +): Promise { + await postJson(config.logoutEndpoint, { refreshToken: session.refreshToken }, session.accessToken) +} diff --git a/src/main/orca-profiles/profile-cloud-dev-auth.ts b/src/main/orca-profiles/profile-cloud-dev-auth.ts new file mode 100644 index 00000000000..2383cea6d40 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-dev-auth.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'node:crypto' +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary +} from '../../shared/orca-profiles' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +const DEV_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +function cleanEnvString(value: string | undefined, fallback: string): string { + const trimmed = value?.trim() + return trimmed || fallback +} + +function defaultDevOrganizations(): OrcaCloudOrgSummary[] { + return [ + { orgId: 'dev-personal', name: 'Personal', role: 'Owner' }, + { orgId: 'dev-acme', name: 'Acme Dev', role: 'Admin' } + ] +} + +function devCapabilities(): OrcaCloudCapabilities { + return { + flags: { + share: true, + team: true, + 'share.create': true, + 'share.manage': true, + 'relay.use': true, + 'team.member': true, + 'enterprise.sso': true + }, + refreshedAt: Date.now() + } +} + +function devToken(prefix: string): string { + return `${prefix}-${randomUUID()}` +} + +export function createDevOrcaCloudSession( + args: { + localProfileId?: string + cloudProfileId?: string + orgId?: string + } = {} +): OrcaCloudSessionExchangeResponse { + const organizations = defaultDevOrganizations() + const selectedOrg = organizations.find((organization) => organization.orgId === args.orgId) + const cloudProfileId = + args.cloudProfileId ?? + (args.localProfileId ? `dev-cloud-${args.localProfileId}` : `dev-cloud-${randomUUID()}`) + + return { + accessToken: devToken('dev-access'), + refreshToken: devToken('dev-refresh'), + expiresAt: Date.now() + DEV_SESSION_TTL_MS, + cloud: { + cloudProfileId, + userId: cleanEnvString(process.env.ORCA_CLOUD_DEV_USER_ID, 'dev-user'), + email: cleanEnvString(process.env.ORCA_CLOUD_DEV_EMAIL, 'dev@orca.local'), + displayName: cleanEnvString(process.env.ORCA_CLOUD_DEV_DISPLAY_NAME, 'Orca Dev'), + activeOrgId: selectedOrg?.orgId, + activeOrgName: selectedOrg?.name, + linkedAt: Date.now() + }, + organizations, + capabilities: devCapabilities() + } +} diff --git a/src/main/orca-profiles/profile-cloud-dev-org-members.ts b/src/main/orca-profiles/profile-cloud-dev-org-members.ts new file mode 100644 index 00000000000..4f61e03df6a --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-dev-org-members.ts @@ -0,0 +1,130 @@ +import type { + OrcaOrgMember, + OrcaOrgMembersRoster, + OrcaOrgPendingInvite, + OrcaProfileOrgMemberChangeRoleArgs, + OrcaProfileOrgMemberInviteArgs, + OrcaProfileOrgMemberMutationResult, + OrcaProfileOrgMemberRemoveArgs, + OrcaProfileOrgInviteRevokeArgs +} from '../../shared/orca-profiles' + +// Why: dev-auth mode has no server, so the whole teammate UI is exercised +// against this in-memory per-org roster. It mirrors the shape the real client +// returns (self as owner, one signed-in teammate, one never-signed-in teammate, +// one pending invite) and the mutation endpoints' status semantics. +type DevOrgRoster = { + members: OrcaOrgMember[] + pendingInvites: OrcaOrgPendingInvite[] +} + +const devRostersByOrg = new Map() + +function cleanEnvString(value: string | undefined, fallback: string): string { + const trimmed = value?.trim() + return trimmed || fallback +} + +function devSelf(): OrcaOrgMember { + return { + userId: cleanEnvString(process.env.ORCA_CLOUD_DEV_USER_ID, 'dev-user'), + email: cleanEnvString(process.env.ORCA_CLOUD_DEV_EMAIL, 'dev@orca.local'), + displayName: cleanEnvString(process.env.ORCA_CLOUD_DEV_DISPLAY_NAME, 'Orca Dev'), + role: 'owner' + } +} + +function seedDevRoster(): DevOrgRoster { + return { + members: [ + devSelf(), + { + userId: 'dev-teammate-1', + email: 'teammate@orca.local', + displayName: 'Dev Teammate', + role: 'admin' + }, + // Why: userId null exercises the "hasn't signed in to Orca yet" disabled row. + { userId: null, email: 'invited-member@orca.local', displayName: undefined, role: 'member' } + ], + pendingInvites: [{ email: 'pending@orca.local', role: 'member', createdAt: Date.now() }] + } +} + +function getDevRoster(orgId: string): DevOrgRoster { + const existing = devRostersByOrg.get(orgId) + if (existing) { + return existing + } + const seeded = seedDevRoster() + devRostersByOrg.set(orgId, seeded) + return seeded +} + +export function listDevOrcaCloudOrgMembers(orgId: string): OrcaOrgMembersRoster { + const roster = getDevRoster(orgId) + return { + members: roster.members.map((member) => ({ ...member })), + pendingInvites: roster.pendingInvites.map((invite) => ({ ...invite })), + viewerRole: 'owner', + canManageMembers: true + } +} + +export function inviteDevOrcaCloudOrgMember( + args: OrcaProfileOrgMemberInviteArgs +): OrcaProfileOrgMemberMutationResult { + const roster = getDevRoster(args.orgId) + const email = args.email.toLowerCase() + if (roster.members.some((member) => member.email.toLowerCase() === email)) { + return { status: 'conflict', reason: 'already_member' } + } + if (roster.pendingInvites.some((invite) => invite.email.toLowerCase() === email)) { + return { status: 'conflict', reason: 'already_invited' } + } + roster.pendingInvites.push({ email: args.email, role: args.role, createdAt: Date.now() }) + return { status: 'ok' } +} + +export function revokeDevOrcaCloudOrgInvite( + args: OrcaProfileOrgInviteRevokeArgs +): OrcaProfileOrgMemberMutationResult { + const roster = getDevRoster(args.orgId) + const email = args.email.toLowerCase() + const index = roster.pendingInvites.findIndex((invite) => invite.email.toLowerCase() === email) + if (index === -1) { + return { status: 'not-found' } + } + roster.pendingInvites.splice(index, 1) + return { status: 'ok' } +} + +export function changeDevOrcaCloudOrgMemberRole( + args: OrcaProfileOrgMemberChangeRoleArgs +): OrcaProfileOrgMemberMutationResult { + const roster = getDevRoster(args.orgId) + if (args.userId === devSelf().userId) { + return { status: 'invalid', reason: 'cannot_change_own_role' } + } + const member = roster.members.find((candidate) => candidate.userId === args.userId) + if (!member) { + return { status: 'not-found' } + } + member.role = args.role + return { status: 'ok' } +} + +export function removeDevOrcaCloudOrgMember( + args: OrcaProfileOrgMemberRemoveArgs +): OrcaProfileOrgMemberMutationResult { + const roster = getDevRoster(args.orgId) + if (args.userId === devSelf().userId) { + return { status: 'invalid', reason: 'cannot_remove_self' } + } + const index = roster.members.findIndex((candidate) => candidate.userId === args.userId) + if (index === -1) { + return { status: 'not-found' } + } + roster.members.splice(index, 1) + return { status: 'ok' } +} diff --git a/src/main/orca-profiles/profile-cloud-dev-service.test.ts b/src/main/orca-profiles/profile-cloud-dev-service.test.ts new file mode 100644 index 00000000000..8801320d13f --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-dev-service.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { + beginOrcaCloudPkceFlowMock, + exchangeOrcaCloudAuthCodeMock, + revokeOrcaCloudSessionMock, + safeStorageMock +} = vi.hoisted(() => ({ + beginOrcaCloudPkceFlowMock: vi.fn(), + exchangeOrcaCloudAuthCodeMock: vi.fn(), + revokeOrcaCloudSessionMock: vi.fn(), + safeStorageMock: { + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + isEncryptionAvailable: vi.fn(() => true) + } +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataPath + }, + safeStorage: safeStorageMock +})) + +vi.mock('./profile-cloud-pkce', () => ({ + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock +})) + +vi.mock('./profile-cloud-client', () => ({ + createOrcaCloudProfile: vi.fn(), + exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock, + refreshOrcaCloudCapabilities: vi.fn(), + refreshOrcaCloudSession: vi.fn(), + revokeOrcaCloudSession: revokeOrcaCloudSessionMock, + selectOrcaCloudOrg: vi.fn() +})) + +import { + connectCurrentOrcaProfile, + createCloudLinkedOrcaProfile, + getCurrentOrcaProfileAuthStatus, + selectCurrentOrcaProfileOrg, + signOutCurrentOrcaProfile +} from './profile-cloud-service' + +describe('Orca cloud dev auth service', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-dev-auth-')) + beginOrcaCloudPkceFlowMock.mockReset() + exchangeOrcaCloudAuthCodeMock.mockReset() + revokeOrcaCloudSessionMock.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) + safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8')) + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + vi.unstubAllEnvs() + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '1') + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('connects the active profile without PKCE or cloud endpoints', async () => { + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + configured: true, + state: 'local' + }) + + const result = await connectCurrentOrcaProfile(userDataPath) + + expect(result.status).toBe('connected') + expect(beginOrcaCloudPkceFlowMock).not.toHaveBeenCalled() + expect(exchangeOrcaCloudAuthCodeMock).not.toHaveBeenCalled() + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { + cloudProfileId: 'dev-cloud-local-default', + email: 'dev@orca.local' + }, + capabilities: { + flags: expect.objectContaining({ 'share.create': true }) + } + }) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toHaveLength(2) + }) + + it('selects dev organizations and creates org-scoped cloud profiles locally', async () => { + await connectCurrentOrcaProfile(userDataPath) + + const selected = await selectCurrentOrcaProfileOrg(userDataPath, 'dev-acme') + const created = await createCloudLinkedOrcaProfile(userDataPath, { + orgId: 'dev-acme', + name: 'Acme Dev' + }) + + expect(selected.status).toBe('selected') + expect(getCurrentOrcaProfileAuthStatus(userDataPath).cloud).toMatchObject({ + activeOrgId: 'dev-acme', + activeOrgName: 'Acme Dev' + }) + expect(created.status).toBe('created') + if (created.status === 'created') { + expect(created.profile).toMatchObject({ + name: 'Acme Dev', + kind: 'cloud-linked', + cloud: expect.objectContaining({ + activeOrgId: 'dev-acme', + activeOrgName: 'Acme Dev' + }) + }) + } + }) + + it('signs out locally without calling the cloud logout endpoint', async () => { + await connectCurrentOrcaProfile(userDataPath) + + const result = await signOutCurrentOrcaProfile(userDataPath) + + expect(result.status).toBe('signed-out') + expect(revokeOrcaCloudSessionMock).not.toHaveBeenCalled() + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + configured: true, + state: 'local', + persistence: 'none' + }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-dev-service.ts b/src/main/orca-profiles/profile-cloud-dev-service.ts new file mode 100644 index 00000000000..bb7a6e3599c --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-dev-service.ts @@ -0,0 +1,92 @@ +import type { + CreateCloudLinkedOrcaProfileArgs, + OrcaProfileListState +} from '../../shared/orca-profiles' +import type { ActiveOrcaProfileState } from './profile-index-store' +import { createCloudLinkedOrcaProfileRecord, linkOrcaProfileToCloud } from './profile-cloud-index' +import { readOrcaCloudSession, saveOrcaCloudSessionExchange } from './profile-cloud-session-store' +import { createDevOrcaCloudSession } from './profile-cloud-dev-auth' + +type DevProfileListResult = OrcaProfileListState + +type DevCreateProfileResult = + | { + status: 'created' + list: ReturnType + } + | { status: 'reconnect-required' } + +type DevMutationResult = + | { + status: 'updated' + list: DevProfileListResult + } + | { status: 'reconnect-required' } + +export function connectDevOrcaCloudProfile( + active: ActiveOrcaProfileState, + userDataPath: string +): DevProfileListResult { + const session = createDevOrcaCloudSession({ localProfileId: active.profile.id }) + saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session) + return linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath) +} + +export function createDevCloudLinkedOrcaProfile( + active: ActiveOrcaProfileState, + userDataPath: string, + args: CreateCloudLinkedOrcaProfileArgs +): DevCreateProfileResult { + if (readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found') { + return { status: 'reconnect-required' } + } + const session = createDevOrcaCloudSession({ orgId: args.orgId }) + const list = createCloudLinkedOrcaProfileRecord(session.cloud, { name: args.name }, userDataPath) + saveOrcaCloudSessionExchange(list.profile.id, userDataPath, session) + return { status: 'created', list } +} + +export function refreshDevOrcaCloudProfile( + active: ActiveOrcaProfileState, + userDataPath: string +): DevMutationResult { + if ( + !active.profile.cloud || + readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found' + ) { + return { status: 'reconnect-required' } + } + const session = createDevOrcaCloudSession({ + localProfileId: active.profile.id, + cloudProfileId: active.profile.cloud.cloudProfileId, + orgId: active.profile.cloud.activeOrgId + }) + saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session) + return { + status: 'updated', + list: linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath) + } +} + +export function selectDevOrcaCloudOrg( + active: ActiveOrcaProfileState, + userDataPath: string, + orgId: string +): DevMutationResult { + if ( + !active.profile.cloud || + readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found' + ) { + return { status: 'reconnect-required' } + } + const session = createDevOrcaCloudSession({ + localProfileId: active.profile.id, + cloudProfileId: active.profile.cloud.cloudProfileId, + orgId + }) + saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session) + return { + status: 'updated', + list: linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath) + } +} diff --git a/src/main/orca-profiles/profile-cloud-index.ts b/src/main/orca-profiles/profile-cloud-index.ts new file mode 100644 index 00000000000..3d6843fcbb2 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-index.ts @@ -0,0 +1,143 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync } from 'node:fs' +import type { + OrcaProfileCloudSummary, + OrcaProfileListState, + OrcaProfileSummary +} from '../../shared/orca-profiles' +import { + getOrcaProfileDirectory, + getOrcaProfileIndexPath, + loadOrCreateProfileIndex, + writeProfileIndex +} from './profile-index-store' + +export type CreateCloudLinkedOrcaProfileRecordResult = OrcaProfileListState & { + profile: OrcaProfileSummary +} + +function sanitizeProfileName(value: unknown, fallback: string): string { + const trimmed = typeof value === 'string' ? value.trim() : '' + return (trimmed || fallback).slice(0, 80) +} + +function profileInitial(name: string): string { + return (name.match(/[A-Za-z0-9]/)?.[0] ?? 'C').toUpperCase() +} + +function toCloudLinkedProfile( + profile: OrcaProfileSummary, + cloud: OrcaProfileCloudSummary, + now: number +): OrcaProfileSummary { + return { + ...profile, + kind: 'cloud-linked', + cloud, + updatedAt: now, + lastOpenedAt: now + } +} + +function toLocalProfile(profile: OrcaProfileSummary, now: number): OrcaProfileSummary { + const { cloud: _cloud, ...localProfile } = profile + return { + ...localProfile, + kind: 'local', + updatedAt: now, + lastOpenedAt: now + } +} + +export function createCloudLinkedOrcaProfileRecord( + cloud: OrcaProfileCloudSummary, + args: { name?: string }, + userDataPath: string +): CreateCloudLinkedOrcaProfileRecordResult { + const index = loadOrCreateProfileIndex(userDataPath) + const now = Date.now() + const fallbackName = cloud.activeOrgName ?? cloud.displayName ?? cloud.email + const name = sanitizeProfileName(args.name, fallbackName) + const profile: OrcaProfileSummary = { + id: `cloud-${randomUUID()}`, + name, + avatar: { + kind: 'initials', + initials: profileInitial(name), + color: 'neutral' + }, + kind: 'cloud-linked', + createdAt: now, + updatedAt: now, + lastOpenedAt: now, + cloud + } + const nextIndex = { + ...index, + profiles: [...index.profiles, profile] + } + mkdirSync(getOrcaProfileDirectory(profile.id, userDataPath), { recursive: true }) + writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + return { + activeProfileId: nextIndex.activeProfileId, + profiles: nextIndex.profiles, + profile + } +} + +export function linkOrcaProfileToCloud( + profileId: string, + cloud: OrcaProfileCloudSummary, + userDataPath: string +): OrcaProfileListState { + const index = loadOrCreateProfileIndex(userDataPath) + const now = Date.now() + let found = false + const profiles = index.profiles.map((profile) => { + if (profile.id !== profileId) { + return profile + } + found = true + return toCloudLinkedProfile(profile, cloud, now) + }) + if (!found) { + throw new Error('unknown_orca_profile') + } + const nextIndex = { + ...index, + profiles + } + writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + return { + activeProfileId: nextIndex.activeProfileId, + profiles: nextIndex.profiles + } +} + +export function unlinkOrcaProfileFromCloud( + profileId: string, + userDataPath: string +): OrcaProfileListState { + const index = loadOrCreateProfileIndex(userDataPath) + const now = Date.now() + let found = false + const profiles = index.profiles.map((profile) => { + if (profile.id !== profileId) { + return profile + } + found = true + return toLocalProfile(profile, now) + }) + if (!found) { + throw new Error('unknown_orca_profile') + } + const nextIndex = { + ...index, + profiles + } + writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + return { + activeProfileId: nextIndex.activeProfileId, + profiles: nextIndex.profiles + } +} diff --git a/src/main/orca-profiles/profile-cloud-org-members-client.test.ts b/src/main/orca-profiles/profile-cloud-org-members-client.test.ts new file mode 100644 index 00000000000..bc3792742d2 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-org-members-client.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import type { OrcaCloudSession } from './profile-cloud-session-store' +import { OrcaCloudRequestError } from './profile-cloud-client' +import { + changeOrcaCloudOrgMemberRole, + inviteOrcaCloudOrgMember, + listOrcaCloudOrgMembers, + removeOrcaCloudOrgMember, + revokeOrcaCloudOrgInvite +} from './profile-cloud-org-members-client' + +const fetchMock = vi.fn() + +const config: OrcaCloudAuthConfig = { + apiBaseUrl: 'https://orca-cloud.example', + authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize', + sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session', + refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh', + capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities', + profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', + orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', + logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + clientId: 'desktop-client', + scope: 'openid profile email offline_access' +} + +const session: OrcaCloudSession = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: 999, + capabilities: { flags: {}, refreshedAt: 1 } +} + +function mockJsonResponse(value: unknown, init: { ok?: boolean; status?: number } = {}): void { + fetchMock.mockResolvedValue({ + ok: init.ok ?? true, + status: init.status ?? 200, + json: async () => value + }) +} + +describe('Orca cloud org members client', () => { + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + it('normalizes the roster, dropping malformed rows and defaulting the viewer role', async () => { + mockJsonResponse({ + members: [ + { userId: 'user-1', email: 'nina@example.com', displayName: 'Nina', role: 'admin' }, + { userId: null, email: 'pending-user@example.com', role: 'member' }, + { userId: 'user-3', email: 'weird@example.com', role: 'superadmin' }, + { userId: 'user-4' } + ], + pendingInvites: [{ email: 'invitee@example.com', role: 'member', createdAt: 1712000000000 }], + viewerRole: 'not-a-role', + canManageMembers: true + }) + + await expect(listOrcaCloudOrgMembers(config, session, 'org-1')).resolves.toEqual({ + members: [ + { userId: 'user-1', email: 'nina@example.com', displayName: 'Nina', role: 'admin' }, + { userId: null, email: 'pending-user@example.com', displayName: undefined, role: 'member' }, + { userId: 'user-3', email: 'weird@example.com', displayName: undefined, role: 'member' } + ], + pendingInvites: [{ email: 'invitee@example.com', role: 'member', createdAt: 1712000000000 }], + viewerRole: 'member', + canManageMembers: true + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://orca-cloud.example/v1/desktop/orgs/org-1/members', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ authorization: 'Bearer access-token' }) + }) + ) + }) + + it('percent-encodes the org id in the request URL', async () => { + mockJsonResponse({ + members: [], + pendingInvites: [], + viewerRole: 'owner', + canManageMembers: true + }) + await listOrcaCloudOrgMembers(config, session, 'org/with space') + expect(fetchMock).toHaveBeenCalledWith( + 'https://orca-cloud.example/v1/desktop/orgs/org%2Fwith%20space/members', + expect.any(Object) + ) + }) + + it('posts invites with an email and role body', async () => { + mockJsonResponse({ ok: true }) + await inviteOrcaCloudOrgMember(config, session, { + orgId: 'org-1', + email: 'new@example.com', + role: 'admin' + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://orca-cloud.example/v1/desktop/orgs/org-1/invites', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'new@example.com', role: 'admin' }) + }) + ) + }) + + it('carries the server error code on a 409 conflict', async () => { + mockJsonResponse({ error: 'already_invited' }, { ok: false, status: 409 }) + await expect( + inviteOrcaCloudOrgMember(config, session, { + orgId: 'org-1', + email: 'dupe@example.com', + role: 'member' + }) + ).rejects.toMatchObject({ statusCode: 409, errorCode: 'already_invited' }) + }) + + it('surfaces a 403 as an OrcaCloudRequestError without an error code when the body is empty', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: async () => { + throw new Error('no body') + } + }) + await expect( + changeOrcaCloudOrgMemberRole(config, session, { + orgId: 'org-1', + userId: 'user-2', + role: 'admin' + }) + ).rejects.toBeInstanceOf(OrcaCloudRequestError) + await expect( + removeOrcaCloudOrgMember(config, session, { orgId: 'org-1', userId: 'user-2' }) + ).rejects.toMatchObject({ statusCode: 403, errorCode: undefined }) + }) + + it('posts invite revocations by email', async () => { + mockJsonResponse({ ok: true }) + await revokeOrcaCloudOrgInvite(config, session, { orgId: 'org-1', email: 'gone@example.com' }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://orca-cloud.example/v1/desktop/orgs/org-1/invites/revoke', + expect.objectContaining({ body: JSON.stringify({ email: 'gone@example.com' }) }) + ) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-org-members-client.ts b/src/main/orca-profiles/profile-cloud-org-members-client.ts new file mode 100644 index 00000000000..cd1b106e489 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-org-members-client.ts @@ -0,0 +1,209 @@ +import type { + OrcaOrgMember, + OrcaOrgMembersRoster, + OrcaOrgPendingInvite, + OrcaOrgRole +} from '../../shared/orca-profiles' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import type { OrcaCloudSession } from './profile-cloud-session-store' +import { OrcaCloudRequestError } from './profile-cloud-client' + +const CLOUD_REQUEST_TIMEOUT_MS = 30_000 +const ORG_ROLES: readonly OrcaOrgRole[] = ['owner', 'admin', 'member'] + +function isOrgRole(value: unknown): value is OrcaOrgRole { + return typeof value === 'string' && (ORG_ROLES as readonly string[]).includes(value) +} + +function normalizeRole(value: unknown, fallback: OrcaOrgRole): OrcaOrgRole { + return isOrgRole(value) ? value : fallback +} + +function optionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const trimmed = value.trim() + return trimmed || undefined +} + +function normalizeTimestamp(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string') { + const parsed = Date.parse(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + return Date.now() +} + +function normalizeMember(value: unknown): OrcaOrgMember | null { + if (!value || typeof value !== 'object') { + return null + } + const record = value as Record + const email = optionalString(record.email) + if (!email) { + return null + } + const userId = optionalString(record.userId) + return { + userId: userId ?? null, + email, + displayName: optionalString(record.displayName), + role: normalizeRole(record.role, 'member') + } +} + +function normalizePendingInvite(value: unknown): OrcaOrgPendingInvite | null { + if (!value || typeof value !== 'object') { + return null + } + const record = value as Record + const email = optionalString(record.email) + if (!email) { + return null + } + return { + email, + role: normalizeRole(record.role, 'member'), + createdAt: normalizeTimestamp(record.createdAt) + } +} + +function normalizeRoster(value: unknown): OrcaOrgMembersRoster { + if (!value || typeof value !== 'object') { + throw new Error('invalid_orca_org_members_roster') + } + const record = value as Record + const members = Array.isArray(record.members) + ? record.members + .map(normalizeMember) + .filter((member): member is OrcaOrgMember => member !== null) + : [] + const pendingInvites = Array.isArray(record.pendingInvites) + ? record.pendingInvites + .map(normalizePendingInvite) + .filter((invite): invite is OrcaOrgPendingInvite => invite !== null) + : [] + return { + members, + pendingInvites, + // Why: default to the least-privileged role so a malformed viewerRole can + // never widen the client-side management affordance; the server still + // enforces authorization on every mutation. + viewerRole: normalizeRole(record.viewerRole, 'member'), + canManageMembers: record.canManageMembers === true + } +} + +function orgMembersUrl(config: OrcaCloudAuthConfig, orgId: string, path: string): string { + return `${config.apiBaseUrl}/v1/desktop/orgs/${encodeURIComponent(orgId)}${path}` +} + +async function extractErrorCode(response: Response): Promise { + try { + const body = (await response.json()) as unknown + if ( + body && + typeof body === 'object' && + typeof (body as { error?: unknown }).error === 'string' + ) { + return (body as { error: string }).error.trim() || undefined + } + } catch { + // Non-JSON error body; the status code alone drives the caller's mapping. + } + return undefined +} + +// Why: these are fixed first-party endpoints bearing the profile's access token; +// following a redirect would leak that token to another origin, and a stalled +// server must not hang the renderer's awaited IPC call forever. +function requestInit(method: 'GET' | 'POST', accessToken: string, body?: unknown): RequestInit { + return { + method, + headers: { + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + authorization: `Bearer ${accessToken}` + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + redirect: 'error', + signal: AbortSignal.timeout(CLOUD_REQUEST_TIMEOUT_MS) + } +} + +async function requestOrgMembers( + url: string, + init: RequestInit, + parse: (value: unknown) => T +): Promise { + const response = await fetch(url, init) + if (!response.ok) { + throw new OrcaCloudRequestError(response.status, await extractErrorCode(response)) + } + return parse((await response.json()) as unknown) +} + +export async function listOrcaCloudOrgMembers( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + orgId: string +): Promise { + return requestOrgMembers( + orgMembersUrl(config, orgId, '/members'), + requestInit('GET', session.accessToken), + normalizeRoster + ) +} + +export async function inviteOrcaCloudOrgMember( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + args: { orgId: string; email: string; role: OrcaOrgRole } +): Promise { + await requestOrgMembers( + orgMembersUrl(config, args.orgId, '/invites'), + requestInit('POST', session.accessToken, { email: args.email, role: args.role }), + () => undefined + ) +} + +export async function revokeOrcaCloudOrgInvite( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + args: { orgId: string; email: string } +): Promise { + await requestOrgMembers( + orgMembersUrl(config, args.orgId, '/invites/revoke'), + requestInit('POST', session.accessToken, { email: args.email }), + () => undefined + ) +} + +export async function changeOrcaCloudOrgMemberRole( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + args: { orgId: string; userId: string; role: OrcaOrgRole } +): Promise { + await requestOrgMembers( + orgMembersUrl(config, args.orgId, '/members/role'), + requestInit('POST', session.accessToken, { userId: args.userId, role: args.role }), + () => undefined + ) +} + +export async function removeOrcaCloudOrgMember( + config: OrcaCloudAuthConfig, + session: OrcaCloudSession, + args: { orgId: string; userId: string } +): Promise { + await requestOrgMembers( + orgMembersUrl(config, args.orgId, '/members/remove'), + requestInit('POST', session.accessToken, { userId: args.userId }), + () => undefined + ) +} diff --git a/src/main/orca-profiles/profile-cloud-org-members-service.test.ts b/src/main/orca-profiles/profile-cloud-org-members-service.test.ts new file mode 100644 index 00000000000..8d4011d79ff --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-org-members-service.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { OrcaOrgMembersRoster } from '../../shared/orca-profiles' +import { OrcaCloudRequestError } from './profile-cloud-client' + +const { + runWithFreshOrcaCloudSessionMock, + listOrcaCloudOrgMembersMock, + inviteOrcaCloudOrgMemberMock, + revokeOrcaCloudOrgInviteMock, + changeOrcaCloudOrgMemberRoleMock, + removeOrcaCloudOrgMemberMock +} = vi.hoisted(() => ({ + runWithFreshOrcaCloudSessionMock: vi.fn(), + listOrcaCloudOrgMembersMock: vi.fn(), + inviteOrcaCloudOrgMemberMock: vi.fn(), + revokeOrcaCloudOrgInviteMock: vi.fn(), + changeOrcaCloudOrgMemberRoleMock: vi.fn(), + removeOrcaCloudOrgMemberMock: vi.fn() +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { getPath: () => userDataPath } +})) + +vi.mock('./profile-cloud-session-refresh', () => ({ + runWithFreshOrcaCloudSessionMock, + runWithFreshOrcaCloudSession: runWithFreshOrcaCloudSessionMock +})) + +vi.mock('./profile-cloud-org-members-client', () => ({ + listOrcaCloudOrgMembers: listOrcaCloudOrgMembersMock, + inviteOrcaCloudOrgMember: inviteOrcaCloudOrgMemberMock, + revokeOrcaCloudOrgInvite: revokeOrcaCloudOrgInviteMock, + changeOrcaCloudOrgMemberRole: changeOrcaCloudOrgMemberRoleMock, + removeOrcaCloudOrgMember: removeOrcaCloudOrgMemberMock +})) + +import { + changeOrcaProfileOrgMemberRole, + inviteOrcaProfileOrgMember, + listOrcaProfileOrgMembers, + removeOrcaProfileOrgMember, + revokeOrcaProfileOrgInvite +} from './profile-cloud-org-members-service' + +const fakeSession = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: Date.now() + 3_600_000, + capabilities: { flags: {}, refreshedAt: 1 } +} + +// Why: mirror the real contract — invoke the operation with a live session and +// surface its resolved value; business 4xx are returned by the operation as +// values, never thrown, so the session layer never sees them. +function runOperationDirectly(): void { + runWithFreshOrcaCloudSessionMock.mockImplementation( + async ( + _config: unknown, + _active: unknown, + _path: unknown, + op: (session: unknown) => unknown + ) => ({ + status: 'ok', + value: await op(fakeSession) + }) + ) +} + +function configureCloudEnv(): void { + vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client') +} + +const roster: OrcaOrgMembersRoster = { + members: [{ userId: 'user-1', email: 'nina@example.com', role: 'owner' }], + pendingInvites: [], + viewerRole: 'owner', + canManageMembers: true +} + +describe('Orca cloud org members service (configured)', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-org-members-')) + runWithFreshOrcaCloudSessionMock.mockReset() + listOrcaCloudOrgMembersMock.mockReset() + inviteOrcaCloudOrgMemberMock.mockReset() + revokeOrcaCloudOrgInviteMock.mockReset() + changeOrcaCloudOrgMemberRoleMock.mockReset() + removeOrcaCloudOrgMemberMock.mockReset() + vi.unstubAllEnvs() + vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '') + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('reports unconfigured when cloud sign-in is not set up', async () => { + await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({ + status: 'unconfigured' + }) + expect(runWithFreshOrcaCloudSessionMock).not.toHaveBeenCalled() + }) + + it('returns the roster from the client', async () => { + configureCloudEnv() + runOperationDirectly() + listOrcaCloudOrgMembersMock.mockResolvedValue(roster) + + await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({ + status: 'ok', + roster + }) + expect(listOrcaCloudOrgMembersMock).toHaveBeenCalledWith( + expect.any(Object), + fakeSession, + 'org-1' + ) + }) + + it('maps a 409 already_member invite conflict', async () => { + configureCloudEnv() + runOperationDirectly() + inviteOrcaCloudOrgMemberMock.mockRejectedValue(new OrcaCloudRequestError(409, 'already_member')) + + await expect( + inviteOrcaProfileOrgMember(userDataPath, { orgId: 'org-1', email: 'a@b.com', role: 'member' }) + ).resolves.toEqual({ status: 'conflict', reason: 'already_member' }) + }) + + it('maps a 403 role change to forbidden', async () => { + configureCloudEnv() + runOperationDirectly() + changeOrcaCloudOrgMemberRoleMock.mockRejectedValue(new OrcaCloudRequestError(403)) + + await expect( + changeOrcaProfileOrgMemberRole(userDataPath, { + orgId: 'org-1', + userId: 'user-2', + role: 'admin' + }) + ).resolves.toEqual({ status: 'forbidden' }) + }) + + it('maps a 400 cannot_remove_self to an invalid result', async () => { + configureCloudEnv() + runOperationDirectly() + removeOrcaCloudOrgMemberMock.mockRejectedValue( + new OrcaCloudRequestError(400, 'cannot_remove_self') + ) + + await expect( + removeOrcaProfileOrgMember(userDataPath, { orgId: 'org-1', userId: 'user-1' }) + ).resolves.toEqual({ status: 'invalid', reason: 'cannot_remove_self' }) + }) + + it('maps a 404 revoke to not-found', async () => { + configureCloudEnv() + runOperationDirectly() + revokeOrcaCloudOrgInviteMock.mockRejectedValue(new OrcaCloudRequestError(404)) + + await expect( + revokeOrcaProfileOrgInvite(userDataPath, { orgId: 'org-1', email: 'gone@b.com' }) + ).resolves.toEqual({ status: 'not-found' }) + }) + + it('reports reconnect-required when the session layer cannot refresh', async () => { + configureCloudEnv() + runWithFreshOrcaCloudSessionMock.mockResolvedValue({ status: 'reconnect-required' }) + + await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({ + status: 'reconnect-required' + }) + }) +}) + +describe('Orca cloud org members service (dev auth)', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-org-members-dev-')) + runWithFreshOrcaCloudSessionMock.mockReset() + vi.unstubAllEnvs() + vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '1') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('serves an in-memory roster the caller can manage', async () => { + const result = await listOrcaProfileOrgMembers(userDataPath, 'dev-list-org') + if (result.status !== 'ok') { + throw new Error(`Expected ok, got ${result.status}`) + } + expect(result.roster.canManageMembers).toBe(true) + expect(result.roster.viewerRole).toBe('owner') + expect(result.roster.members[0]).toMatchObject({ role: 'owner' }) + expect(result.roster.members.some((member) => member.userId === null)).toBe(true) + expect(result.roster.pendingInvites.length).toBeGreaterThan(0) + expect(runWithFreshOrcaCloudSessionMock).not.toHaveBeenCalled() + }) + + it('mutates the dev roster across invite and revoke', async () => { + const orgId = 'dev-mutate-org' + await expect( + inviteOrcaProfileOrgMember(userDataPath, { + orgId, + email: 'fresh@orca.local', + role: 'member' + }) + ).resolves.toEqual({ status: 'ok' }) + + const afterInvite = await listOrcaProfileOrgMembers(userDataPath, orgId) + if (afterInvite.status !== 'ok') { + throw new Error('expected ok') + } + expect(afterInvite.roster.pendingInvites.some((i) => i.email === 'fresh@orca.local')).toBe(true) + + await expect( + inviteOrcaProfileOrgMember(userDataPath, { + orgId, + email: 'fresh@orca.local', + role: 'member' + }) + ).resolves.toEqual({ status: 'conflict', reason: 'already_invited' }) + + await expect( + revokeOrcaProfileOrgInvite(userDataPath, { orgId, email: 'fresh@orca.local' }) + ).resolves.toEqual({ status: 'ok' }) + await expect( + revokeOrcaProfileOrgInvite(userDataPath, { orgId, email: 'fresh@orca.local' }) + ).resolves.toEqual({ status: 'not-found' }) + }) + + it('blocks changing the dev owner (self) role', async () => { + const orgId = 'dev-self-org' + const list = await listOrcaProfileOrgMembers(userDataPath, orgId) + if (list.status !== 'ok') { + throw new Error('expected ok') + } + const self = list.roster.members.find((member) => member.role === 'owner') + await expect( + changeOrcaProfileOrgMemberRole(userDataPath, { + orgId, + userId: self?.userId ?? 'dev-user', + role: 'member' + }) + ).resolves.toEqual({ status: 'invalid', reason: 'cannot_change_own_role' }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-org-members-service.ts b/src/main/orca-profiles/profile-cloud-org-members-service.ts new file mode 100644 index 00000000000..d115059a08f --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-org-members-service.ts @@ -0,0 +1,212 @@ +import type { + OrcaProfileOrgInviteRevokeArgs, + OrcaProfileOrgMemberChangeRoleArgs, + OrcaProfileOrgMemberInviteArgs, + OrcaProfileOrgMemberMutationResult, + OrcaProfileOrgMemberRemoveArgs, + OrcaProfileOrgMembersListResult +} from '../../shared/orca-profiles' +import type { ActiveOrcaProfileState } from './profile-index-store' +import { ensureActiveOrcaProfile } from './profile-index-store' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config' +import type { OrcaCloudSession } from './profile-cloud-session-store' +import { OrcaCloudRequestError } from './profile-cloud-client' +import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh' +import { + changeOrcaCloudOrgMemberRole, + inviteOrcaCloudOrgMember, + listOrcaCloudOrgMembers, + removeOrcaCloudOrgMember, + revokeOrcaCloudOrgInvite +} from './profile-cloud-org-members-client' +import { + changeDevOrcaCloudOrgMemberRole, + inviteDevOrcaCloudOrgMember, + listDevOrcaCloudOrgMembers, + removeDevOrcaCloudOrgMember, + revokeDevOrcaCloudOrgInvite +} from './profile-cloud-dev-org-members' + +type OrgCallResult = + | { status: 'ok'; value: T } + | { status: 'reconnect-required' } + | { status: 'request-error'; error: OrcaCloudRequestError } + | { status: 'failed'; error: string } + +// Why: only a 401 means the token itself is stale and should drive a session +// refresh/reconnect. 403/404/409/400 are business or permission outcomes the UI +// must interpret, so they are surfaced as values rather than thrown — otherwise +// runWithFreshOrcaCloudSession would treat a 403 as an auth failure and burn a +// pointless token refresh + retry before giving up. +async function runOrgMemberCall( + config: OrcaCloudAuthConfig, + active: ActiveOrcaProfileState, + userDataPath: string, + call: (session: OrcaCloudSession) => Promise +): Promise> { + try { + const operation = await runWithFreshOrcaCloudSession( + config, + active, + userDataPath, + async (session) => { + try { + return { ok: true as const, value: await call(session) } + } catch (error) { + if (error instanceof OrcaCloudRequestError && error.statusCode !== 401) { + return { ok: false as const, error } + } + throw error + } + } + ) + if (operation.status !== 'ok') { + return { status: 'reconnect-required' } + } + const outcome = operation.value + return outcome.ok + ? { status: 'ok', value: outcome.value } + : { status: 'request-error', error: outcome.error } + } catch (error) { + return { status: 'failed', error: error instanceof Error ? error.message : String(error) } + } +} + +function mapMutationRequestError(error: OrcaCloudRequestError): OrcaProfileOrgMemberMutationResult { + switch (error.statusCode) { + case 403: + return { status: 'forbidden' } + case 404: + return { status: 'not-found' } + case 409: + return { + status: 'conflict', + reason: error.errorCode === 'already_member' ? 'already_member' : 'already_invited' + } + case 400: + return { + status: 'invalid', + reason: + error.errorCode === 'cannot_remove_self' ? 'cannot_remove_self' : 'cannot_change_own_role' + } + default: + return { status: 'failed', error: error.message } + } +} + +function mapMutationResult(result: OrgCallResult): OrcaProfileOrgMemberMutationResult { + switch (result.status) { + case 'ok': + return { status: 'ok' } + case 'reconnect-required': + return { status: 'reconnect-required' } + case 'request-error': + return mapMutationRequestError(result.error) + case 'failed': + return { status: 'failed', error: result.error } + } +} + +export async function listOrcaProfileOrgMembers( + userDataPath: string, + orgId: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + return { status: 'ok', roster: listDevOrcaCloudOrgMembers(orgId) } + } + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured' } + } + const result = await runOrgMemberCall(configState.config, active, userDataPath, (session) => + listOrcaCloudOrgMembers(configState.config, session, orgId) + ) + switch (result.status) { + case 'ok': + return { status: 'ok', roster: result.value } + case 'reconnect-required': + return { status: 'reconnect-required' } + case 'request-error': + return { status: 'failed', error: result.error.message } + case 'failed': + return { status: 'failed', error: result.error } + } +} + +export async function inviteOrcaProfileOrgMember( + userDataPath: string, + args: OrcaProfileOrgMemberInviteArgs +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + return inviteDevOrcaCloudOrgMember(args) + } + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured' } + } + return mapMutationResult( + await runOrgMemberCall(configState.config, active, userDataPath, (session) => + inviteOrcaCloudOrgMember(configState.config, session, args) + ) + ) +} + +export async function revokeOrcaProfileOrgInvite( + userDataPath: string, + args: OrcaProfileOrgInviteRevokeArgs +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + return revokeDevOrcaCloudOrgInvite(args) + } + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured' } + } + return mapMutationResult( + await runOrgMemberCall(configState.config, active, userDataPath, (session) => + revokeOrcaCloudOrgInvite(configState.config, session, args) + ) + ) +} + +export async function changeOrcaProfileOrgMemberRole( + userDataPath: string, + args: OrcaProfileOrgMemberChangeRoleArgs +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + return changeDevOrcaCloudOrgMemberRole(args) + } + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured' } + } + return mapMutationResult( + await runOrgMemberCall(configState.config, active, userDataPath, (session) => + changeOrcaCloudOrgMemberRole(configState.config, session, args) + ) + ) +} + +export async function removeOrcaProfileOrgMember( + userDataPath: string, + args: OrcaProfileOrgMemberRemoveArgs +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + return removeDevOrcaCloudOrgMember(args) + } + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured' } + } + return mapMutationResult( + await runOrgMemberCall(configState.config, active, userDataPath, (session) => + removeOrcaCloudOrgMember(configState.config, session, args) + ) + ) +} diff --git a/src/main/orca-profiles/profile-cloud-pkce.test.ts b/src/main/orca-profiles/profile-cloud-pkce.test.ts new file mode 100644 index 00000000000..03f6e5894b3 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-pkce.test.ts @@ -0,0 +1,127 @@ +import { get } from 'node:http' +import { describe, expect, it, beforeEach, vi } from 'vitest' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' + +const { openExternalMock } = vi.hoisted(() => ({ + openExternalMock: vi.fn() +})) + +vi.mock('electron', () => ({ + shell: { + openExternal: openExternalMock + } +})) + +import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce' + +type HttpResponse = { + body: string + statusCode: number | undefined +} + +const config: OrcaCloudAuthConfig = { + apiBaseUrl: 'https://orca-cloud.example', + authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize', + sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session', + refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh', + capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities', + profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile', + orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org', + logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout', + clientId: 'desktop-client', + scope: 'openid profile email offline_access' +} + +function readHttp(url: string): Promise { + return new Promise((resolve, reject) => { + const request = get(url, (response) => { + response.setEncoding('utf-8') + let body = '' + response.on('data', (chunk: string) => { + body += chunk + }) + response.on('end', () => { + resolve({ body, statusCode: response.statusCode }) + }) + }) + request.on('error', reject) + }) +} + +function callbackUrl(redirectUri: string, params: Record): string { + const url = new URL(redirectUri) + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value) + } + return url.toString() +} + +async function startedFlow(): Promise<{ + authUrl: URL + flow: ReturnType + nonce: string + redirectUri: string + state: string +}> { + const flow = beginOrcaCloudPkceFlow(config, 'local-default') + await vi.waitFor(() => expect(openExternalMock).toHaveBeenCalledTimes(1)) + const authUrl = new URL(String(openExternalMock.mock.calls[0]?.[0])) + const nonce = authUrl.searchParams.get('nonce') + const redirectUri = authUrl.searchParams.get('redirect_uri') + const state = authUrl.searchParams.get('state') + if (!nonce || !redirectUri || !state) { + throw new Error('Expected PKCE flow to create nonce, redirect_uri, and state') + } + return { authUrl, flow, nonce, redirectUri, state } +} + +describe('Orca cloud PKCE flow', () => { + beforeEach(() => { + openExternalMock.mockReset() + openExternalMock.mockResolvedValue(undefined) + }) + + it('keeps the loopback listener alive after an invalid callback', async () => { + const { flow, redirectUri, state } = await startedFlow() + + const invalidResponse = await readHttp( + callbackUrl(redirectUri, { code: 'wrong-code', state: 'wrong-state' }) + ) + expect(invalidResponse.statusCode).toBe(400) + + const validResponse = await readHttp(callbackUrl(redirectUri, { code: 'real-code', state })) + expect(validResponse.statusCode).toBe(200) + await expect(flow).resolves.toMatchObject({ + code: 'real-code', + redirectUri, + state + }) + }) + + it('rejects a provider error that matches the flow state', async () => { + const { flow, redirectUri, state } = await startedFlow() + const observedFlow = flow.catch((error: unknown) => error) + + const response = await readHttp(callbackUrl(redirectUri, { error: 'access_denied', state })) + + expect(response.statusCode).toBe(400) + await expect(observedFlow).resolves.toMatchObject({ message: 'orca_cloud_auth_denied' }) + }) + + it('adds desktop PKCE parameters to the authorize URL', async () => { + const { authUrl, flow, nonce, redirectUri, state } = await startedFlow() + + expect(authUrl.searchParams.get('client_id')).toBe('desktop-client') + expect(authUrl.searchParams.get('response_type')).toBe('code') + expect(authUrl.searchParams.get('redirect_uri')).toBe(redirectUri) + expect(authUrl.searchParams.get('scope')).toBe('openid profile email offline_access') + expect(authUrl.searchParams.get('nonce')).toBe(nonce) + expect(authUrl.searchParams.get('state')).toBe(state) + expect(authUrl.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]+$/) + expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256') + expect(authUrl.searchParams.get('local_profile_id')).toBe('local-default') + + await readHttp(callbackUrl(redirectUri, { code: 'real-code', state })) + await expect(flow).resolves.toMatchObject({ code: 'real-code', nonce }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-pkce.ts b/src/main/orca-profiles/profile-cloud-pkce.ts new file mode 100644 index 00000000000..8adf3f3e587 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-pkce.ts @@ -0,0 +1,142 @@ +import { createHash, randomBytes } from 'node:crypto' +import { createServer, type Server, type ServerResponse } from 'node:http' +import { shell } from 'electron' +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' + +export type OrcaCloudAuthorizationCode = { + code: string + codeVerifier: string + nonce: string + redirectUri: string + state: string +} + +const AUTH_TIMEOUT_MS = 5 * 60 * 1000 + +function base64Url(buffer: Buffer): string { + return buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') +} + +function createCodeVerifier(): string { + return base64Url(randomBytes(32)) +} + +function createCodeChallenge(verifier: string): string { + return base64Url(createHash('sha256').update(verifier).digest()) +} + +function closeServer(server: Server): void { + try { + // Why: keep-alive sockets from the browser can delay 'close' (and the + // timeout cleanup) until the browser drops the connection. + server.closeAllConnections?.() + server.close() + } catch { + // Already closed. + } +} + +export function beginOrcaCloudPkceFlow( + config: OrcaCloudAuthConfig, + localProfileId: string +): Promise { + const codeVerifier = createCodeVerifier() + const nonce = base64Url(randomBytes(32)) + const state = base64Url(randomBytes(32)) + + return new Promise((resolve, reject) => { + let settled = false + let redirectUri = '' + + function rejectFlow(error: Error): void { + if (settled) { + return + } + settled = true + reject(error) + closeServer(server) + } + + function resolveFlow(code: string): void { + if (settled) { + return + } + settled = true + resolve({ + code, + codeVerifier, + nonce, + redirectUri, + state + }) + closeServer(server) + } + + function writeInvalidCallback(response: ServerResponse): void { + response.writeHead(400) + response.end('Invalid Orca sign-in response.') + } + + const server = createServer((request, response) => { + try { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + if (url.pathname !== '/auth/callback') { + response.writeHead(404) + response.end('Not found') + return + } + const code = url.searchParams.get('code') + const returnedState = url.searchParams.get('state') + if (returnedState !== state) { + // Why: stray loopback probes must not be able to cancel the user's login. + writeInvalidCallback(response) + return + } + if (url.searchParams.has('error')) { + response.writeHead(400) + response.end('Orca sign-in was cancelled.') + rejectFlow(new Error('orca_cloud_auth_denied')) + return + } + if (!code) { + writeInvalidCallback(response) + return + } + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + response.end('Orca

You can return to Orca.

') + resolveFlow(code) + } catch (error) { + rejectFlow(error instanceof Error ? error : new Error('orca_cloud_auth_callback_failed')) + } + }) + + const timeout = setTimeout(() => { + rejectFlow(new Error('orca_cloud_auth_timeout')) + }, AUTH_TIMEOUT_MS) + server.once('close', () => clearTimeout(timeout)) + server.once('error', rejectFlow) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + rejectFlow(new Error('orca_cloud_auth_loopback_unavailable')) + return + } + redirectUri = `http://127.0.0.1:${address.port}/auth/callback` + const authorizeUrl = new URL(config.authorizeEndpoint) + authorizeUrl.searchParams.set('client_id', config.clientId) + authorizeUrl.searchParams.set('response_type', 'code') + authorizeUrl.searchParams.set('redirect_uri', redirectUri) + authorizeUrl.searchParams.set('scope', config.scope) + authorizeUrl.searchParams.set('nonce', nonce) + authorizeUrl.searchParams.set('state', state) + authorizeUrl.searchParams.set('code_challenge', createCodeChallenge(codeVerifier)) + authorizeUrl.searchParams.set('code_challenge_method', 'S256') + authorizeUrl.searchParams.set('local_profile_id', localProfileId) + void shell.openExternal(authorizeUrl.toString()).catch((error) => { + rejectFlow( + error instanceof Error ? error : new Error('orca_cloud_auth_browser_open_failed') + ) + }) + }) + }) +} diff --git a/src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts b/src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts new file mode 100644 index 00000000000..045f31359f8 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaProfileCloudSummary +} from '../../shared/orca-profiles' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +const { + beginOrcaCloudPkceFlowMock, + createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCodeMock, + refreshOrcaCloudCapabilitiesMock, + refreshOrcaCloudSessionMock, + selectOrcaCloudOrgMock, + OrcaCloudRequestErrorMock, + safeStorageMock +} = vi.hoisted(() => ({ + beginOrcaCloudPkceFlowMock: vi.fn(), + createOrcaCloudProfileMock: vi.fn(), + exchangeOrcaCloudAuthCodeMock: vi.fn(), + refreshOrcaCloudCapabilitiesMock: vi.fn(), + refreshOrcaCloudSessionMock: vi.fn(), + selectOrcaCloudOrgMock: vi.fn(), + OrcaCloudRequestErrorMock: class OrcaCloudRequestError extends Error { + constructor(public readonly statusCode: number) { + super(`orca_cloud_request_failed_${statusCode}`) + this.name = 'OrcaCloudRequestError' + } + }, + safeStorageMock: { + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + isEncryptionAvailable: vi.fn(() => true) + } +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataPath + }, + safeStorage: safeStorageMock +})) + +vi.mock('./profile-cloud-pkce', () => ({ + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock +})) + +vi.mock('./profile-cloud-client', () => ({ + OrcaCloudRequestError: OrcaCloudRequestErrorMock, + createOrcaCloudProfile: createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock, + refreshOrcaCloudCapabilities: refreshOrcaCloudCapabilitiesMock, + refreshOrcaCloudSession: refreshOrcaCloudSessionMock, + revokeOrcaCloudSession: vi.fn(), + selectOrcaCloudOrg: selectOrcaCloudOrgMock +})) + +import { + connectCurrentOrcaProfile, + createCloudLinkedOrcaProfile, + getCurrentOrcaProfileAuthStatus, + refreshCurrentOrcaProfileAuth, + selectCurrentOrcaProfileOrg +} from './profile-cloud-service' + +const cloudSummary: OrcaProfileCloudSummary = { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + displayName: 'Nina', + linkedAt: 10 +} + +const capabilities: OrcaCloudCapabilities = { + flags: { share: true }, + refreshedAt: 11 +} + +const organizations: OrcaCloudOrgSummary[] = [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: 'org-2', name: 'Personal' } +] + +function futureExpiresAt(): number { + return Date.now() + 3_600_000 +} + +function configureCloudEnv(): void { + vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client') +} + +function mockSuccessfulConnect(): void { + beginOrcaCloudPkceFlowMock.mockResolvedValue({ + code: 'auth-code', + codeVerifier: 'code-verifier', + nonce: 'nonce', + redirectUri: 'http://127.0.0.1:4100/auth/callback', + state: 'state' + }) + exchangeOrcaCloudAuthCodeMock.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: futureExpiresAt(), + cloud: cloudSummary, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) +} + +function mockSuccessfulSessionRefresh(): void { + refreshOrcaCloudSessionMock.mockResolvedValue({ + accessToken: 'rotated-access-token', + refreshToken: 'rotated-refresh-token', + expiresAt: futureExpiresAt(), + cloud: cloudSummary, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) +} + +describe('Orca cloud profile auth-failure retry', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-auth-retry-')) + beginOrcaCloudPkceFlowMock.mockReset() + createOrcaCloudProfileMock.mockReset() + exchangeOrcaCloudAuthCodeMock.mockReset() + refreshOrcaCloudCapabilitiesMock.mockReset() + refreshOrcaCloudSessionMock.mockReset() + selectOrcaCloudOrgMock.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) + safeStorageMock.encryptString.mockImplementation((value: string) => + Buffer.from(value, 'utf-8') + ) + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + vi.unstubAllEnvs() + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('refreshes and retries cloud profile creation after an auth failure', async () => { + configureCloudEnv() + mockSuccessfulConnect() + mockSuccessfulSessionRefresh() + await connectCurrentOrcaProfile(userDataPath) + createOrcaCloudProfileMock + .mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401)) + .mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresAt: futureExpiresAt(), + cloud: { ...cloudSummary, cloudProfileId: 'cloud-profile-2' }, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) + + const result = await createCloudLinkedOrcaProfile(userDataPath, { name: 'Acme' }) + + expect(result.status).toBe('created') + expect(createOrcaCloudProfileMock).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + expect.objectContaining({ accessToken: 'rotated-access-token' }), + { name: 'Acme' } + ) + }) + + it('refreshes and retries capability refresh after an auth failure', async () => { + configureCloudEnv() + mockSuccessfulConnect() + mockSuccessfulSessionRefresh() + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudCapabilitiesMock + .mockRejectedValueOnce(new OrcaCloudRequestErrorMock(403)) + .mockResolvedValue({ + capabilities: { flags: { share: false }, refreshedAt: 26 } satisfies OrcaCloudCapabilities + }) + + const result = await refreshCurrentOrcaProfileAuth(userDataPath) + + expect(result.status).toBe('refreshed') + expect(refreshOrcaCloudCapabilitiesMock).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + expect.objectContaining({ accessToken: 'rotated-access-token' }) + ) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toEqual({ + flags: { share: false }, + refreshedAt: 26 + }) + }) + + it('requires reconnect when a retried capability refresh is still unauthorized', async () => { + configureCloudEnv() + mockSuccessfulConnect() + mockSuccessfulSessionRefresh() + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudCapabilitiesMock + .mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401)) + .mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401)) + + const result = await refreshCurrentOrcaProfileAuth(userDataPath) + + expect(result.status).toBe('reconnect-required') + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + state: 'reconnect-required', + persistence: 'none', + cloud: cloudSummary + }) + }) + + it('refreshes and retries organization selection after an auth failure', async () => { + configureCloudEnv() + mockSuccessfulConnect() + mockSuccessfulSessionRefresh() + await connectCurrentOrcaProfile(userDataPath) + selectOrcaCloudOrgMock + .mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401)) + .mockResolvedValue({ + cloud: { ...cloudSummary, activeOrgId: 'org-1', activeOrgName: 'Acme' }, + organizations, + capabilities + }) + + const result = await selectCurrentOrcaProfileOrg(userDataPath, 'org-1') + + expect(result.status).toBe('selected') + expect(selectOrcaCloudOrgMock).toHaveBeenNthCalledWith( + 2, + expect.any(Object), + expect.objectContaining({ accessToken: 'rotated-access-token' }), + 'org-1' + ) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-service-refresh.test.ts b/src/main/orca-profiles/profile-cloud-service-refresh.test.ts new file mode 100644 index 00000000000..e2e8fcbc1d4 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service-refresh.test.ts @@ -0,0 +1,256 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaProfileCloudSummary +} from '../../shared/orca-profiles' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +const { + beginOrcaCloudPkceFlowMock, + createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCodeMock, + refreshOrcaCloudCapabilitiesMock, + refreshOrcaCloudSessionMock, + OrcaCloudRequestErrorMock, + safeStorageMock +} = vi.hoisted(() => ({ + beginOrcaCloudPkceFlowMock: vi.fn(), + createOrcaCloudProfileMock: vi.fn(), + exchangeOrcaCloudAuthCodeMock: vi.fn(), + refreshOrcaCloudCapabilitiesMock: vi.fn(), + refreshOrcaCloudSessionMock: vi.fn(), + OrcaCloudRequestErrorMock: class OrcaCloudRequestError extends Error { + constructor(public readonly statusCode: number) { + super(`orca_cloud_request_failed_${statusCode}`) + this.name = 'OrcaCloudRequestError' + } + }, + safeStorageMock: { + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + isEncryptionAvailable: vi.fn(() => true) + } +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataPath + }, + safeStorage: safeStorageMock +})) + +vi.mock('./profile-cloud-pkce', () => ({ + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock +})) + +vi.mock('./profile-cloud-client', () => ({ + OrcaCloudRequestError: OrcaCloudRequestErrorMock, + createOrcaCloudProfile: createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock, + refreshOrcaCloudCapabilities: refreshOrcaCloudCapabilitiesMock, + refreshOrcaCloudSession: refreshOrcaCloudSessionMock, + revokeOrcaCloudSession: vi.fn(), + selectOrcaCloudOrg: vi.fn() +})) + +import { + connectCurrentOrcaProfile, + createCloudLinkedOrcaProfile, + getCurrentOrcaProfileAuthStatus, + refreshCurrentOrcaProfileAuth +} from './profile-cloud-service' + +const cloudSummary: OrcaProfileCloudSummary = { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + displayName: 'Nina', + linkedAt: 10 +} + +const capabilities: OrcaCloudCapabilities = { + flags: { share: true }, + refreshedAt: 11 +} + +const organizations: OrcaCloudOrgSummary[] = [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: 'org-2', name: 'Personal' } +] + +function futureExpiresAt(): number { + return Date.now() + 3_600_000 +} + +function configureCloudEnv(): void { + vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client') +} + +function mockSuccessfulConnect(expiresAt = futureExpiresAt()): void { + beginOrcaCloudPkceFlowMock.mockResolvedValue({ + code: 'auth-code', + codeVerifier: 'code-verifier', + nonce: 'nonce', + redirectUri: 'http://127.0.0.1:4100/auth/callback', + state: 'state' + }) + exchangeOrcaCloudAuthCodeMock.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt, + cloud: cloudSummary, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) +} + +describe('Orca cloud profile service session refresh', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-refresh-')) + beginOrcaCloudPkceFlowMock.mockReset() + createOrcaCloudProfileMock.mockReset() + exchangeOrcaCloudAuthCodeMock.mockReset() + refreshOrcaCloudCapabilitiesMock.mockReset() + refreshOrcaCloudSessionMock.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) + safeStorageMock.encryptString.mockImplementation((value: string) => + Buffer.from(value, 'utf-8') + ) + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + vi.unstubAllEnvs() + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('refreshes an expired access token before creating cloud profiles', async () => { + configureCloudEnv() + mockSuccessfulConnect(Date.now() - 1_000) + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudSessionMock.mockResolvedValue({ + accessToken: 'rotated-access-token', + refreshToken: 'rotated-refresh-token', + expiresAt: futureExpiresAt(), + cloud: cloudSummary, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) + createOrcaCloudProfileMock.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresAt: futureExpiresAt(), + cloud: { + ...cloudSummary, + cloudProfileId: 'cloud-profile-2', + activeOrgId: 'org-1', + activeOrgName: 'Acme' + }, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) + + const result = await createCloudLinkedOrcaProfile(userDataPath, { + orgId: 'org-1', + name: 'Acme' + }) + + expect(result.status).toBe('created') + expect(refreshOrcaCloudSessionMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ refreshToken: 'refresh-token' }) + ) + expect(createOrcaCloudProfileMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ accessToken: 'rotated-access-token' }), + { orgId: 'org-1', name: 'Acme' } + ) + }) + + it('refreshes capability flags for the connected profile', async () => { + configureCloudEnv() + mockSuccessfulConnect() + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudCapabilitiesMock.mockResolvedValue({ + capabilities: { + flags: { share: false, team: true }, + refreshedAt: 25 + } + }) + + const result = await refreshCurrentOrcaProfileAuth(userDataPath) + + expect(result.status).toBe('refreshed') + expect(refreshOrcaCloudCapabilitiesMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ accessToken: 'access-token' }) + ) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toEqual({ + flags: { share: false, team: true }, + refreshedAt: 25 + }) + }) + + it('clears stale active org metadata when capability refresh returns no active org', async () => { + configureCloudEnv() + mockSuccessfulConnect() + exchangeOrcaCloudAuthCodeMock.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: futureExpiresAt(), + cloud: { ...cloudSummary, activeOrgId: 'org-1', activeOrgName: 'Acme' }, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudCapabilitiesMock.mockResolvedValue({ + cloud: cloudSummary, + organizations: [], + capabilities: { + flags: { share: false }, + refreshedAt: 31 + } + }) + + const result = await refreshCurrentOrcaProfileAuth(userDataPath) + const status = getCurrentOrcaProfileAuthStatus(userDataPath) + + expect(result.status).toBe('refreshed') + expect(status.cloud?.activeOrgId).toBeUndefined() + expect(status.cloud?.activeOrgName).toBeUndefined() + expect(status.organizations).toEqual([]) + expect(status.capabilities).toEqual({ + flags: { share: false }, + refreshedAt: 31 + }) + }) + + it('requires reconnect when an expired refresh token is rejected', async () => { + configureCloudEnv() + mockSuccessfulConnect(Date.now() - 1_000) + await connectCurrentOrcaProfile(userDataPath) + refreshOrcaCloudSessionMock.mockRejectedValue(new OrcaCloudRequestErrorMock(401)) + + const result = await refreshCurrentOrcaProfileAuth(userDataPath) + + expect(result.status).toBe('reconnect-required') + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + state: 'reconnect-required', + persistence: 'none', + cloud: cloudSummary + }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-service.test.ts b/src/main/orca-profiles/profile-cloud-service.test.ts new file mode 100644 index 00000000000..92e0064c2f5 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service.test.ts @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaProfileCloudSummary +} from '../../shared/orca-profiles' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +const { + beginOrcaCloudPkceFlowMock, + createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCodeMock, + revokeOrcaCloudSessionMock, + selectOrcaCloudOrgMock, + safeStorageMock +} = vi.hoisted(() => ({ + beginOrcaCloudPkceFlowMock: vi.fn(), + createOrcaCloudProfileMock: vi.fn(), + exchangeOrcaCloudAuthCodeMock: vi.fn(), + revokeOrcaCloudSessionMock: vi.fn(), + selectOrcaCloudOrgMock: vi.fn(), + safeStorageMock: { + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + isEncryptionAvailable: vi.fn(() => true) + } +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataPath + }, + safeStorage: safeStorageMock +})) + +vi.mock('./profile-cloud-pkce', () => ({ + beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock +})) + +vi.mock('./profile-cloud-client', () => ({ + createOrcaCloudProfile: createOrcaCloudProfileMock, + exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock, + revokeOrcaCloudSession: revokeOrcaCloudSessionMock, + selectOrcaCloudOrg: selectOrcaCloudOrgMock +})) + +import { + connectCurrentOrcaProfile, + createCloudLinkedOrcaProfile, + getCurrentOrcaProfileAuthStatus, + selectCurrentOrcaProfileOrg, + signOutCurrentOrcaProfile +} from './profile-cloud-service' + +const cloudSummary: OrcaProfileCloudSummary = { + cloudProfileId: 'cloud-profile-1', + userId: 'user-1', + email: 'nina@example.com', + displayName: 'Nina', + linkedAt: 10 +} + +const capabilities: OrcaCloudCapabilities = { + flags: { share: true }, + refreshedAt: 11 +} + +const organizations: OrcaCloudOrgSummary[] = [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: 'org-2', name: 'Personal' } +] + +function configureCloudEnv(): void { + vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client') +} + +function futureExpiresAt(): number { + return Date.now() + 3_600_000 +} + +function mockSuccessfulConnect(expiresAt = futureExpiresAt()): void { + beginOrcaCloudPkceFlowMock.mockResolvedValue({ + code: 'auth-code', + codeVerifier: 'code-verifier', + nonce: 'nonce', + redirectUri: 'http://127.0.0.1:4100/auth/callback', + state: 'state' + }) + exchangeOrcaCloudAuthCodeMock.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt, + cloud: cloudSummary, + organizations, + capabilities + } satisfies OrcaCloudSessionExchangeResponse) +} + +describe('Orca cloud profile service', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-')) + beginOrcaCloudPkceFlowMock.mockReset() + createOrcaCloudProfileMock.mockReset() + exchangeOrcaCloudAuthCodeMock.mockReset() + revokeOrcaCloudSessionMock.mockReset() + selectOrcaCloudOrgMock.mockReset() + safeStorageMock.decryptString.mockReset() + safeStorageMock.encryptString.mockReset() + safeStorageMock.isEncryptionAvailable.mockReset() + safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8')) + safeStorageMock.encryptString.mockImplementation((value: string) => + Buffer.from(value, 'utf-8') + ) + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + revokeOrcaCloudSessionMock.mockResolvedValue(undefined) + vi.unstubAllEnvs() + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('reports local unconfigured auth without cloud setup', () => { + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + activeProfileId: 'local-default', + configured: false, + state: 'unconfigured', + persistence: 'none' + }) + }) + + it('connects the active local profile without replacing its local profile ID', async () => { + configureCloudEnv() + mockSuccessfulConnect() + + const result = await connectCurrentOrcaProfile(userDataPath) + + if (result.status !== 'connected') { + throw new Error(`Expected connected result, got ${result.status}`) + } + expect(result.activeProfileId).toBe('local-default') + expect(result.profiles[0]).toMatchObject({ + id: 'local-default', + kind: 'cloud-linked', + cloud: cloudSummary + }) + expect(exchangeOrcaCloudAuthCodeMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ localProfileId: 'local-default', nonce: 'nonce' }) + ) + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + state: 'connected', + persistence: 'encrypted', + cloud: cloudSummary, + organizations, + capabilities + }) + }) + + it('treats provider-denied sign-in as a cancelled connect attempt', async () => { + configureCloudEnv() + beginOrcaCloudPkceFlowMock.mockRejectedValue(new Error('orca_cloud_auth_denied')) + + const result = await connectCurrentOrcaProfile(userDataPath) + + expect(result.status).toBe('cancelled') + expect(exchangeOrcaCloudAuthCodeMock).not.toHaveBeenCalled() + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + state: 'local', + persistence: 'none' + }) + }) + + it('does not report a saved cloud session as connected when cloud config is unavailable', async () => { + configureCloudEnv() + mockSuccessfulConnect() + await connectCurrentOrcaProfile(userDataPath) + vi.stubEnv('ORCA_CLOUD_API_URL', '') + vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '') + + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + configured: false, + state: 'unconfigured', + persistence: 'encrypted', + cloud: cloudSummary, + setupMessage: 'Orca Cloud sign-in is not configured for this build.' + }) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toBeUndefined() + expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toBeUndefined() + }) + + it('signs out by removing cloud metadata while keeping the local profile', async () => { + configureCloudEnv() + mockSuccessfulConnect() + await connectCurrentOrcaProfile(userDataPath) + + const result = await signOutCurrentOrcaProfile(userDataPath) + + expect(result.status).toBe('signed-out') + expect(result.activeProfileId).toBe('local-default') + expect(result.profiles[0]).toMatchObject({ id: 'local-default', kind: 'local' }) + expect(result.profiles[0]?.cloud).toBeUndefined() + expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({ + state: 'local', + persistence: 'none' + }) + expect(revokeOrcaCloudSessionMock).toHaveBeenCalledOnce() + }) + + it('creates a new empty cloud-linked profile with its own cloud session', async () => { + configureCloudEnv() + mockSuccessfulConnect() + await connectCurrentOrcaProfile(userDataPath) + createOrcaCloudProfileMock.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expiresAt: 1000, + cloud: { + ...cloudSummary, + cloudProfileId: 'cloud-profile-2', + activeOrgId: 'org-1', + activeOrgName: 'Acme' + }, + organizations, + capabilities: { flags: { share: true, team: true }, refreshedAt: 13 } + } satisfies OrcaCloudSessionExchangeResponse) + + const result = await createCloudLinkedOrcaProfile(userDataPath, { + orgId: 'org-1', + name: 'Acme' + }) + + if (result.status !== 'created') { + throw new Error(`Expected created result, got ${result.status}`) + } + expect(result.profile).toMatchObject({ + id: expect.stringMatching(/^cloud-/), + name: 'Acme', + kind: 'cloud-linked', + cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-2' }) + }) + expect(createOrcaCloudProfileMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ accessToken: 'access-token' }), + { orgId: 'org-1', name: 'Acme' } + ) + }) + + it('selects an organization for a connected profile', async () => { + configureCloudEnv() + mockSuccessfulConnect() + await connectCurrentOrcaProfile(userDataPath) + const orgCloudSummary = { + ...cloudSummary, + activeOrgId: 'org-1', + activeOrgName: 'Acme' + } + selectOrcaCloudOrgMock.mockResolvedValue({ + cloud: orgCloudSummary, + organizations, + capabilities: { flags: { share: true, sso: true }, refreshedAt: 12 } + }) + + const result = await selectCurrentOrcaProfileOrg(userDataPath, 'org-1') + + expect(result.status).toBe('selected') + expect(selectOrcaCloudOrgMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ accessToken: 'access-token' }), + 'org-1' + ) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).cloud).toMatchObject({ + activeOrgId: 'org-1', + activeOrgName: 'Acme' + }) + expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toEqual(organizations) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-service.ts b/src/main/orca-profiles/profile-cloud-service.ts new file mode 100644 index 00000000000..df735a81220 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service.ts @@ -0,0 +1,300 @@ +import type { + ConnectCurrentOrcaProfileResult, + CreateCloudLinkedOrcaProfileArgs, + CreateCloudLinkedOrcaProfileResult, + OrcaProfileAuthStatus, + RefreshCurrentOrcaProfileAuthResult, + SelectOrcaProfileOrgResult, + SignOutCurrentOrcaProfileResult +} from '../../shared/orca-profiles' +import { ensureActiveOrcaProfile, getOrcaProfileListState } from './profile-index-store' +import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config' +import { + clearOrcaCloudSession, + readOrcaCloudSession, + saveOrcaCloudSession, + saveOrcaCloudSessionExchange +} from './profile-cloud-session-store' +import { + createOrcaCloudProfile, + exchangeOrcaCloudAuthCode, + refreshOrcaCloudCapabilities, + revokeOrcaCloudSession, + selectOrcaCloudOrg +} from './profile-cloud-client' +import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce' +import { + createCloudLinkedOrcaProfileRecord, + linkOrcaProfileToCloud, + unlinkOrcaProfileFromCloud +} from './profile-cloud-index' +import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh' +import { + connectDevOrcaCloudProfile, + createDevCloudLinkedOrcaProfile, + refreshDevOrcaCloudProfile, + selectDevOrcaCloudOrg +} from './profile-cloud-dev-service' +import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status' + +function isUserCancelledAuthError(message: string): boolean { + return message === 'orca_cloud_auth_timeout' || message === 'orca_cloud_auth_denied' +} + +function activeAuth( + active: ReturnType, + userDataPath: string +): OrcaProfileAuthStatus { + return getOrcaProfileAuthStatusFromProfile(active, userDataPath) +} + +export function getCurrentOrcaProfileAuthStatus(userDataPath: string): OrcaProfileAuthStatus { + return getOrcaProfileAuthStatusFromProfile(ensureActiveOrcaProfile(userDataPath), userDataPath) +} + +export async function connectCurrentOrcaProfile( + userDataPath: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + const list = connectDevOrcaCloudProfile(active, userDataPath) + return { + status: 'connected', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles + } + } + + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { + status: 'unconfigured', + auth: activeAuth(active, userDataPath) + } + } + + try { + const code = await beginOrcaCloudPkceFlow(configState.config, active.profile.id) + const exchange = await exchangeOrcaCloudAuthCode(configState.config, { + ...code, + localProfileId: active.profile.id + }) + saveOrcaCloudSessionExchange(active.profile.id, userDataPath, exchange) + const list = linkOrcaProfileToCloud(active.profile.id, exchange.cloud, userDataPath) + return { + status: 'connected', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (isUserCancelledAuthError(message)) { + return { + status: 'cancelled', + auth: getCurrentOrcaProfileAuthStatus(userDataPath) + } + } + return { + status: 'failed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + error: message + } + } +} + +export async function signOutCurrentOrcaProfile( + userDataPath: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + const configState = getOrcaCloudAuthConfig() + const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (!isOrcaCloudDevAuthEnabled() && configState.configured && session.status === 'found') { + await revokeOrcaCloudSession(configState.config, session.session).catch(() => undefined) + } + clearOrcaCloudSession(active.profile.id, userDataPath) + const list = unlinkOrcaProfileFromCloud(active.profile.id, userDataPath) + return { + status: 'signed-out', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles + } +} + +export async function createCloudLinkedOrcaProfile( + userDataPath: string, + args: CreateCloudLinkedOrcaProfileArgs +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + const result = createDevCloudLinkedOrcaProfile(active, userDataPath, args) + if (result.status !== 'created') { + return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } + } + return { + status: 'created', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: result.list.activeProfileId, + profiles: result.list.profiles, + profile: result.list.profile + } + } + + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured', auth: activeAuth(active, userDataPath) } + } + try { + const operation = await runWithFreshOrcaCloudSession( + configState.config, + active, + userDataPath, + (session) => createOrcaCloudProfile(configState.config, session, args) + ) + if (operation.status !== 'ok') { + return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } + } + const created = operation.value + const list = createCloudLinkedOrcaProfileRecord( + created.cloud, + { name: args.name }, + userDataPath + ) + saveOrcaCloudSessionExchange(list.profile.id, userDataPath, created) + return { + status: 'created', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles, + profile: list.profile + } + } catch (error) { + return { + status: 'failed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + error: error instanceof Error ? error.message : String(error) + } + } +} + +export async function refreshCurrentOrcaProfileAuth( + userDataPath: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (!active.profile.cloud) { + return { status: 'local', auth: activeAuth(active, userDataPath) } + } + if (isOrcaCloudDevAuthEnabled()) { + const result = refreshDevOrcaCloudProfile(active, userDataPath) + if (result.status !== 'updated') { + return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) } + } + return { + status: 'refreshed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: result.list.activeProfileId, + profiles: result.list.profiles + } + } + + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured', auth: activeAuth(active, userDataPath) } + } + try { + const operation = await runWithFreshOrcaCloudSession( + configState.config, + active, + userDataPath, + (session) => refreshOrcaCloudCapabilities(configState.config, session) + ) + if (operation.status !== 'ok') { + return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) } + } + const refresh = operation.value + const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (session.status !== 'found') { + return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) } + } + saveOrcaCloudSession(active.profile.id, userDataPath, { + ...session.session, + organizations: refresh.organizations ?? session.session.organizations, + capabilities: refresh.capabilities + }) + const list = refresh.cloud + ? linkOrcaProfileToCloud(active.profile.id, refresh.cloud, userDataPath) + : getOrcaProfileListState(userDataPath) + return { + status: 'refreshed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles + } + } catch (error) { + return { + status: 'failed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + error: error instanceof Error ? error.message : String(error) + } + } +} + +export async function selectCurrentOrcaProfileOrg( + userDataPath: string, + orgId: string +): Promise { + const active = ensureActiveOrcaProfile(userDataPath) + if (isOrcaCloudDevAuthEnabled()) { + const result = selectDevOrcaCloudOrg(active, userDataPath, orgId) + if (result.status !== 'updated') { + return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } + } + return { + status: 'selected', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: result.list.activeProfileId, + profiles: result.list.profiles + } + } + + const configState = getOrcaCloudAuthConfig() + if (!configState.configured) { + return { status: 'unconfigured', auth: activeAuth(active, userDataPath) } + } + try { + const operation = await runWithFreshOrcaCloudSession( + configState.config, + active, + userDataPath, + (session) => selectOrcaCloudOrg(configState.config, session, orgId) + ) + if (operation.status !== 'ok') { + return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } + } + const selected = operation.value + const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (session.status !== 'found') { + return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) } + } + saveOrcaCloudSession(active.profile.id, userDataPath, { + ...session.session, + organizations: selected.organizations ?? session.session.organizations, + capabilities: selected.capabilities + }) + const list = linkOrcaProfileToCloud(active.profile.id, selected.cloud, userDataPath) + return { + status: 'selected', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + activeProfileId: list.activeProfileId, + profiles: list.profiles + } + } catch (error) { + return { + status: 'failed', + auth: getCurrentOrcaProfileAuthStatus(userDataPath), + error: error instanceof Error ? error.message : String(error) + } + } +} diff --git a/src/main/orca-profiles/profile-cloud-session-exchange.ts b/src/main/orca-profiles/profile-cloud-session-exchange.ts new file mode 100644 index 00000000000..d3dff43816f --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-exchange.ts @@ -0,0 +1,14 @@ +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaProfileCloudSummary +} from '../../shared/orca-profiles' + +export type OrcaCloudSessionExchangeResponse = { + accessToken: string + refreshToken: string + expiresAt: number + cloud: OrcaProfileCloudSummary + organizations?: OrcaCloudOrgSummary[] + capabilities: OrcaCloudCapabilities +} diff --git a/src/main/orca-profiles/profile-cloud-session-refresh.ts b/src/main/orca-profiles/profile-cloud-session-refresh.ts new file mode 100644 index 00000000000..404cba47bd4 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-refresh.ts @@ -0,0 +1,178 @@ +import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config' +import type { ActiveOrcaProfileState } from './profile-index-store' +import { + clearOrcaCloudSession, + type OrcaCloudSession, + readOrcaCloudSession, + saveOrcaCloudSession +} from './profile-cloud-session-store' +import { OrcaCloudRequestError, refreshOrcaCloudSession } from './profile-cloud-client' +import { linkOrcaProfileToCloud } from './profile-cloud-index' + +const CLOUD_SESSION_REFRESH_SKEW_MS = 60_000 + +export type FreshCloudSessionResult = + | { status: 'found'; session: OrcaCloudSession } + | { status: 'reconnect-required' } + +export type CloudSessionOperationResult = + | { status: 'ok'; value: T } + | { status: 'reconnect-required' } + +function shouldRefreshCloudSession(session: OrcaCloudSession, now = Date.now()): boolean { + return session.expiresAt <= now + CLOUD_SESSION_REFRESH_SKEW_MS +} + +export function isOrcaCloudAuthFailure(error: unknown): boolean { + return ( + error instanceof OrcaCloudRequestError && (error.statusCode === 401 || error.statusCode === 403) + ) +} + +const inflightCloudSessionRefreshes = new Map>() + +function cloudSessionRefreshKey(profileId: string, userDataPath: string): string { + return `${userDataPath}\0${profileId}` +} + +// Why: with refresh-token rotation, only the session that actually failed may +// clear the store; otherwise a loser of a concurrent refresh race would wipe +// the winner's freshly rotated session. +function clearCloudSessionIfUnchanged( + profileId: string, + userDataPath: string, + failed: OrcaCloudSession +): void { + const current = readOrcaCloudSession(profileId, userDataPath) + if (current.status === 'found' && current.session.refreshToken !== failed.refreshToken) { + return + } + clearOrcaCloudSession(profileId, userDataPath) +} + +async function refreshStoredCloudSession( + config: OrcaCloudAuthConfig, + active: ActiveOrcaProfileState, + userDataPath: string, + session: OrcaCloudSession +): Promise { + // Why: refresh tokens rotate, so concurrent refreshes must single-flight; + // a second POST with the same refresh token can trip server reuse detection + // and revoke the whole token family. + const key = cloudSessionRefreshKey(active.profile.id, userDataPath) + const inflight = inflightCloudSessionRefreshes.get(key) + if (inflight) { + return inflight + } + const task = (async () => { + const current = readOrcaCloudSession(active.profile.id, userDataPath) + if (current.status === 'found' && current.session.refreshToken !== session.refreshToken) { + // Another caller already rotated this session; reuse its result. + return current.session + } + const refreshed = await refreshOrcaCloudSession(config, session) + const nextSession = { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + expiresAt: refreshed.expiresAt, + organizations: refreshed.organizations, + capabilities: refreshed.capabilities + } + saveOrcaCloudSession(active.profile.id, userDataPath, nextSession) + linkOrcaProfileToCloud(active.profile.id, refreshed.cloud, userDataPath) + return nextSession + })() + inflightCloudSessionRefreshes.set(key, task) + try { + return await task + } finally { + inflightCloudSessionRefreshes.delete(key) + } +} + +export async function readFreshOrcaCloudSession( + config: OrcaCloudAuthConfig, + active: ActiveOrcaProfileState, + userDataPath: string +): Promise { + const session = readOrcaCloudSession(active.profile.id, userDataPath) + if (session.status !== 'found') { + return { status: 'reconnect-required' } + } + if (!shouldRefreshCloudSession(session.session)) { + return { status: 'found', session: session.session } + } + try { + return { + status: 'found', + session: await refreshStoredCloudSession(config, active, userDataPath, session.session) + } + } catch (error) { + if (isOrcaCloudAuthFailure(error)) { + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session.session) + return { status: 'reconnect-required' } + } + throw error + } +} + +export async function forceRefreshOrcaCloudSession( + config: OrcaCloudAuthConfig, + active: ActiveOrcaProfileState, + userDataPath: string, + session: OrcaCloudSession +): Promise { + try { + return { + status: 'found', + session: await refreshStoredCloudSession(config, active, userDataPath, session) + } + } catch (error) { + if (isOrcaCloudAuthFailure(error)) { + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session) + return { status: 'reconnect-required' } + } + throw error + } +} + +export async function runWithFreshOrcaCloudSession( + config: OrcaCloudAuthConfig, + active: ActiveOrcaProfileState, + userDataPath: string, + operation: (session: OrcaCloudSession) => Promise +): Promise> { + const session = await readFreshOrcaCloudSession(config, active, userDataPath) + if (session.status !== 'found') { + return { status: 'reconnect-required' } + } + try { + return { status: 'ok', value: await operation(session.session) } + } catch (error) { + if (!isOrcaCloudAuthFailure(error)) { + throw error + } + const refreshed = await forceRefreshOrcaCloudSession( + config, + active, + userDataPath, + session.session + ) + if (refreshed.status !== 'found') { + return { status: 'reconnect-required' } + } + try { + return { status: 'ok', value: await operation(refreshed.session) } + } catch (retryError) { + // Why: a 401 after a successful refresh means the session itself is + // rejected. A 403 is an authorization (permission) failure — signing + // the user out for it would destroy a valid session, so let it surface + // as a failed operation instead. + if (retryError instanceof OrcaCloudRequestError && retryError.statusCode === 401) { + clearCloudSessionIfUnchanged(active.profile.id, userDataPath, refreshed.session) + return { status: 'reconnect-required' } + } + throw retryError + } + } +} diff --git a/src/main/orca-profiles/profile-cloud-session-store.test.ts b/src/main/orca-profiles/profile-cloud-session-store.test.ts new file mode 100644 index 00000000000..ca32f0529f1 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-store.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { OrcaCloudSession } from './profile-cloud-session-store' + +const safeStorageMock = vi.hoisted(() => ({ + decryptString: vi.fn((value: Buffer) => value.toString('utf-8')), + encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')), + isEncryptionAvailable: vi.fn(() => true) +})) + +let userDataPath = '' + +vi.mock('electron', () => ({ + app: { + getPath: () => userDataPath + }, + safeStorage: safeStorageMock +})) + +async function loadSessionStore() { + vi.resetModules() + return import('./profile-cloud-session-store') +} + +function makeSession(): OrcaCloudSession { + return { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: 9_999, + organizations: [ + { orgId: 'org-1', name: 'Acme', role: 'Admin' }, + { orgId: 'org-2', name: 'Personal' } + ], + capabilities: { + flags: { share: true }, + refreshedAt: 123 + } + } +} + +function writePlaintextSessionFile(profileId: string, session: OrcaCloudSession): void { + const profileDirectory = join(userDataPath, 'profiles', profileId) + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync( + join(profileDirectory, 'account-session.json.enc'), + JSON.stringify( + { + version: 1, + format: 'dev-plaintext-v1', + savedAt: 1, + session + }, + null, + 2 + ), + 'utf-8' + ) +} + +describe('Orca cloud session store', () => { + beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-session-')) + vi.unstubAllEnvs() + safeStorageMock.decryptString.mockClear() + safeStorageMock.encryptString.mockClear() + safeStorageMock.isEncryptionAvailable.mockClear() + safeStorageMock.isEncryptionAvailable.mockReturnValue(true) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('persists encrypted sessions and reports encrypted persistence from memory and disk', async () => { + const store = await loadSessionStore() + const session = makeSession() + + expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('encrypted') + expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'found', + session, + persistence: 'encrypted' + }) + + const reloaded = await loadSessionStore() + expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'found', + session, + persistence: 'encrypted' + }) + }) + + it('falls back to memory-only when encryption is unavailable and plaintext is not allowed', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + const store = await loadSessionStore() + const session = makeSession() + + expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('memory-only') + expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'found', + session, + persistence: 'memory-only' + }) + + const reloaded = await loadSessionStore() + expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'missing', + persistence: 'none' + }) + }) + + it('scopes memory-only sessions by user-data path and profile ID', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + const otherUserDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-session-other-')) + const store = await loadSessionStore() + const session = makeSession() + const otherSession = { ...session, accessToken: 'other-access-token' } + + try { + store.saveOrcaCloudSession('local-default', userDataPath, session) + store.saveOrcaCloudSession('local-default', otherUserDataPath, otherSession) + + expect(store.readOrcaCloudSession('local-default', userDataPath)).toMatchObject({ + status: 'found', + session + }) + expect(store.readOrcaCloudSession('local-default', otherUserDataPath)).toMatchObject({ + status: 'found', + session: otherSession + }) + } finally { + rmSync(otherUserDataPath, { recursive: true, force: true }) + } + }) + + it('writes explicit dev plaintext only when the dev escape hatch is enabled', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + vi.stubEnv('ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION', '1') + vi.stubEnv('NODE_ENV', 'development') + const store = await loadSessionStore() + const session = makeSession() + + expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('dev-plaintext') + + const saved = JSON.parse( + readFileSync(store.getOrcaCloudSessionPath('profile-1', userDataPath), 'utf-8') + ) as { format: string } + expect(saved.format).toBe('dev-plaintext-v1') + + const reloaded = await loadSessionStore() + expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'found', + session, + persistence: 'dev-plaintext' + }) + }) + + it('rejects dev plaintext files when the escape hatch is disabled', async () => { + safeStorageMock.isEncryptionAvailable.mockReturnValue(false) + writePlaintextSessionFile('profile-1', makeSession()) + const store = await loadSessionStore() + + expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({ + status: 'decrypt-failed', + persistence: 'none', + error: 'Unsafe session format.' + }) + }) +}) diff --git a/src/main/orca-profiles/profile-cloud-session-store.ts b/src/main/orca-profiles/profile-cloud-session-store.ts new file mode 100644 index 00000000000..a8914b8f7a3 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-session-store.ts @@ -0,0 +1,209 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { safeStorage } from 'electron' +import { writeSecureJsonFile } from '../../shared/secure-file' +import type { + OrcaCloudCapabilities, + OrcaCloudOrgSummary, + OrcaCloudSessionPersistence +} from '../../shared/orca-profiles' +import { getOrcaProfileDirectory } from './profile-storage-paths' +import { allowsPlaintextOrcaCloudSession } from './profile-cloud-auth-config' +import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange' + +export type OrcaCloudSession = { + accessToken: string + refreshToken: string + expiresAt: number + capabilities: OrcaCloudCapabilities + organizations?: OrcaCloudOrgSummary[] +} + +export type OrcaCloudSessionReadResult = + | { status: 'found'; session: OrcaCloudSession; persistence: OrcaCloudSessionPersistence } + | { status: 'missing'; persistence: 'none' } + | { status: 'decrypt-failed'; persistence: 'none'; error: string } + +type PersistedEncryptedSession = { + version: 1 + format: 'electron-safe-storage-v1' + savedAt: number + ciphertext: string +} + +type PersistedPlaintextSession = { + version: 1 + format: 'dev-plaintext-v1' + savedAt: number + session: OrcaCloudSession +} + +type CachedOrcaCloudSession = { + session: OrcaCloudSession + persistence: Exclude +} + +const memorySessions = new Map() + +function sessionCacheKey(profileId: string, userDataPath: string): string { + return `${userDataPath}\0${profileId}` +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isOrcaCloudSession(value: unknown): value is OrcaCloudSession { + if (!isObject(value) || !isObject(value.capabilities) || !isObject(value.capabilities.flags)) { + return false + } + if (value.organizations !== undefined && !isOrcaCloudOrganizations(value.organizations)) { + return false + } + return ( + typeof value.accessToken === 'string' && + value.accessToken.length > 0 && + typeof value.refreshToken === 'string' && + value.refreshToken.length > 0 && + typeof value.expiresAt === 'number' && + Number.isFinite(value.expiresAt) && + typeof value.capabilities.refreshedAt === 'number' && + Number.isFinite(value.capabilities.refreshedAt) + ) +} + +function isOrcaCloudOrganizations(value: unknown): value is OrcaCloudOrgSummary[] { + if (!Array.isArray(value)) { + return false + } + return value.every((organization) => { + if (!isObject(organization)) { + return false + } + return ( + typeof organization.orgId === 'string' && + organization.orgId.length > 0 && + typeof organization.name === 'string' && + organization.name.length > 0 && + (organization.role === undefined || typeof organization.role === 'string') + ) + }) +} + +export function getOrcaCloudSessionPath(profileId: string, userDataPath: string): string { + return join(getOrcaProfileDirectory(profileId, userDataPath), 'account-session.json.enc') +} + +export function saveOrcaCloudSession( + profileId: string, + userDataPath: string, + session: OrcaCloudSession +): OrcaCloudSessionPersistence { + const cacheKey = sessionCacheKey(profileId, userDataPath) + if (safeStorage.isEncryptionAvailable()) { + const encrypted: PersistedEncryptedSession = { + version: 1, + format: 'electron-safe-storage-v1', + savedAt: Date.now(), + ciphertext: safeStorage.encryptString(JSON.stringify(session)).toString('base64') + } + writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), encrypted) + memorySessions.set(cacheKey, { session, persistence: 'encrypted' }) + return 'encrypted' + } + + if (allowsPlaintextOrcaCloudSession()) { + const plaintext: PersistedPlaintextSession = { + version: 1, + format: 'dev-plaintext-v1', + savedAt: Date.now(), + session + } + writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), plaintext) + memorySessions.set(cacheKey, { session, persistence: 'dev-plaintext' }) + return 'dev-plaintext' + } + + // Why: Orca account refresh tokens must not silently fall back to plaintext + // in production. Memory-only keeps cloud features usable until restart. + memorySessions.set(cacheKey, { session, persistence: 'memory-only' }) + return 'memory-only' +} + +export function saveOrcaCloudSessionExchange( + profileId: string, + userDataPath: string, + exchange: OrcaCloudSessionExchangeResponse +): OrcaCloudSessionPersistence { + return saveOrcaCloudSession(profileId, userDataPath, { + accessToken: exchange.accessToken, + refreshToken: exchange.refreshToken, + expiresAt: exchange.expiresAt, + organizations: exchange.organizations, + capabilities: exchange.capabilities + }) +} + +export function readOrcaCloudSession( + profileId: string, + userDataPath: string +): OrcaCloudSessionReadResult { + const cacheKey = sessionCacheKey(profileId, userDataPath) + const memorySession = memorySessions.get(cacheKey) + if (memorySession) { + return { + status: 'found', + session: memorySession.session, + persistence: memorySession.persistence + } + } + + const path = getOrcaCloudSessionPath(profileId, userDataPath) + if (!existsSync(path)) { + return { status: 'missing', persistence: 'none' } + } + + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as + | PersistedEncryptedSession + | PersistedPlaintextSession + if (parsed.version !== 1) { + return { status: 'decrypt-failed', persistence: 'none', error: 'Unsupported session format.' } + } + if (parsed.format === 'electron-safe-storage-v1') { + if (!safeStorage.isEncryptionAvailable()) { + return { + status: 'decrypt-failed', + persistence: 'none', + error: 'OS-backed encryption is unavailable.' + } + } + const decrypted = safeStorage.decryptString(Buffer.from(parsed.ciphertext, 'base64')) + const session = JSON.parse(decrypted) as OrcaCloudSession + if (!isOrcaCloudSession(session)) { + return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' } + } + memorySessions.set(cacheKey, { session, persistence: 'encrypted' }) + return { status: 'found', session, persistence: 'encrypted' } + } + if (parsed.format === 'dev-plaintext-v1' && allowsPlaintextOrcaCloudSession()) { + if (!isOrcaCloudSession(parsed.session)) { + return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' } + } + memorySessions.set(cacheKey, { session: parsed.session, persistence: 'dev-plaintext' }) + return { status: 'found', session: parsed.session, persistence: 'dev-plaintext' } + } + return { status: 'decrypt-failed', persistence: 'none', error: 'Unsafe session format.' } + } catch { + return { + status: 'decrypt-failed', + persistence: 'none', + error: 'Could not decrypt saved Orca account session.' + } + } +} + +export function clearOrcaCloudSession(profileId: string, userDataPath: string): void { + memorySessions.delete(sessionCacheKey(profileId, userDataPath)) + rmSync(getOrcaCloudSessionPath(profileId, userDataPath), { force: true }) +} diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts new file mode 100644 index 00000000000..a2d3446a99c --- /dev/null +++ b/src/main/orca-profiles/profile-index-store.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + DEFAULT_LOCAL_ORCA_PROFILE_ID, + DEFAULT_LOCAL_ORCA_PROFILE_NAME, + ORCA_PROFILE_INDEX_SCHEMA_VERSION, + type OrcaProfileIndex +} from '../../shared/orca-profiles' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + } +})) + +async function loadProfileIndexStore() { + vi.resetModules() + return import('./profile-index-store') +} + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, 'utf-8')) +} + +describe('profile index store', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-test-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('creates the default local profile and copies legacy state without deleting it', async () => { + const legacyState = { schemaVersion: 1, repos: [{ id: 'repo-1' }] } + const legacyBackup = { schemaVersion: 1, repos: [{ id: 'backup-repo' }] } + const legacyBrowserSessionMeta = { + defaultSource: { browserFamily: 'chrome', importedAt: 1 }, + profiles: [] + } + writeFileSync(join(testState.dir, 'orca-data.json'), JSON.stringify(legacyState), 'utf-8') + writeFileSync( + join(testState.dir, 'orca-data.json.bak.0'), + JSON.stringify(legacyBackup), + 'utf-8' + ) + writeFileSync( + join(testState.dir, 'browser-session-meta.json'), + JSON.stringify(legacyBrowserSessionMeta), + 'utf-8' + ) + + const { ensureActiveOrcaProfile, getOrcaProfileIndexPath } = await loadProfileIndexStore() + const activeProfile = ensureActiveOrcaProfile() + + expect(activeProfile.profile.id).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID) + expect(activeProfile.profile.name).toBe(DEFAULT_LOCAL_ORCA_PROFILE_NAME) + expect(activeProfile.dataFile).toBe( + join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json') + ) + expect(readJson(activeProfile.dataFile)).toEqual(legacyState) + expect(readJson(`${activeProfile.dataFile}.bak.0`)).toEqual(legacyBackup) + expect( + readJson( + join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'browser-session-meta.json') + ) + ).toEqual(legacyBrowserSessionMeta) + expect(existsSync(join(testState.dir, 'orca-data.json'))).toBe(true) + + expect(readJson(getOrcaProfileIndexPath())).toMatchObject({ + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID, + profiles: [expect.objectContaining({ id: DEFAULT_LOCAL_ORCA_PROFILE_ID, kind: 'local' })] + }) + }) + + it('uses an existing active profile data file without overwriting it from legacy state', async () => { + const profileId = 'work-profile' + const profileDirectory = join(testState.dir, 'profiles', profileId) + const profileData = { schemaVersion: 1, repos: [{ id: 'profile-repo' }] } + mkdirSync(profileDirectory, { recursive: true }) + writeFileSync(join(profileDirectory, 'orca-data.json'), JSON.stringify(profileData), 'utf-8') + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ schemaVersion: 1, repos: [{ id: 'legacy-repo' }] }), + 'utf-8' + ) + const index: OrcaProfileIndex = { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: profileId, + profiles: [ + { + id: profileId, + name: 'Work', + avatar: { kind: 'initials', initials: 'W', color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } + ] + } + writeFileSync(join(testState.dir, 'orca-profile-index.json'), JSON.stringify(index), 'utf-8') + + const { ensureActiveOrcaProfile } = await loadProfileIndexStore() + const activeProfile = ensureActiveOrcaProfile() + + expect(activeProfile.profile.id).toBe(profileId) + expect(activeProfile.dataFile).toBe(join(profileDirectory, 'orca-data.json')) + expect(readJson(activeProfile.dataFile)).toEqual(profileData) + }) + + it('creates an empty local profile without copying legacy state into it', async () => { + writeFileSync( + join(testState.dir, 'orca-data.json'), + JSON.stringify({ schemaVersion: 1, repos: [{ id: 'legacy-repo' }] }), + 'utf-8' + ) + + const { createLocalOrcaProfile, getOrcaProfileDataFile, getOrcaProfileListState } = + await loadProfileIndexStore() + const created = createLocalOrcaProfile({ name: ' Work ' }) + + expect(created.profile.name).toBe('Work') + expect(created.profile.id).toMatch(/^local-/) + expect(created.activeProfileId).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID) + expect(created.profiles.map((profile) => profile.id)).toContain(created.profile.id) + expect(existsSync(getOrcaProfileDataFile(created.profile.id))).toBe(false) + expect(getOrcaProfileListState().profiles.map((profile) => profile.id)).toContain( + created.profile.id + ) + }) + + it('switches the active profile and updates last-opened metadata', async () => { + const { createLocalOrcaProfile, setActiveOrcaProfile } = await loadProfileIndexStore() + const created = createLocalOrcaProfile({ name: 'Work' }) + + const switched = setActiveOrcaProfile(created.profile.id) + + expect(switched.activeProfileId).toBe(created.profile.id) + expect(switched.profiles.find((profile) => profile.id === created.profile.id)).toMatchObject({ + id: created.profile.id, + lastOpenedAt: expect.any(Number) + }) + }) + + it('rejects switching to an unknown profile', async () => { + const { setActiveOrcaProfile } = await loadProfileIndexStore() + + expect(() => setActiveOrcaProfile('missing-profile')).toThrow('unknown_orca_profile') + }) + + it('recovers a corrupted profile index from the backup copy', async () => { + const store = await loadProfileIndexStore() + store.ensureActiveOrcaProfile() + const created = store.createLocalOrcaProfile({ name: 'Work' }) + // Trigger one more write so the backup captures the two-profile index. + store.setActiveOrcaProfile(created.profile.id) + + const indexPath = store.getOrcaProfileIndexPath() + expect(existsSync(`${indexPath}.bak`)).toBe(true) + writeFileSync(indexPath, '{ not json', 'utf-8') + + const recovered = store.getOrcaProfileListState() + expect(recovered.profiles.map((profile) => profile.id)).toContain(created.profile.id) + expect(recovered.profiles.length).toBeGreaterThanOrEqual(2) + }) + + it('rejects profile ids that are not safe path segments', async () => { + const store = await loadProfileIndexStore() + const indexPath = store.getOrcaProfileIndexPath() + const index: OrcaProfileIndex = { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: '../../escape', + profiles: [ + { + id: '../../escape', + name: 'Evil', + avatar: { kind: 'initials', initials: 'E', color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } + ] + } + mkdirSync(testState.dir, { recursive: true }) + writeFileSync(indexPath, JSON.stringify(index), 'utf-8') + + // The tampered entry is filtered; startup falls back to a fresh default. + const state = store.ensureActiveOrcaProfile() + expect(state.profile.id).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID) + }) +}) diff --git a/src/main/orca-profiles/profile-index-store.ts b/src/main/orca-profiles/profile-index-store.ts new file mode 100644 index 00000000000..903e7a85ff8 --- /dev/null +++ b/src/main/orca-profiles/profile-index-store.ts @@ -0,0 +1,327 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync +} from 'node:fs' +import { randomUUID } from 'node:crypto' +import { dirname } from 'node:path' +import type { GlobalSettings } from '../../shared/types' +import { + createDefaultLocalOrcaProfile, + DEFAULT_LOCAL_ORCA_PROFILE_ID, + DEFAULT_LOCAL_ORCA_PROFILE_NAME, + ORCA_PROFILE_INDEX_SCHEMA_VERSION, + type CreateLocalOrcaProfileArgs, + type CreateLocalOrcaProfileResult, + type OrcaProfileIndex, + type OrcaProfileListState, + type OrcaProfileSummary +} from '../../shared/orca-profiles' +import { + getOrcaProfileBrowserSessionMetaFile, + getOrcaProfileDataFile, + getOrcaProfileDirectory, + getOrcaProfileIndexPath, + getProfileUserDataPath, + LEGACY_BACKUP_COUNT, + legacyBackupPath, + legacyBrowserSessionMetaPath, + legacyDataFilePath, + profileBackupPath +} from './profile-storage-paths' + +export { + getOrcaProfileBrowserSessionMetaFile, + getOrcaProfileDataFile, + getOrcaProfileDirectory, + getOrcaProfileIndexPath, + getOrcaProfilesDirectory, + initOrcaProfilePaths +} from './profile-storage-paths' + +export type ActiveOrcaProfileState = { + index: OrcaProfileIndex + profile: OrcaProfileSummary + dataFile: string + profileDirectory: string +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isProfileSummary(value: unknown): value is OrcaProfileSummary { + if (!isObject(value)) { + return false + } + const avatar = value.avatar + const cloud = value.cloud + return ( + typeof value.id === 'string' && + // Why: IDs from the on-disk index become filesystem path segments; a + // tampered index must not be able to escape the profiles directory. + /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value.id) && + typeof value.name === 'string' && + value.name.length > 0 && + (value.kind === 'local' || value.kind === 'cloud-linked') && + typeof value.createdAt === 'number' && + typeof value.updatedAt === 'number' && + typeof value.lastOpenedAt === 'number' && + isObject(avatar) && + avatar.kind === 'initials' && + typeof avatar.initials === 'string' && + avatar.color === 'neutral' && + (cloud === undefined || isObject(cloud)) + ) +} + +function normalizeProfileIndex(raw: unknown): OrcaProfileIndex | null { + if (!isObject(raw) || !Array.isArray(raw.profiles)) { + return null + } + const profiles = raw.profiles.filter(isProfileSummary) + const activeProfileId = + typeof raw.activeProfileId === 'string' && + profiles.some((profile) => profile.id === raw.activeProfileId) + ? raw.activeProfileId + : profiles[0]?.id + if (!activeProfileId) { + return null + } + return { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId, + profiles + } +} + +function sanitizeProfileName(value: unknown): string { + const trimmed = typeof value === 'string' ? value.trim() : '' + return trimmed.length > 0 ? trimmed.slice(0, 80) : 'New Profile' +} + +function readProfileIndexFile(indexPath: string): OrcaProfileIndex | null { + try { + return normalizeProfileIndex(JSON.parse(readFileSync(indexPath, 'utf-8'))) + } catch { + return null + } +} + +export function readProfileIndex(indexPath: string): OrcaProfileIndex | null { + // Why: a torn/corrupt index must not silently reset the app to a single + // default profile — that would orphan every other profile's data directory. + return readProfileIndexFile(indexPath) ?? readProfileIndexFile(`${indexPath}.bak`) +} + +export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): void { + mkdirSync(dirname(indexPath), { recursive: true }) + // Why: only a still-parseable current index may refresh the backup; + // copying a corrupt file over the backup would destroy the recovery copy. + if (existsSync(indexPath) && readProfileIndexFile(indexPath)) { + try { + copyFileSync(indexPath, `${indexPath}.bak`) + } catch { + // Best-effort backup; the primary write below still proceeds. + } + } + const tmpPath = `${indexPath}.tmp` + writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8') + renameSync(tmpPath, indexPath) +} + +function copyIfPresent(source: string, target: string): void { + if (!existsSync(source) || existsSync(target)) { + return + } + mkdirSync(dirname(target), { recursive: true }) + // Why: tmp+rename so a crash mid-copy cannot leave a truncated target that + // the exists() guard above would then treat as a completed migration. + const tmpTarget = `${target}.tmp` + copyFileSync(source, tmpTarget) + renameSync(tmpTarget, target) +} + +function copyLegacyStateToProfile(userDataPath: string, profileId: string): void { + const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath) + copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile) + copyIfPresent( + legacyBrowserSessionMetaPath(userDataPath), + getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath) + ) + for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) { + copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i)) + } +} + +// Why: a brand-new profile has no data file, which the telemetry cohort +// migration reads as a fresh install and defaults to opted-in. Copying the +// active profile's consent block keeps an opted-out user opted out (and keeps +// one installId per install) when they create additional profiles. +export function seedNewOrcaProfileTelemetryConsent( + profileId: string, + telemetry: GlobalSettings['telemetry'], + userDataPath = getProfileUserDataPath() +): void { + if (!telemetry) { + return + } + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + if (existsSync(dataFile)) { + return + } + mkdirSync(dirname(dataFile), { recursive: true }) + const tmpPath = `${dataFile}.tmp` + writeFileSync(tmpPath, JSON.stringify({ settings: { telemetry } }, null, 2), 'utf-8') + renameSync(tmpPath, dataFile) +} + +function createInitialProfileIndex(now = Date.now()): OrcaProfileIndex { + const profile = createDefaultLocalOrcaProfile(now) + return { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId: profile.id, + profiles: [profile] + } +} + +export function loadOrCreateProfileIndex(userDataPath: string): OrcaProfileIndex { + const indexPath = getOrcaProfileIndexPath(userDataPath) + const index = existsSync(indexPath) ? readProfileIndex(indexPath) : null + if (index) { + return index + } + const nextIndex = createInitialProfileIndex() + writeProfileIndex(indexPath, nextIndex) + return nextIndex +} + +function getActiveProfile(index: OrcaProfileIndex): OrcaProfileSummary { + return ( + index.profiles.find((profile) => profile.id === index.activeProfileId) ?? + index.profiles[0] ?? + createDefaultLocalOrcaProfile(Date.now()) + ) +} + +export function ensureActiveOrcaProfile( + userDataPath = getProfileUserDataPath() +): ActiveOrcaProfileState { + const indexPath = getOrcaProfileIndexPath(userDataPath) + let index = existsSync(indexPath) ? readProfileIndex(indexPath) : null + let shouldWriteIndex = false + + if (!index) { + index = createInitialProfileIndex() + shouldWriteIndex = true + } + + const activeProfile = getActiveProfile(index) + if (activeProfile.id !== index.activeProfileId) { + index = { ...index, activeProfileId: activeProfile.id } + shouldWriteIndex = true + } + + const profileDirectory = getOrcaProfileDirectory(activeProfile.id, userDataPath) + mkdirSync(profileDirectory, { recursive: true }) + if (activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID) { + copyLegacyStateToProfile(userDataPath, activeProfile.id) + } + + if (shouldWriteIndex) { + writeProfileIndex(indexPath, index) + } + + return { + index, + profile: activeProfile, + dataFile: getOrcaProfileDataFile(activeProfile.id, userDataPath), + profileDirectory + } +} + +export function isDefaultLocalOrcaProfileId(profileId: string): boolean { + return profileId === DEFAULT_LOCAL_ORCA_PROFILE_ID +} + +export function getOrcaProfileListState( + userDataPath = getProfileUserDataPath() +): OrcaProfileListState { + const { index } = ensureActiveOrcaProfile(userDataPath) + return { + activeProfileId: index.activeProfileId, + profiles: index.profiles + } +} + +export function createLocalOrcaProfile( + args: CreateLocalOrcaProfileArgs = {}, + userDataPath = getProfileUserDataPath() +): CreateLocalOrcaProfileResult { + const index = loadOrCreateProfileIndex(userDataPath) + const now = Date.now() + const name = sanitizeProfileName(args.name) + const profile: OrcaProfileSummary = { + id: `local-${randomUUID()}`, + name, + avatar: { + kind: 'initials', + initials: ( + name.match(/[A-Za-z0-9]/)?.[0] ?? DEFAULT_LOCAL_ORCA_PROFILE_NAME[0] + ).toUpperCase(), + color: 'neutral' + }, + kind: 'local', + createdAt: now, + updatedAt: now, + lastOpenedAt: now + } + const nextIndex: OrcaProfileIndex = { + ...index, + profiles: [...index.profiles, profile] + } + mkdirSync(getOrcaProfileDirectory(profile.id, userDataPath), { recursive: true }) + writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + return { + activeProfileId: nextIndex.activeProfileId, + profiles: nextIndex.profiles, + profile + } +} + +export function setActiveOrcaProfile( + profileId: string, + userDataPath = getProfileUserDataPath() +): OrcaProfileListState { + const index = loadOrCreateProfileIndex(userDataPath) + const now = Date.now() + let found = false + const profiles = index.profiles.map((profile) => { + if (profile.id !== profileId) { + return profile + } + found = true + return { + ...profile, + updatedAt: now, + lastOpenedAt: now + } + }) + if (!found) { + throw new Error('unknown_orca_profile') + } + const nextIndex: OrcaProfileIndex = { + ...index, + activeProfileId: profileId, + profiles + } + mkdirSync(getOrcaProfileDirectory(profileId, userDataPath), { recursive: true }) + writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex) + return { + activeProfileId: nextIndex.activeProfileId, + profiles: nextIndex.profiles + } +} diff --git a/src/main/orca-profiles/profile-project-presence.test.ts b/src/main/orca-profiles/profile-project-presence.test.ts new file mode 100644 index 00000000000..ebbc05b8e14 --- /dev/null +++ b/src/main/orca-profiles/profile-project-presence.test.ts @@ -0,0 +1,145 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { + ORCA_PROFILE_INDEX_SCHEMA_VERSION, + type OrcaProfileIndex, + type OrcaProfileKind +} from '../../shared/orca-profiles' +import type { PersistedState, Repo } from '../../shared/types' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + } +})) + +async function loadPresenceModule() { + vi.resetModules() + return import('./profile-project-presence') +} + +function profile( + id: string, + name: string, + kind: OrcaProfileKind = 'local' +): OrcaProfileIndex['profiles'][number] { + return { + id, + name, + avatar: { kind: 'initials', initials: name[0], color: 'neutral' }, + kind, + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } +} + +function writeIndex(activeProfileId = 'personal'): void { + const index: OrcaProfileIndex = { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId, + profiles: [profile('personal', 'Personal'), profile('work', 'Work')] + } + writeFileSync(join(testState.dir, 'orca-profile-index.json'), JSON.stringify(index), 'utf-8') +} + +function writeProfileState(profileId: string, repos: Repo[]): void { + const state: PersistedState = { + ...getDefaultPersistedState('/Users/tester'), + repos + } + const dataFile = join(testState.dir, 'profiles', profileId, 'orca-data.json') + mkdirSync(dirname(dataFile), { recursive: true }) + writeFileSync(dataFile, JSON.stringify(state), 'utf-8') +} + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/workspace/orca', + displayName: 'Orca', + badgeColor: '#33aa99', + addedAt: 100, + kind: 'git', + connectionId: null, + ...overrides + } +} + +describe('profile project presence', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-presence-')) + writeIndex() + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('finds matching projects in other profiles while excluding the active profile', async () => { + writeProfileState('personal', [ + makeRepo({ id: 'personal-repo', path: 'C:\\Code\\Orca', displayName: 'Personal Orca' }) + ]) + writeProfileState('work', [ + makeRepo({ id: 'work-repo', path: 'C:\\Code\\Orca', displayName: 'Work Orca' }) + ]) + + const { findOrcaProfileProjectsByPath } = await loadPresenceModule() + const result = findOrcaProfileProjectsByPath( + { + path: 'c:/code/orca/', + executionHostId: 'local', + excludeProfileId: 'personal' + }, + testState.dir + ) + + expect(result.projects).toEqual([ + { + profileId: 'work', + profileName: 'Work', + profileKind: 'local', + repoId: 'work-repo', + repoName: 'Work Orca' + } + ]) + }) + + it('keeps SSH projects separate from local projects with the same path', async () => { + writeProfileState('personal', [ + makeRepo({ id: 'local-repo', path: '/srv/orca', displayName: 'Local Orca' }) + ]) + writeProfileState('work', [ + makeRepo({ + id: 'ssh-repo', + path: '/srv/orca', + displayName: 'SSH Orca', + connectionId: 'builder', + executionHostId: 'ssh:builder' + }) + ]) + + const { findOrcaProfileProjectsByPath } = await loadPresenceModule() + const result = findOrcaProfileProjectsByPath( + { + path: '/srv/orca', + connectionId: 'builder', + executionHostId: 'ssh:builder' + }, + testState.dir + ) + + expect(result.projects).toEqual([ + expect.objectContaining({ + profileId: 'work', + repoId: 'ssh-repo', + repoName: 'SSH Orca' + }) + ]) + }) +}) diff --git a/src/main/orca-profiles/profile-project-presence.ts b/src/main/orca-profiles/profile-project-presence.ts new file mode 100644 index 00000000000..23f54884dee --- /dev/null +++ b/src/main/orca-profiles/profile-project-presence.ts @@ -0,0 +1,51 @@ +import type { + FindOrcaProfileProjectsByPathArgs, + FindOrcaProfileProjectsByPathResult +} from '../../shared/orca-profiles' +import { getOrcaProfileListState } from './profile-index-store' +import { readProfileState } from './profile-project-state-file' +import { repoPhysicalKey } from './profile-project-worktree-identity' + +function cleanOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +export function findOrcaProfileProjectsByPath( + args: FindOrcaProfileProjectsByPathArgs, + userDataPath: string +): FindOrcaProfileProjectsByPathResult { + const path = args.path.trim() + if (!path) { + throw new Error('invalid_orca_profile_project_path') + } + + const excludeProfileId = cleanOptionalString(args.excludeProfileId) + const candidateKey = repoPhysicalKey({ + path, + connectionId: cleanOptionalString(args.connectionId), + executionHostId: args.executionHostId ?? undefined + }) + const projects: FindOrcaProfileProjectsByPathResult['projects'] = [] + + for (const profile of getOrcaProfileListState(userDataPath).profiles) { + if (profile.id === excludeProfileId) { + continue + } + const state = readProfileState(profile.id, userDataPath) + for (const repo of state.repos) { + if (repoPhysicalKey(repo) !== candidateKey) { + continue + } + projects.push({ + profileId: profile.id, + profileName: profile.name, + profileKind: profile.kind, + repoId: repo.id, + repoName: repo.displayName || repo.path + }) + } + } + + return { projects } +} diff --git a/src/main/orca-profiles/profile-project-session-state.ts b/src/main/orca-profiles/profile-project-session-state.ts new file mode 100644 index 00000000000..0d80880a19d --- /dev/null +++ b/src/main/orca-profiles/profile-project-session-state.ts @@ -0,0 +1,155 @@ +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { WorkspaceSessionState } from '../../shared/types' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { + isRepoWorktreeId, + ownerKeyBelongsToRepo, + removeRepoWorktreeRecord +} from './profile-project-worktree-identity' + +export function mergeHostWorkspaceSessions( + existing: Partial> | undefined, + incoming: Partial> +): Partial> { + const next: Partial> = { ...existing } + for (const [hostId, session] of Object.entries(incoming)) { + if (!session) { + continue + } + next[hostId as ExecutionHostId] = mergeWorkspaceSessions( + next[hostId as ExecutionHostId], + session + ) + } + return next +} + +export function mergeWorkspaceSessions( + existing: WorkspaceSessionState | undefined, + incoming: WorkspaceSessionState +): WorkspaceSessionState { + const base = existing ?? getDefaultWorkspaceSession() + return { + ...base, + tabsByWorktree: { ...base.tabsByWorktree, ...incoming.tabsByWorktree }, + terminalLayoutsByTabId: { + ...base.terminalLayoutsByTabId, + ...incoming.terminalLayoutsByTabId + }, + openFilesByWorktree: { ...base.openFilesByWorktree, ...incoming.openFilesByWorktree }, + browserTabsByWorktree: { + ...base.browserTabsByWorktree, + ...incoming.browserTabsByWorktree + }, + browserPagesByWorkspace: { + ...base.browserPagesByWorkspace, + ...incoming.browserPagesByWorkspace + }, + activeBrowserTabIdByWorktree: { + ...base.activeBrowserTabIdByWorktree, + ...incoming.activeBrowserTabIdByWorktree + }, + activeFileIdByWorktree: { + ...base.activeFileIdByWorktree, + ...incoming.activeFileIdByWorktree + }, + activeTabTypeByWorktree: { + ...base.activeTabTypeByWorktree, + ...incoming.activeTabTypeByWorktree + }, + activeTabIdByWorktree: { ...base.activeTabIdByWorktree, ...incoming.activeTabIdByWorktree }, + unifiedTabs: { ...base.unifiedTabs, ...incoming.unifiedTabs }, + tabGroups: { ...base.tabGroups, ...incoming.tabGroups }, + tabGroupLayouts: { ...base.tabGroupLayouts, ...incoming.tabGroupLayouts }, + activeGroupIdByWorktree: { + ...base.activeGroupIdByWorktree, + ...incoming.activeGroupIdByWorktree + }, + lastVisitedAtByWorktreeId: { + ...base.lastVisitedAtByWorktreeId, + ...incoming.lastVisitedAtByWorktreeId + }, + defaultTerminalTabsAppliedByWorktreeId: { + ...base.defaultTerminalTabsAppliedByWorktreeId, + ...incoming.defaultTerminalTabsAppliedByWorktreeId + }, + activeWorktreeIdsOnShutdown: [ + ...(base.activeWorktreeIdsOnShutdown ?? []), + ...(incoming.activeWorktreeIdsOnShutdown ?? []) + ], + activeWorktreeId: base.activeWorktreeId ?? incoming.activeWorktreeId, + activeWorkspaceKey: base.activeWorkspaceKey ?? incoming.activeWorkspaceKey, + activeTabId: base.activeTabId ?? incoming.activeTabId + } +} + +export function removeRepoFromHostWorkspaceSessions( + sessions: Partial> | undefined, + repoId: string +): Partial> { + const next: Partial> = {} + for (const [hostId, session] of Object.entries(sessions ?? {})) { + next[hostId as ExecutionHostId] = removeRepoFromWorkspaceSession(session, repoId) + } + return next +} + +export function removeRepoFromWorkspaceSession( + session: WorkspaceSessionState | undefined, + repoId: string +): WorkspaceSessionState { + const next = structuredClone(session ?? getDefaultWorkspaceSession()) + const removedTerminalTabIds = new Set() + for (const [ownerKey, tabs] of Object.entries(next.tabsByWorktree)) { + if (!ownerKeyBelongsToRepo(ownerKey, repoId)) { + continue + } + tabs.forEach((tab) => removedTerminalTabIds.add(tab.id)) + delete next.tabsByWorktree[ownerKey] + } + for (const tabId of removedTerminalTabIds) { + delete next.terminalLayoutsByTabId[tabId] + } + next.openFilesByWorktree = removeRepoWorktreeRecord(next.openFilesByWorktree, repoId) + next.activeFileIdByWorktree = removeRepoWorktreeRecord(next.activeFileIdByWorktree, repoId) + const removedBrowserWorkspaceIds = new Set() + for (const [ownerKey, workspaces] of Object.entries(next.browserTabsByWorktree ?? {})) { + if (!ownerKeyBelongsToRepo(ownerKey, repoId)) { + continue + } + workspaces.forEach((workspace) => removedBrowserWorkspaceIds.add(workspace.id)) + delete next.browserTabsByWorktree![ownerKey] + } + if (next.browserPagesByWorkspace) { + for (const workspaceId of removedBrowserWorkspaceIds) { + delete next.browserPagesByWorkspace[workspaceId] + } + } + next.activeBrowserTabIdByWorktree = removeRepoWorktreeRecord( + next.activeBrowserTabIdByWorktree, + repoId + ) + next.activeTabTypeByWorktree = removeRepoWorktreeRecord(next.activeTabTypeByWorktree, repoId) + next.activeTabIdByWorktree = removeRepoWorktreeRecord(next.activeTabIdByWorktree, repoId) + next.unifiedTabs = removeRepoWorktreeRecord(next.unifiedTabs, repoId) + next.tabGroups = removeRepoWorktreeRecord(next.tabGroups, repoId) + next.tabGroupLayouts = removeRepoWorktreeRecord(next.tabGroupLayouts, repoId) + next.activeGroupIdByWorktree = removeRepoWorktreeRecord(next.activeGroupIdByWorktree, repoId) + next.lastVisitedAtByWorktreeId = removeRepoWorktreeRecord(next.lastVisitedAtByWorktreeId, repoId) + next.defaultTerminalTabsAppliedByWorktreeId = removeRepoWorktreeRecord( + next.defaultTerminalTabsAppliedByWorktreeId, + repoId + ) + if (next.activeWorktreeId && isRepoWorktreeId(repoId, next.activeWorktreeId)) { + next.activeWorktreeId = null + } + const activeScope = next.activeWorkspaceKey ? parseWorkspaceKey(next.activeWorkspaceKey) : null + if (activeScope?.type === 'worktree' && isRepoWorktreeId(repoId, activeScope.worktreeId)) { + next.activeWorkspaceKey = null + } + next.activeWorktreeIdsOnShutdown = next.activeWorktreeIdsOnShutdown?.filter( + (worktreeId) => !isRepoWorktreeId(repoId, worktreeId) + ) + return next +} diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts new file mode 100644 index 00000000000..8380e31301b --- /dev/null +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -0,0 +1,215 @@ +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { + BrowserPage, + BrowserWorkspace, + PersistedOpenFile, + Tab, + TabGroup, + TerminalTab, + WorkspaceSessionState +} from '../../shared/types' +import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { + isRepoWorktreeId, + rekeyOwnerKey, + rekeyWorktreeId +} from './profile-project-worktree-identity' + +export function extractHostSessionsForTransfer( + sessions: Partial> | undefined, + oldRepoId: string, + newRepoId: string +): Partial> { + const next: Partial> = {} + for (const [hostId, session] of Object.entries(sessions ?? {})) { + if (!session) { + continue + } + const transferred = extractSessionForTransfer(session, oldRepoId, newRepoId) + if (hasTransferredSessionState(transferred)) { + next[hostId as ExecutionHostId] = transferred + } + } + return next +} + +function hasTransferredSessionState(session: WorkspaceSessionState): boolean { + return ( + Object.keys(session.tabsByWorktree).length > 0 || + Object.keys(session.openFilesByWorktree ?? {}).length > 0 || + Object.keys(session.browserTabsByWorktree ?? {}).length > 0 || + Object.keys(session.unifiedTabs ?? {}).length > 0 || + Object.keys(session.tabGroups ?? {}).length > 0 + ) +} + +export function extractSessionForTransfer( + session: WorkspaceSessionState | undefined, + oldRepoId: string, + newRepoId: string +): WorkspaceSessionState { + const source = session ?? getDefaultWorkspaceSession() + const transferred = getDefaultWorkspaceSession() + const copiedTerminalTabIds = new Set() + const copiedBrowserWorkspaceIds = new Set() + const mapOwnerRecord = ( + record: Record | undefined, + mapValue: (value: T) => T + ): Record => { + const next: Record = {} + for (const [ownerKey, value] of Object.entries(record ?? {})) { + const nextOwnerKey = rekeyOwnerKey(oldRepoId, newRepoId, ownerKey) + if (nextOwnerKey) { + next[nextOwnerKey] = mapValue(value) + } + } + return next + } + transferred.tabsByWorktree = mapOwnerRecord(source.tabsByWorktree, (tabs) => + tabs.map((tab) => { + copiedTerminalTabIds.add(tab.id) + return rekeyTerminalTab(tab, oldRepoId, newRepoId) + }) + ) + transferred.openFilesByWorktree = mapOwnerRecord(source.openFilesByWorktree, (files) => + files.map((file) => rekeyOpenFile(file, oldRepoId, newRepoId)) + ) + transferred.activeFileIdByWorktree = mapOwnerRecord(source.activeFileIdByWorktree, (value) => + structuredClone(value) + ) + transferred.browserTabsByWorktree = mapOwnerRecord(source.browserTabsByWorktree, (tabs) => + tabs.map((tab) => { + copiedBrowserWorkspaceIds.add(tab.id) + return rekeyBrowserWorkspace(tab, oldRepoId, newRepoId) + }) + ) + transferred.browserPagesByWorkspace = copyBrowserPages( + source.browserPagesByWorkspace, + copiedBrowserWorkspaceIds, + oldRepoId, + newRepoId + ) + transferred.activeBrowserTabIdByWorktree = mapOwnerRecord( + source.activeBrowserTabIdByWorktree, + (value) => structuredClone(value) + ) + transferred.activeTabTypeByWorktree = mapOwnerRecord(source.activeTabTypeByWorktree, (value) => + structuredClone(value) + ) + transferred.activeTabIdByWorktree = mapOwnerRecord(source.activeTabIdByWorktree, (value) => + structuredClone(value) + ) + transferred.unifiedTabs = mapOwnerRecord(source.unifiedTabs, (tabs) => + tabs.map((tab) => rekeyUnifiedTab(tab, oldRepoId, newRepoId)) + ) + transferred.tabGroups = mapOwnerRecord(source.tabGroups, (groups) => + groups.map((group) => rekeyTabGroup(group, oldRepoId, newRepoId)) + ) + transferred.tabGroupLayouts = mapOwnerRecord(source.tabGroupLayouts, (value) => + structuredClone(value) + ) + transferred.activeGroupIdByWorktree = mapOwnerRecord(source.activeGroupIdByWorktree, (value) => + structuredClone(value) + ) + transferred.lastVisitedAtByWorktreeId = mapOwnerRecord( + source.lastVisitedAtByWorktreeId, + (value) => structuredClone(value) + ) + transferred.defaultTerminalTabsAppliedByWorktreeId = mapOwnerRecord( + source.defaultTerminalTabsAppliedByWorktreeId, + (value) => structuredClone(value) + ) + transferred.terminalLayoutsByTabId = {} + for (const tabId of copiedTerminalTabIds) { + const layout = source.terminalLayoutsByTabId[tabId] + if (layout) { + transferred.terminalLayoutsByTabId[tabId] = structuredClone(layout) + } + } + transferred.activeWorktreeIdsOnShutdown = source.activeWorktreeIdsOnShutdown + ?.filter((worktreeId) => isRepoWorktreeId(oldRepoId, worktreeId)) + .map((worktreeId) => rekeyWorktreeId(oldRepoId, newRepoId, worktreeId)) + if (source.activeWorktreeId && isRepoWorktreeId(oldRepoId, source.activeWorktreeId)) { + transferred.activeWorktreeId = rekeyWorktreeId(oldRepoId, newRepoId, source.activeWorktreeId) + } + const activeScope = source.activeWorkspaceKey + ? parseWorkspaceKey(source.activeWorkspaceKey) + : null + if (activeScope?.type === 'worktree' && isRepoWorktreeId(oldRepoId, activeScope.worktreeId)) { + transferred.activeWorkspaceKey = worktreeWorkspaceKey( + rekeyWorktreeId(oldRepoId, newRepoId, activeScope.worktreeId) + ) + } + return transferred +} + +function rekeyTerminalTab(tab: TerminalTab, oldRepoId: string, newRepoId: string): TerminalTab { + return { + ...structuredClone(tab), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId) + } +} + +function rekeyOpenFile( + file: PersistedOpenFile, + oldRepoId: string, + newRepoId: string +): PersistedOpenFile { + return { + ...structuredClone(file), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, file.worktreeId) + } +} + +function rekeyBrowserWorkspace( + workspace: BrowserWorkspace, + oldRepoId: string, + newRepoId: string +): BrowserWorkspace { + return { + ...structuredClone(workspace), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, workspace.worktreeId), + // Why: both the session profile and the resolved partition string are + // source-profile-scoped; carrying either across would point the restored + // pane at a partition the target profile's allowlist rejects. + sessionProfileId: null, + sessionPartition: null + } +} + +function rekeyBrowserPage(page: BrowserPage, oldRepoId: string, newRepoId: string): BrowserPage { + return { + ...structuredClone(page), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, page.worktreeId) + } +} + +function copyBrowserPages( + pagesByWorkspace: Record | undefined, + workspaceIds: ReadonlySet, + oldRepoId: string, + newRepoId: string +): Record { + const next: Record = {} + for (const [workspaceId, pages] of Object.entries(pagesByWorkspace ?? {})) { + if (workspaceIds.has(workspaceId)) { + next[workspaceId] = pages.map((page) => rekeyBrowserPage(page, oldRepoId, newRepoId)) + } + } + return next +} + +function rekeyUnifiedTab(tab: Tab, oldRepoId: string, newRepoId: string): Tab { + return { + ...structuredClone(tab), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, tab.worktreeId) + } +} + +function rekeyTabGroup(group: TabGroup, oldRepoId: string, newRepoId: string): TabGroup { + return { + ...structuredClone(group), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, group.worktreeId) + } +} diff --git a/src/main/orca-profiles/profile-project-source-removal.ts b/src/main/orca-profiles/profile-project-source-removal.ts new file mode 100644 index 00000000000..edb923e6293 --- /dev/null +++ b/src/main/orca-profiles/profile-project-source-removal.ts @@ -0,0 +1,66 @@ +import type { WorkspaceKey } from '../../shared/types' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { + rebuildRepoBackedProjectState, + type TransferProfileState +} from './profile-project-state-file' +import { + removeRepoFromHostWorkspaceSessions, + removeRepoFromWorkspaceSession +} from './profile-project-session-state' +import { isRepoWorktreeId, removeRepoWorktreeRecord } from './profile-project-worktree-identity' + +export function removeSourceRepo( + state: TransferProfileState, + repoId: string +): TransferProfileState { + const next: TransferProfileState = { + ...state, + repos: state.repos.filter((repo) => repo.id !== repoId), + sparsePresetsByRepo: { ...state.sparsePresetsByRepo }, + worktreeMeta: { ...state.worktreeMeta }, + worktreeLineageById: { ...state.worktreeLineageById }, + workspaceLineageByChildKey: { ...state.workspaceLineageByChildKey }, + workspaceSession: removeRepoFromWorkspaceSession(state.workspaceSession, repoId), + workspaceSessionsByHostId: removeRepoFromHostWorkspaceSessions( + state.workspaceSessionsByHostId, + repoId + ), + ui: { + ...state.ui, + lastActiveRepoId: state.ui.lastActiveRepoId === repoId ? null : state.ui.lastActiveRepoId, + lastActiveWorktreeId: + state.ui.lastActiveWorktreeId && isRepoWorktreeId(repoId, state.ui.lastActiveWorktreeId) + ? null + : state.ui.lastActiveWorktreeId, + filterRepoIds: state.ui.filterRepoIds?.filter((id) => id !== repoId) ?? [], + showDotfilesByWorktree: removeRepoWorktreeRecord(state.ui.showDotfilesByWorktree, repoId) + } + } + delete next.sparsePresetsByRepo[repoId] + removeRepoWorktreeMetadata(next, repoId) + return rebuildRepoBackedProjectState(next) +} + +function removeRepoWorktreeMetadata(state: TransferProfileState, repoId: string): void { + for (const key of Object.keys(state.worktreeMeta)) { + if (isRepoWorktreeId(repoId, key)) { + delete state.worktreeMeta[key] + } + } + for (const [key, lineage] of Object.entries(state.worktreeLineageById)) { + if (isRepoWorktreeId(repoId, key) || isRepoWorktreeId(repoId, lineage.parentWorktreeId)) { + delete state.worktreeLineageById[key] + } + } + for (const [key, lineage] of Object.entries(state.workspaceLineageByChildKey)) { + const child = parseWorkspaceKey(key) + const parent = parseWorkspaceKey(lineage.parentWorkspaceKey) + if ( + (child?.type === 'worktree' && isRepoWorktreeId(repoId, child.worktreeId)) || + (parent?.type === 'worktree' && isRepoWorktreeId(repoId, parent.worktreeId)) + ) { + delete state.workspaceLineageByChildKey[key as WorkspaceKey] + } + } +} diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts new file mode 100644 index 00000000000..4169fea6ffc --- /dev/null +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -0,0 +1,139 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname } from 'node:path' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import type { + PersistedState, + Project, + ProjectHostSetup, + Repo, + SparsePreset, + WorkspaceSessionState +} from '../../shared/types' +import { getOrcaProfileDataFile } from './profile-index-store' + +export type TransferProfileState = PersistedState + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function arrayOrEmpty(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : [] +} + +function recordOrEmpty(value: unknown): Record { + return isRecord(value) ? (value as Record) : {} +} + +export function readProfileState(profileId: string, userDataPath: string): TransferProfileState { + const defaults = getDefaultPersistedState(homedir()) + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + if (!existsSync(dataFile)) { + return structuredClone(defaults) + } + const parsed = JSON.parse(readFileSync(dataFile, 'utf-8')) as Partial + return rebuildRepoBackedProjectState({ + ...defaults, + ...parsed, + repos: arrayOrEmpty(parsed.repos), + projects: arrayOrEmpty(parsed.projects), + projectHostSetups: arrayOrEmpty(parsed.projectHostSetups), + projectGroups: arrayOrEmpty(parsed.projectGroups), + folderWorkspaces: arrayOrEmpty(parsed.folderWorkspaces), + sparsePresetsByRepo: recordOrEmpty(parsed.sparsePresetsByRepo), + worktreeMeta: recordOrEmpty(parsed.worktreeMeta), + worktreeLineageById: recordOrEmpty(parsed.worktreeLineageById), + workspaceLineageByChildKey: recordOrEmpty(parsed.workspaceLineageByChildKey), + settings: isRecord(parsed.settings) + ? { ...defaults.settings, ...parsed.settings } + : defaults.settings, + ui: isRecord(parsed.ui) ? { ...defaults.ui, ...parsed.ui } : defaults.ui, + githubCache: isRecord(parsed.githubCache) + ? { + pr: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).pr), + issue: recordOrEmpty((parsed.githubCache as PersistedState['githubCache']).issue) + } + : defaults.githubCache, + workspaceSession: isRecord(parsed.workspaceSession) + ? { ...getDefaultWorkspaceSession(), ...parsed.workspaceSession } + : defaults.workspaceSession, + workspaceSessionsByHostId: isRecord(parsed.workspaceSessionsByHostId) + ? (parsed.workspaceSessionsByHostId as Partial< + Record + >) + : {}, + sshTargets: arrayOrEmpty(parsed.sshTargets), + sshRemotePtyLeases: arrayOrEmpty(parsed.sshRemotePtyLeases), + migrationUnsupportedPtyEntries: arrayOrEmpty(parsed.migrationUnsupportedPtyEntries), + legacyPaneKeyAliasEntries: arrayOrEmpty(parsed.legacyPaneKeyAliasEntries), + automations: arrayOrEmpty(parsed.automations), + automationRuns: arrayOrEmpty(parsed.automationRuns), + onboarding: isRecord(parsed.onboarding) + ? { ...defaults.onboarding, ...parsed.onboarding } + : defaults.onboarding, + featureInteractionTelemetryBuckets: isRecord(parsed.featureInteractionTelemetryBuckets) + ? parsed.featureInteractionTelemetryBuckets + : defaults.featureInteractionTelemetryBuckets + }) +} + +export function writeProfileState( + profileId: string, + userDataPath: string, + state: TransferProfileState +): void { + const dataFile = getOrcaProfileDataFile(profileId, userDataPath) + mkdirSync(dirname(dataFile), { recursive: true }) + const tmpPath = `${dataFile}.${process.pid}.${randomUUID()}.tmp` + writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8') + renameSync(tmpPath, dataFile) +} + +function isRepoBackedProjectHostSetup( + setup: ProjectHostSetup, + currentRepoIds: ReadonlySet +): boolean { + return Boolean(setup.repoId && currentRepoIds.has(setup.repoId)) +} + +export function rebuildRepoBackedProjectState(state: TransferProfileState): TransferProfileState { + const projection = projectHostSetupProjectionFromRepos(state.repos) + const existingProjectsById = new Map(state.projects.map((project) => [project.id, project])) + const currentRepoIds = new Set(state.repos.map((repo) => repo.id)) + const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) + const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) + const independentSetups = state.projectHostSetups.filter((setup) => { + if (projectedSetupIds.has(setup.id)) { + return false + } + return !isRepoBackedProjectHostSetup(setup, currentRepoIds) + }) + const independentProjectIds = new Set(independentSetups.map((setup) => setup.projectId)) + const independentProjects = state.projects + .filter( + (project) => independentProjectIds.has(project.id) && !projectedProjectIds.has(project.id) + ) + .map((project) => ({ + ...project, + sourceRepoIds: project.sourceRepoIds.filter((repoId) => currentRepoIds.has(repoId)) + })) + const projectedProjects = projection.projects.map((project) => { + const existingProject = existingProjectsById.get(project.id) + return existingProject?.localWindowsRuntimePreference + ? { + ...project, + localWindowsRuntimePreference: existingProject.localWindowsRuntimePreference, + updatedAt: Math.max(project.updatedAt, existingProject.updatedAt) + } + : project + }) + return { + ...state, + projects: [...projectedProjects, ...independentProjects], + projectHostSetups: [...projection.setups, ...independentSetups] + } +} diff --git a/src/main/orca-profiles/profile-project-transfer-payload.ts b/src/main/orca-profiles/profile-project-transfer-payload.ts new file mode 100644 index 00000000000..68075a71a11 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer-payload.ts @@ -0,0 +1,294 @@ +import { randomUUID } from 'node:crypto' +import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import type { SshTarget } from '../../shared/ssh-types' +import type { PersistedState, Repo, SparsePreset, WorkspaceKey } from '../../shared/types' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import type { TransferProfileState } from './profile-project-state-file' +import { rebuildRepoBackedProjectState } from './profile-project-state-file' +import { mergeHostWorkspaceSessions, mergeWorkspaceSessions } from './profile-project-session-state' +import { + extractHostSessionsForTransfer, + extractSessionForTransfer +} from './profile-project-session-transfer' +import { + isRepoWorktreeId, + rekeyWorktreeId, + rekeyWorkspaceKey +} from './profile-project-worktree-identity' + +export type TransferPayload = { + repo: Repo + sparsePresets: SparsePreset[] + worktreeMeta: PersistedState['worktreeMeta'] + worktreeLineageById: PersistedState['worktreeLineageById'] + workspaceLineageByChildKey: PersistedState['workspaceLineageByChildKey'] + workspaceSession?: PersistedState['workspaceSession'] + workspaceSessionsByHostId?: Partial> + sshTargets: SshTarget[] + targetProjectId: string | null +} + +export function createTargetRepo( + sourceRepo: Repo, + targetState: TransferProfileState, + copy: boolean +): Repo { + const targetRepoId = + !copy && !targetState.repos.some((repo) => repo.id === sourceRepo.id) + ? sourceRepo.id + : createUniqueRepoId(targetState) + const repo: Repo = { + ...sourceRepo, + id: targetRepoId, + projectGroupId: null, + addedAt: copy ? Date.now() : sourceRepo.addedAt + } + delete repo.projectGroupOrder + return repo +} + +function createUniqueRepoId(state: TransferProfileState): string { + const existingRepoIds = new Set(state.repos.map((repo) => repo.id)) + let candidate = randomUUID() + while (existingRepoIds.has(candidate)) { + candidate = randomUUID() + } + return candidate +} + +function collectTransferWorktreeIds(state: TransferProfileState, repoId: string): Set { + const ids = new Set() + const add = (value: string | null | undefined): void => { + if (value && isRepoWorktreeId(repoId, value)) { + ids.add(value) + } + } + Object.keys(state.worktreeMeta).forEach(add) + for (const lineage of Object.values(state.worktreeLineageById)) { + add(lineage.worktreeId) + add(lineage.parentWorktreeId) + } + for (const [key, lineage] of Object.entries(state.workspaceLineageByChildKey)) { + const child = parseWorkspaceKey(key) + const parent = parseWorkspaceKey(lineage.parentWorkspaceKey) + if (child?.type === 'worktree') { + add(child.worktreeId) + } + if (parent?.type === 'worktree') { + add(parent.worktreeId) + } + } + collectSessionWorktreeIds(state.workspaceSession, repoId, ids) + for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + collectSessionWorktreeIds(session, repoId, ids) + } + Object.keys(state.ui?.showDotfilesByWorktree ?? {}).forEach(add) + return ids +} + +function collectSessionWorktreeIds( + session: PersistedState['workspaceSession'] | undefined, + repoId: string, + ids: Set +): void { + if (!session) { + return + } + const add = (value: string | null | undefined): void => { + if (value && isRepoWorktreeId(repoId, value)) { + ids.add(value) + } + } + const addOwnerKeys = (record: Record | undefined): void => { + for (const key of Object.keys(record ?? {})) { + if (isRepoWorktreeId(repoId, key)) { + ids.add(key) + } + const parsed = parseWorkspaceKey(key) + if (parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId)) { + ids.add(parsed.worktreeId) + } + } + } + addOwnerKeys(session.tabsByWorktree) + addOwnerKeys(session.openFilesByWorktree) + addOwnerKeys(session.browserTabsByWorktree) + addOwnerKeys(session.activeBrowserTabIdByWorktree) + addOwnerKeys(session.activeTabTypeByWorktree) + addOwnerKeys(session.activeTabIdByWorktree) + addOwnerKeys(session.unifiedTabs) + addOwnerKeys(session.tabGroups) + addOwnerKeys(session.tabGroupLayouts) + addOwnerKeys(session.activeGroupIdByWorktree) + addOwnerKeys(session.lastVisitedAtByWorktreeId) + addOwnerKeys(session.defaultTerminalTabsAppliedByWorktreeId) + addOwnerKeys(session.activeFileIdByWorktree) + add(session.activeWorktreeId) + const activeScope = session.activeWorkspaceKey + ? parseWorkspaceKey(session.activeWorkspaceKey) + : null + if (activeScope?.type === 'worktree') { + add(activeScope.worktreeId) + } +} + +function rekeyWorktreeIdRecord( + record: Record, + worktreeIds: ReadonlySet, + oldRepoId: string, + newRepoId: string, + mapValue: (value: T) => T = (value) => structuredClone(value) +): Record { + const next: Record = {} + for (const [oldKey, value] of Object.entries(record)) { + if (worktreeIds.has(oldKey)) { + next[rekeyWorktreeId(oldRepoId, newRepoId, oldKey)] = mapValue(value) + } + } + return next +} + +function rekeyLineageRecord( + record: PersistedState['worktreeLineageById'], + worktreeIds: ReadonlySet, + oldRepoId: string, + newRepoId: string +): PersistedState['worktreeLineageById'] { + const next: PersistedState['worktreeLineageById'] = {} + for (const [oldKey, lineage] of Object.entries(record)) { + if (!worktreeIds.has(oldKey) && !worktreeIds.has(lineage.parentWorktreeId)) { + continue + } + const newKey = rekeyWorktreeId(oldRepoId, newRepoId, oldKey) + next[newKey] = { + ...structuredClone(lineage), + worktreeId: rekeyWorktreeId(oldRepoId, newRepoId, lineage.worktreeId), + parentWorktreeId: rekeyWorktreeId(oldRepoId, newRepoId, lineage.parentWorktreeId) + } + } + return next +} + +function rekeyWorkspaceLineageRecord( + record: PersistedState['workspaceLineageByChildKey'], + oldRepoId: string, + newRepoId: string +): PersistedState['workspaceLineageByChildKey'] { + const next: PersistedState['workspaceLineageByChildKey'] = {} + for (const [oldKey, lineage] of Object.entries(record)) { + const newChildKey = rekeyWorkspaceKey(oldRepoId, newRepoId, oldKey as WorkspaceKey) + const newParentKey = rekeyWorkspaceKey(oldRepoId, newRepoId, lineage.parentWorkspaceKey) + if (newChildKey === oldKey && newParentKey === lineage.parentWorkspaceKey) { + continue + } + next[newChildKey] = { + ...structuredClone(lineage), + childWorkspaceKey: newChildKey, + parentWorkspaceKey: newParentKey + } + } + return next +} + +export function createTransferPayload(args: { + sourceState: TransferProfileState + sourceRepo: Repo + targetRepo: Repo + includeSessions: boolean +}): TransferPayload { + const { sourceState, sourceRepo, targetRepo, includeSessions } = args + const oldRepoId = sourceRepo.id + const newRepoId = targetRepo.id + const worktreeIds = collectTransferWorktreeIds(sourceState, oldRepoId) + const targetProjection = projectHostSetupProjectionFromRepos([targetRepo]) + const targetProjectId = + targetProjection.setups[0]?.projectId ?? targetProjection.projects[0]?.id ?? null + return { + repo: targetRepo, + sparsePresets: (sourceState.sparsePresetsByRepo[oldRepoId] ?? []).map((preset) => ({ + ...structuredClone(preset), + repoId: newRepoId + })), + worktreeMeta: rekeyWorktreeIdRecord( + sourceState.worktreeMeta, + worktreeIds, + oldRepoId, + newRepoId, + (meta) => ({ + ...structuredClone(meta), + ...(targetProjectId ? { projectId: targetProjectId } : {}), + hostId: getRepoExecutionHostId(targetRepo), + projectHostSetupId: targetRepo.id + }) + ), + worktreeLineageById: rekeyLineageRecord( + sourceState.worktreeLineageById, + worktreeIds, + oldRepoId, + newRepoId + ), + workspaceLineageByChildKey: rekeyWorkspaceLineageRecord( + sourceState.workspaceLineageByChildKey, + oldRepoId, + newRepoId + ), + ...(includeSessions + ? { + workspaceSession: extractSessionForTransfer( + sourceState.workspaceSession, + oldRepoId, + newRepoId + ), + workspaceSessionsByHostId: extractHostSessionsForTransfer( + sourceState.workspaceSessionsByHostId, + oldRepoId, + newRepoId + ) + } + : {}), + sshTargets: sourceRepo.connectionId + ? sourceState.sshTargets.filter((target) => target.id === sourceRepo.connectionId) + : [], + targetProjectId + } +} + +export function applyPayloadToTarget( + targetState: TransferProfileState, + payload: TransferPayload +): TransferProfileState { + const next: TransferProfileState = { + ...targetState, + repos: [...targetState.repos, payload.repo], + sparsePresetsByRepo: { + ...targetState.sparsePresetsByRepo, + ...(payload.sparsePresets.length > 0 ? { [payload.repo.id]: payload.sparsePresets } : {}) + }, + worktreeMeta: { ...targetState.worktreeMeta, ...payload.worktreeMeta }, + worktreeLineageById: { ...targetState.worktreeLineageById, ...payload.worktreeLineageById }, + workspaceLineageByChildKey: { + ...targetState.workspaceLineageByChildKey, + ...payload.workspaceLineageByChildKey + }, + sshTargets: mergeSshTargets(targetState.sshTargets, payload.sshTargets) + } + if (payload.workspaceSession) { + next.workspaceSession = mergeWorkspaceSessions( + targetState.workspaceSession, + payload.workspaceSession + ) + } + if (payload.workspaceSessionsByHostId) { + next.workspaceSessionsByHostId = mergeHostWorkspaceSessions( + targetState.workspaceSessionsByHostId, + payload.workspaceSessionsByHostId + ) + } + return rebuildRepoBackedProjectState(next) +} + +function mergeSshTargets(existing: SshTarget[], incoming: SshTarget[]): SshTarget[] { + const existingIds = new Set(existing.map((target) => target.id)) + return [...existing, ...incoming.filter((target) => !existingIds.has(target.id))] +} diff --git a/src/main/orca-profiles/profile-project-transfer.test.ts b/src/main/orca-profiles/profile-project-transfer.test.ts new file mode 100644 index 00000000000..3c3a4643387 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer.test.ts @@ -0,0 +1,301 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { + ORCA_PROFILE_INDEX_SCHEMA_VERSION, + type OrcaProfileIndex +} from '../../shared/orca-profiles' +import type { PersistedState, Repo, WorktreeMeta } from '../../shared/types' +import type { SshTarget } from '../../shared/ssh-types' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + } +})) + +async function loadTransferModule() { + vi.resetModules() + return import('./profile-project-transfer') +} + +function profile(id: string, name: string): OrcaProfileIndex['profiles'][number] { + return { + id, + name, + avatar: { kind: 'initials', initials: name[0], color: 'neutral' }, + kind: 'local', + createdAt: 1, + updatedAt: 1, + lastOpenedAt: 1 + } +} + +function writeIndex(activeProfileId = 'personal'): void { + const index: OrcaProfileIndex = { + schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION, + activeProfileId, + profiles: [profile('personal', 'Personal'), profile('work', 'Work')] + } + writeFileSync(join(testState.dir, 'orca-profile-index.json'), JSON.stringify(index), 'utf-8') +} + +function profileDataPath(profileId: string): string { + return join(testState.dir, 'profiles', profileId, 'orca-data.json') +} + +function writeProfileState(profileId: string, state: PersistedState): void { + const dataFile = profileDataPath(profileId) + mkdirSync(join(dataFile, '..'), { recursive: true }) + writeFileSync(dataFile, JSON.stringify(state, null, 2), 'utf-8') +} + +function readProfileState(profileId: string): PersistedState { + return JSON.parse(readFileSync(profileDataPath(profileId), 'utf-8')) as PersistedState +} + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/workspace/orca', + displayName: 'Orca', + badgeColor: '#33aa99', + addedAt: 100, + kind: 'git', + connectionId: null, + ...overrides + } +} + +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: 'Feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 10, + lastActivityAt: 123, + ...overrides + } +} + +function makeState(overrides: Partial = {}): PersistedState { + const defaults = getDefaultPersistedState('/Users/tester') + return { + ...defaults, + ...overrides, + settings: { ...defaults.settings, ...overrides.settings }, + ui: { ...defaults.ui, ...overrides.ui }, + workspaceSession: { ...defaults.workspaceSession, ...overrides.workspaceSession } + } +} + +describe('profile project transfer', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-transfer-')) + writeIndex() + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('copies a project into another profile with a new repo id and re-keyed metadata', async () => { + const sourceWorktreeId = 'repo-1::/workspace/orca-feature' + writeProfileState( + 'personal', + makeState({ + repos: [makeRepo()], + sparsePresetsByRepo: { + 'repo-1': [ + { + id: 'preset-1', + repoId: 'repo-1', + name: 'UI', + directories: ['src/renderer'], + createdAt: 1, + updatedAt: 1 + } + ] + }, + worktreeMeta: { + [sourceWorktreeId]: makeWorktreeMeta({ projectHostSetupId: 'repo-1' }) + }, + workspaceSession: { + ...getDefaultPersistedState('/Users/tester').workspaceSession, + tabsByWorktree: { + [sourceWorktreeId]: [ + { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: sourceWorktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + } + }) + ) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result.status).toBe('transferred') + expect(result.status === 'transferred' ? result.targetRepoId : '').not.toBe('repo-1') + const targetRepoId = result.status === 'transferred' ? result.targetRepoId : '' + const target = readProfileState('work') + const targetWorktreeId = `${targetRepoId}::/workspace/orca-feature` + expect(target.repos).toEqual([ + expect.objectContaining({ id: targetRepoId, path: '/workspace/orca' }) + ]) + expect(target.worktreeMeta[targetWorktreeId]).toMatchObject({ + displayName: 'Feature', + projectHostSetupId: targetRepoId, + hostId: 'local' + }) + expect(target.sparsePresetsByRepo[targetRepoId]).toEqual([ + expect.objectContaining({ id: 'preset-1', repoId: targetRepoId }) + ]) + expect(target.workspaceSession.tabsByWorktree).toEqual({}) + expect(readProfileState('personal').repos.map((repo) => repo.id)).toEqual(['repo-1']) + }) + + it('moves a project, preserving SSH identity and restorable workspace session state', async () => { + const sourceWorktreeId = 'repo-ssh::/srv/orca-feature' + const sshTarget: SshTarget = { + id: 'ssh-1', + label: 'Builder', + host: 'builder.example.com', + port: 22, + username: 'dev' + } + writeProfileState( + 'personal', + makeState({ + repos: [ + makeRepo({ + id: 'repo-ssh', + path: '/srv/orca', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + ], + sshTargets: [sshTarget], + worktreeMeta: { + [sourceWorktreeId]: makeWorktreeMeta({ projectHostSetupId: 'repo-ssh' }) + }, + workspaceSession: { + ...getDefaultPersistedState('/Users/tester').workspaceSession, + browserTabsByWorktree: { + [sourceWorktreeId]: [ + { + id: 'browser-1', + worktreeId: sourceWorktreeId, + sessionProfileId: 'source-browser-profile', + sessionPartition: 'persist:orca-profile-personal-deadbeef-browser-default', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } + } + }) + ) + writeProfileState('work', makeState()) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-ssh', + mode: 'move' + }, + testState.dir + ) + + expect(result).toMatchObject({ + status: 'transferred', + sourceRepoId: 'repo-ssh', + targetRepoId: 'repo-ssh' + }) + const source = readProfileState('personal') + const target = readProfileState('work') + expect(source.repos).toEqual([]) + expect(source.worktreeMeta).toEqual({}) + expect(target.repos[0]).toMatchObject({ + id: 'repo-ssh', + path: '/srv/orca', + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + }) + expect(target.sshTargets).toEqual([sshTarget]) + expect(target.workspaceSession.browserTabsByWorktree?.[sourceWorktreeId]?.[0]).toMatchObject({ + worktreeId: sourceWorktreeId, + sessionProfileId: null, + sessionPartition: null + }) + }) + + it('rejects a duplicate physical project inside the target profile', async () => { + writeProfileState( + 'personal', + makeState({ + repos: [makeRepo({ path: 'C:\\Work\\Orca\\' })] + }) + ) + writeProfileState( + 'work', + makeState({ + repos: [makeRepo({ id: 'repo-existing', path: 'c:/work/orca' })] + }) + ) + + const { transferOrcaProfileProject } = await loadTransferModule() + const result = transferOrcaProfileProject( + { + sourceProfileId: 'personal', + targetProfileId: 'work', + repoId: 'repo-1', + mode: 'copy' + }, + testState.dir + ) + + expect(result).toEqual({ + status: 'duplicate-target', + sourceProfileId: 'personal', + targetProfileId: 'work', + sourceRepoId: 'repo-1', + duplicateRepoId: 'repo-existing' + }) + expect(readProfileState('work').repos.map((repo) => repo.id)).toEqual(['repo-existing']) + }) +}) diff --git a/src/main/orca-profiles/profile-project-transfer.ts b/src/main/orca-profiles/profile-project-transfer.ts new file mode 100644 index 00000000000..75022d3ad65 --- /dev/null +++ b/src/main/orca-profiles/profile-project-transfer.ts @@ -0,0 +1,77 @@ +import type { + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult +} from '../../shared/orca-profiles' +import { getOrcaProfileListState } from './profile-index-store' +import { readProfileState, writeProfileState } from './profile-project-state-file' +import { removeSourceRepo } from './profile-project-source-removal' +import { + applyPayloadToTarget, + createTargetRepo, + createTransferPayload +} from './profile-project-transfer-payload' +import { repoPhysicalKey } from './profile-project-worktree-identity' + +function assertKnownProfiles(args: TransferOrcaProfileProjectArgs, userDataPath: string): void { + const profiles = getOrcaProfileListState(userDataPath).profiles + const ids = new Set(profiles.map((profile) => profile.id)) + if (!ids.has(args.sourceProfileId)) { + throw new Error('unknown_source_orca_profile') + } + if (!ids.has(args.targetProfileId)) { + throw new Error('unknown_target_orca_profile') + } + if (args.sourceProfileId === args.targetProfileId) { + throw new Error('matching_orca_profile_transfer') + } +} + +export function transferOrcaProfileProject( + args: TransferOrcaProfileProjectArgs, + userDataPath: string +): TransferOrcaProfileProjectResult { + assertKnownProfiles(args, userDataPath) + const sourceState = readProfileState(args.sourceProfileId, userDataPath) + const targetState = readProfileState(args.targetProfileId, userDataPath) + const sourceRepo = sourceState.repos.find((repo) => repo.id === args.repoId) + if (!sourceRepo) { + throw new Error('unknown_source_repo') + } + const duplicate = targetState.repos.find( + (repo) => repoPhysicalKey(repo) === repoPhysicalKey(sourceRepo) + ) + if (duplicate) { + return { + status: 'duplicate-target', + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + sourceRepoId: sourceRepo.id, + duplicateRepoId: duplicate.id + } + } + + const targetRepo = createTargetRepo(sourceRepo, targetState, args.mode === 'copy') + const payload = createTransferPayload({ + sourceState, + sourceRepo, + targetRepo, + includeSessions: args.mode === 'move' + }) + writeProfileState(args.targetProfileId, userDataPath, applyPayloadToTarget(targetState, payload)) + if (args.mode === 'move') { + writeProfileState( + args.sourceProfileId, + userDataPath, + removeSourceRepo(sourceState, sourceRepo.id) + ) + } + return { + status: 'transferred', + mode: args.mode, + sourceProfileId: args.sourceProfileId, + targetProfileId: args.targetProfileId, + sourceRepoId: sourceRepo.id, + targetRepoId: targetRepo.id, + targetProjectId: payload.targetProjectId + } +} diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts new file mode 100644 index 00000000000..9bb4fa9fc27 --- /dev/null +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -0,0 +1,77 @@ +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { Repo, WorkspaceKey } from '../../shared/types' +import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id' + +export function repoPhysicalKey( + repo: Pick +): string { + return [ + getRepoExecutionHostId(repo), + repo.connectionId?.trim() ?? '', + normalizeRuntimePathForComparison(repo.path) + ].join('\0') +} + +export function isRepoWorktreeId(repoId: string, worktreeId: string): boolean { + return worktreeId === repoId || worktreeId.startsWith(`${repoId}${WORKTREE_ID_SEPARATOR}`) +} + +export function rekeyWorktreeId(oldRepoId: string, newRepoId: string, worktreeId: string): string { + if (worktreeId === oldRepoId) { + return newRepoId + } + const prefix = `${oldRepoId}${WORKTREE_ID_SEPARATOR}` + return worktreeId.startsWith(prefix) + ? `${newRepoId}${WORKTREE_ID_SEPARATOR}${worktreeId.slice(prefix.length)}` + : worktreeId +} + +export function rekeyWorkspaceKey( + oldRepoId: string, + newRepoId: string, + workspaceKey: WorkspaceKey +): WorkspaceKey { + const parsed = parseWorkspaceKey(workspaceKey) + if (parsed?.type !== 'worktree' || !isRepoWorktreeId(oldRepoId, parsed.worktreeId)) { + return workspaceKey + } + return worktreeWorkspaceKey(rekeyWorktreeId(oldRepoId, newRepoId, parsed.worktreeId)) +} + +export function rekeyOwnerKey( + oldRepoId: string, + newRepoId: string, + ownerKey: string +): string | null { + if (isRepoWorktreeId(oldRepoId, ownerKey)) { + return rekeyWorktreeId(oldRepoId, newRepoId, ownerKey) + } + const parsed = parseWorkspaceKey(ownerKey) + if (parsed?.type === 'worktree' && isRepoWorktreeId(oldRepoId, parsed.worktreeId)) { + return worktreeWorkspaceKey(rekeyWorktreeId(oldRepoId, newRepoId, parsed.worktreeId)) + } + return null +} + +export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean { + if (isRepoWorktreeId(repoId, ownerKey)) { + return true + } + const parsed = parseWorkspaceKey(ownerKey) + return parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId) +} + +export function removeRepoWorktreeRecord( + record: Record | undefined, + repoId: string +): Record { + const next = { ...record } + for (const key of Object.keys(next)) { + if (ownerKeyBelongsToRepo(key, repoId)) { + delete next[key] + } + } + return next +} diff --git a/src/main/orca-profiles/profile-storage-paths.ts b/src/main/orca-profiles/profile-storage-paths.ts new file mode 100644 index 00000000000..5f8dc0df241 --- /dev/null +++ b/src/main/orca-profiles/profile-storage-paths.ts @@ -0,0 +1,72 @@ +import { app } from 'electron' +import { join } from 'node:path' + +const LEGACY_DATA_FILE_NAME = 'orca-data.json' +const LEGACY_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' +const PROFILE_INDEX_FILE_NAME = 'orca-profile-index.json' +const PROFILE_DATA_FILE_NAME = 'orca-data.json' +const PROFILE_BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json' +const PROFILE_DIRECTORY_NAME = 'profiles' + +export const LEGACY_BACKUP_COUNT = 5 + +let profileUserDataPath: string | null = null + +export function initOrcaProfilePaths(): void { + profileUserDataPath = app.getPath('userData') +} + +export function getProfileUserDataPath(): string { + if (!profileUserDataPath) { + profileUserDataPath = app.getPath('userData') + } + return profileUserDataPath +} + +export function getOrcaProfileIndexPath(userDataPath = getProfileUserDataPath()): string { + return join(userDataPath, PROFILE_INDEX_FILE_NAME) +} + +export function getOrcaProfilesDirectory(userDataPath = getProfileUserDataPath()): string { + return join(userDataPath, PROFILE_DIRECTORY_NAME) +} + +export function getOrcaProfileDirectory( + profileId: string, + userDataPath = getProfileUserDataPath() +): string { + return join(getOrcaProfilesDirectory(userDataPath), profileId) +} + +export function getOrcaProfileDataFile( + profileId: string, + userDataPath = getProfileUserDataPath() +): string { + return join(getOrcaProfileDirectory(profileId, userDataPath), PROFILE_DATA_FILE_NAME) +} + +export function getOrcaProfileBrowserSessionMetaFile( + profileId: string, + userDataPath = getProfileUserDataPath() +): string { + return join( + getOrcaProfileDirectory(profileId, userDataPath), + PROFILE_BROWSER_SESSION_META_FILE_NAME + ) +} + +export function legacyDataFilePath(userDataPath: string): string { + return join(userDataPath, LEGACY_DATA_FILE_NAME) +} + +export function legacyBrowserSessionMetaPath(userDataPath: string): string { + return join(userDataPath, LEGACY_BROWSER_SESSION_META_FILE_NAME) +} + +export function legacyBackupPath(userDataPath: string, index: number): string { + return `${legacyDataFilePath(userDataPath)}.bak.${index}` +} + +export function profileBackupPath(profileDataFile: string, index: number): string { + return `${profileDataFile}.bak.${index}` +} diff --git a/src/main/orca-profiles/profile-ui-scope.test.ts b/src/main/orca-profiles/profile-ui-scope.test.ts new file mode 100644 index 00000000000..0ed1f31de0d --- /dev/null +++ b/src/main/orca-profiles/profile-ui-scope.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { isMultiProfileUiEnabled } from './profile-ui-scope' + +describe('isMultiProfileUiEnabled', () => { + it('enables multi-profile UI only when the flag is exactly "1"', () => { + expect(isMultiProfileUiEnabled({ ORCA_MULTI_PROFILE_UI: '1' })).toBe(true) + }) + + it('defaults to the single-profile account menu', () => { + expect(isMultiProfileUiEnabled({})).toBe(false) + expect(isMultiProfileUiEnabled({ ORCA_MULTI_PROFILE_UI: '0' })).toBe(false) + expect(isMultiProfileUiEnabled({ ORCA_MULTI_PROFILE_UI: 'true' })).toBe(false) + }) +}) diff --git a/src/main/orca-profiles/profile-ui-scope.ts b/src/main/orca-profiles/profile-ui-scope.ts new file mode 100644 index 00000000000..937f4af2abd --- /dev/null +++ b/src/main/orca-profiles/profile-ui-scope.ts @@ -0,0 +1,6 @@ +// Why: multi-profile management is downscoped to an accounts-first UX, not +// removed — the full switcher stays reachable behind this product-scope toggle +// (safe in packaged builds; it is not a security escape hatch). +export function isMultiProfileUiEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env.ORCA_MULTI_PROFILE_UI === '1' +} diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d4862d9dcae..366c9d4256e 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -337,6 +337,31 @@ describe('Store', () => { expect(store.getRepos()).toEqual([]) }, 15_000) + it('loads state from an explicit profile data file path', async () => { + const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') + const profileDataFile = join(profileDataDirectory, 'orca-data.json') + mkdirSync(profileDataDirectory, { recursive: true }) + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo({ id: 'legacy-root-repo', path: '/legacy' })] + }) + writeFileSync( + profileDataFile, + JSON.stringify({ + schemaVersion: 1, + repos: [makeRepo({ id: 'profile-repo', path: '/profile' })] + }), + 'utf-8' + ) + + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + const store = new Store({ dataFile: profileDataFile }) + + expect(store.getRepos().map((repo) => repo.id)).toEqual(['profile-repo']) + }, 15_000) + it('backfills project host setup compatibility records from legacy repos on load', async () => { writeDataFile({ schemaVersion: 1, @@ -502,7 +527,7 @@ describe('Store', () => { expect(settings.experimentalActivity).toBe(false) expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true) expect(settings.experimentalTerminalAttention).toBe(false) - expect(settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(settings.experimentalNewWorktreeCardStyle).toBe(false) expect(settings.floatingTerminalEnabled).toBe(true) expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true) expect(settings.notifications.customSoundPath).toBeNull() @@ -633,7 +658,7 @@ describe('Store', () => { expect(store.getUI().setupGuideSidebarDismissed).toBe(false) }) - it('defaults new worktree card style on while onboarding is open', async () => { + it('keeps new worktree card style off while onboarding is open', async () => { writeDataFile({ settings: {}, onboarding: { @@ -648,7 +673,7 @@ describe('Store', () => { const store = await createStore() - expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(true) + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) it('preserves explicit new worktree card style opt-out while onboarding is open', async () => { @@ -671,6 +696,20 @@ describe('Store', () => { expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) + it('preserves explicit new worktree card style opt-in on load', async () => { + writeDataFile({ + schemaVersion: 1, + settings: { + experimentalNewWorktreeCardStyle: true + }, + ui: {} + }) + + const store = await createStore() + + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(true) + }) + it('keeps new worktree card style off for existing users backfilled as completed', async () => { writeDataFile({ schemaVersion: 1, @@ -2988,6 +3027,16 @@ describe('Store', () => { expect(updated?.projectGroupOrder).toBe(1) }) + it('updates repo execution host identity', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1' })) + + const updated = store.updateRepo('r1', { executionHostId: 'runtime:env-1' }) + + expect(updated?.executionHostId).toBe('runtime:env-1') + expect(store.getRepo('r1')?.executionHostId).toBe('runtime:env-1') + }) + it('getRepo returns undefined for nonexistent id', async () => { const store = await createStore() expect(store.getRepo('nonexistent')).toBeUndefined() @@ -3058,6 +3107,270 @@ describe('Store', () => { expect(store.getWorktreeLineage('r2::/other')).toBeDefined() }) + // ── 6b. removeProjectForHost is host-scoped ─────────────────────────── + + it('removeProjectForHost removes only the target host row for a shared repo id', async () => { + const store = await createStore() + // Same repo id on both local and an SSH host. + store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/remote/repo', + connectionId: 'ssh-old', + executionHostId: 'ssh:ssh-old' + }) + ) + store.setWorktreeMeta('shared::/local/repo/wt', { displayName: 'local-wt', hostId: 'local' }) + store.setWorktreeMeta('shared::/remote/repo/wt', { + displayName: 'remote-wt', + hostId: 'ssh:ssh-old' + }) + + store.removeProjectForHost('shared', 'ssh:ssh-old') + + const remaining = store.getRepos().filter((r) => r.id === 'shared') + expect(remaining).toHaveLength(1) + expect(remaining[0].path).toBe('/local/repo') + // Local worktree meta survives; the SSH host's meta is pruned. + expect(store.getWorktreeMeta('shared::/local/repo/wt')).toBeDefined() + expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined() + }) + + it('removeProjectForHost prunes the SSH host meta (tagged hostId) for a shared id', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/remote/repo', + connectionId: 'ssh-old', + executionHostId: 'ssh:ssh-old' + }) + ) + // SSH worktree meta correctly carries hostId (the normal, stamped case). + store.setWorktreeMeta('shared::/remote/repo/wt', { + displayName: 'remote-wt', + hostId: 'ssh:ssh-old' + }) + // A hostId-less meta under the same id is treated as local and left behind + // (conservative: never deletes the wrong host's meta). + store.setWorktreeMeta('shared::/local/repo/wt', { displayName: 'local-wt' }) + + store.removeProjectForHost('shared', 'ssh:ssh-old') + + expect(store.getWorktreeMeta('shared::/remote/repo/wt')).toBeUndefined() + expect(store.getWorktreeMeta('shared::/local/repo/wt')).toBeDefined() + }) + + it('removeProjectForHost prunes everything when the id exists on no other host', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'only', connectionId: 'ssh-x', executionHostId: 'ssh:ssh-x' })) + store.setWorktreeMeta('only::/repo/wt', { displayName: 'wt', hostId: 'ssh:ssh-x' }) + + store.removeProjectForHost('only', 'ssh:ssh-x') + + expect(store.getRepo('only')).toBeUndefined() + expect(store.getWorktreeMeta('only::/repo/wt')).toBeUndefined() + }) + + // ── 6c. reassignSshTargetId re-adopts orphaned workspaces ───────────── + + it('reassignSshTargetId re-points repos and worktree metas onto the new id', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' })) + store.setWorktreeMeta('r1::/repo/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) + + const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + + expect(count).toBe(1) + const repo = store.getRepo('r1')! + expect(repo.connectionId).toBe('ssh-new') + expect(repo.executionHostId).toBe('ssh:ssh-new') + expect(store.getWorktreeMeta('r1::/repo/wt')!.hostId).toBe('ssh:ssh-new') + }) + + it('reassignSshTargetId leaves repos on other hosts untouched', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'local-repo', path: '/local' })) + store.addRepo( + makeRepo({ + id: 'ssh-repo', + path: '/remote', + connectionId: 'ssh-old', + executionHostId: 'ssh:ssh-old' + }) + ) + + const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + + expect(count).toBe(1) + expect(store.getRepo('local-repo')!.connectionId).toBeUndefined() + expect(store.getRepo('ssh-repo')!.connectionId).toBe('ssh-new') + }) + + it('reassignSshTargetId re-points a repo that only carries connectionId (no executionHostId)', async () => { + const store = await createStore() + // SSH repos created via addRemoteRepoFromPath leave executionHostId unset. + store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old' })) + + const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + + expect(count).toBe(1) + const repo = store.getRepo('r1')! + expect(repo.connectionId).toBe('ssh-new') + // Must not stamp an executionHostId where there wasn't one. + expect(repo.executionHostId).toBeUndefined() + }) + + it('reassignSshTargetId persists a worktree-meta-only re-point (no matching repo)', async () => { + const store = await createStore() + // A meta on the old SSH host with no corresponding repo row — the re-point + // must still be saved, not left in memory only. + store.setWorktreeMeta('r1::/remote/wt', { displayName: 'wt', hostId: 'ssh:ssh-old' }) + + const count = store.reassignSshTargetId('ssh-old', 'ssh-new') + expect(count).toBe(0) // no repo matched + store.flush() + + const reloaded = await createStore() + expect(reloaded.getWorktreeMeta('r1::/remote/wt')?.hostId).toBe('ssh:ssh-new') + }) + + it('reassignSshTargetId migrates session pty ids, reconnect list, leases, and host scope', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', connectionId: 'ssh-old', executionHostId: 'ssh:ssh-old' })) + store.setWorkspaceSession({ + activeRepoId: 'r1', + activeWorktreeId: 'r1::/wt', + activeTabId: 'tab1', + tabsByWorktree: { + 'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-2' })] + }, + terminalLayoutsByTabId: {}, + remoteSessionIdsByTabId: { tab1: 'ssh:ssh-old@@pty-2' }, + activeConnectionIdsAtShutdown: ['ssh-old'] + }) + store.upsertSshRemotePtyLease({ targetId: 'ssh-old', ptyId: 'pty-2', state: 'detached' }) + store.updateUI({ + workspaceHostScope: 'ssh:ssh-old', + visibleWorkspaceHostIds: ['local', 'ssh:ssh-old'], + workspaceHostOrder: ['ssh:ssh-old', 'local'] + }) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + store.flush() + + const reloaded = await createStore() + const session = reloaded.getWorkspaceSession() + expect(session.tabsByWorktree['r1::/wt'][0].ptyId).toBe('ssh:ssh-new@@pty-2') + expect(session.remoteSessionIdsByTabId).toEqual({ tab1: 'ssh:ssh-new@@pty-2' }) + expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-new']) + expect(reloaded.getSshRemotePtyLeases('ssh-new')).toHaveLength(1) + expect(reloaded.getSshRemotePtyLeases('ssh-old')).toHaveLength(0) + const ui = reloaded.getUI() + expect(ui.workspaceHostScope).toBe('ssh:ssh-new') + expect(ui.visibleWorkspaceHostIds).toEqual(['local', 'ssh:ssh-new']) + expect(ui.workspaceHostOrder).toEqual(['ssh:ssh-new', 'local']) + }) + + it('reassignSshTargetId re-keys a session partition stored under the old ssh host id', async () => { + const store = await createStore() + store.setWorkspaceSession( + { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: { + 'r1::/wt': [makeTerminalTab({ id: 'tab1', ptyId: 'ssh:ssh-old@@pty-9' })] + }, + terminalLayoutsByTabId: {} + }, + 'ssh:ssh-old' + ) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + store.flush() + + const reloaded = await createStore() + // Old-key partition is gone; the re-keyed one carries migrated pty ids. + expect(reloaded.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({}) + expect(reloaded.getWorkspaceSession('ssh:ssh-new').tabsByWorktree['r1::/wt'][0].ptyId).toBe( + 'ssh:ssh-new@@pty-9' + ) + }) + + it('reassignSshTargetId keeps the live partition when both host keys exist', async () => { + const store = await createStore() + const baseSession = { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + terminalLayoutsByTabId: {} + } + store.setWorkspaceSession( + { ...baseSession, tabsByWorktree: { 'r1::/dead': [] } }, + 'ssh:ssh-old' + ) + store.setWorkspaceSession( + { ...baseSession, tabsByWorktree: { 'r1::/live': [] } }, + 'ssh:ssh-new' + ) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + + expect(store.getWorkspaceSession('ssh:ssh-old').tabsByWorktree).toEqual({}) + expect(store.getWorkspaceSession('ssh:ssh-new').tabsByWorktree).toEqual({ 'r1::/live': [] }) + }) + + it('reassignSshTargetId re-points an independent provisioned host setup', async () => { + const store = await createStore() + store.addRepo({ + ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), + upstream: { owner: 'stablyai', repo: 'cloud-project' } + }) + store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'ssh:ssh-old', + setupId: 'cloud-project::ssh-old', + setupMethod: 'provisioned' + }) + + // Meta-only re-adoption (no repo pinned to the old id) must still migrate + // the provisioned setup, or new worktrees would be born on a dead host id. + store.reassignSshTargetId('ssh-old', 'ssh-new') + + const setups = store.getProjectHostSetups() + const provisioned = setups.find((entry) => entry.id === 'cloud-project::ssh-old') + expect(provisioned?.hostId).toBe('ssh:ssh-new') + }) + + it('reassignSshTargetId drops a stale setup when the new host already has one', async () => { + const store = await createStore() + store.addRepo({ + ...makeRepo({ id: 'r1', displayName: 'Cloud Project' }), + upstream: { owner: 'stablyai', repo: 'cloud-project' } + }) + store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'ssh:ssh-old', + setupId: 'setup-old', + setupMethod: 'provisioned' + }) + store.createProjectHostSetup({ + projectId: 'github:stablyai/cloud-project', + hostId: 'ssh:ssh-new', + setupId: 'setup-new', + setupMethod: 'provisioned' + }) + + store.reassignSshTargetId('ssh-old', 'ssh-new') + + const setups = store.getProjectHostSetups() + expect(setups.find((entry) => entry.id === 'setup-old')).toBeUndefined() + expect(setups.find((entry) => entry.id === 'setup-new')?.hostId).toBe('ssh:ssh-new') + }) + // ── 7. updateRepo ────────────────────────────────────────────────── it('updateRepo modifies the repo in place', async () => { @@ -4735,6 +5048,33 @@ describe('Store', () => { expect(restarted.getGitHubCache().pr['o/r#7']).toEqual({ fetchedAt: 7 }) }) + it('keeps GitHub cache sidecars scoped to explicit profile data files', async () => { + const profileADir = join(testState.dir, 'profiles', 'a') + const profileBDir = join(testState.dir, 'profiles', 'b') + const profileADataFile = join(profileADir, 'orca-data.json') + const profileBDataFile = join(profileBDir, 'orca-data.json') + mkdirSync(profileADir, { recursive: true }) + mkdirSync(profileBDir, { recursive: true }) + + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + const profileAStore = new Store({ dataFile: profileADataFile }) + profileAStore.setGitHubCache({ pr: { 'o/r#a': { fetchedAt: 10 } as never }, issue: {} }) + profileAStore.flush() + + const profileBStore = new Store({ dataFile: profileBDataFile }) + expect(profileBStore.getGitHubCache().pr['o/r#a']).toBeUndefined() + profileBStore.setGitHubCache({ pr: { 'o/r#b': { fetchedAt: 20 } as never }, issue: {} }) + profileBStore.flush() + + const restartedProfileA = new Store({ dataFile: profileADataFile }) + const restartedProfileB = new Store({ dataFile: profileBDataFile }) + expect(restartedProfileA.getGitHubCache().pr['o/r#a']).toEqual({ fetchedAt: 10 }) + expect(restartedProfileA.getGitHubCache().pr['o/r#b']).toBeUndefined() + expect(restartedProfileB.getGitHubCache().pr['o/r#b']).toEqual({ fetchedAt: 20 }) + }) + it('keeps a legacy in-file cache as the seed and strips it from disk', async () => { writeDataFile({ githubCache: { pr: { legacy: { fetchedAt: 1 } }, issue: {} } }) @@ -6106,6 +6446,49 @@ describe('Store', () => { expect(ref ? store.readTerminalScrollbackSnapshot(ref) : null).toBe('remote-scrollback') }) + it('stores terminal scrollback snapshots beside explicit profile data files', async () => { + const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') + const profileDataFile = join(profileDataDirectory, 'orca-data.json') + mkdirSync(profileDataDirectory, { recursive: true }) + + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + const store = new Store({ dataFile: profileDataFile }) + store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) + const session = makeSessionWithTerminalBuffers() + store.setWorkspaceSession({ + ...session, + tabsByWorktree: { 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] }, + terminalLayoutsByTabId: { 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] } + }) + + const ref = + store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[ + TEST_LEAF_2 + ] + expect(ref).toEqual(expect.stringMatching(/^v1-[0-9a-f]{32}$/)) + expect(existsSync(join(profileDataDirectory, 'terminal-scrollback', `${ref}.bin`))).toBe(true) + expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false) + }) + + it('reads legacy terminal scrollback snapshots for explicit profile data files', async () => { + const profileDataDirectory = join(testState.dir, 'profiles', 'local-default') + const profileDataFile = join(profileDataDirectory, 'orca-data.json') + const ref = 'v1-11111111111111111111111111111111' + const legacySnapshotDir = join(testState.dir, 'terminal-scrollback') + mkdirSync(profileDataDirectory, { recursive: true }) + mkdirSync(legacySnapshotDir, { recursive: true }) + writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8') + + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + const store = new Store({ dataFile: profileDataFile }) + + expect(store.readTerminalScrollbackSnapshot(ref)).toBe('legacy-scrollback') + }) + it('caps oversized browser history when setting workspace session', async () => { const store = await createStore() const oversizedSession = makeSessionWithBrowserHistory(500) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 157ab31f011..5ef81f071bd 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -82,6 +82,7 @@ import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files' import { hardenExistingSecureFile } from '../shared/secure-file' import { LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, + type RemovedSshTargetTombstone, type SshRemotePtyLease, type SshTarget } from '../shared/ssh-types' @@ -108,9 +109,14 @@ import { normalizeExecutionHostOrder, normalizeExecutionHostId, normalizeVisibleExecutionHostIds, + toSshExecutionHostId, type ExecutionHostId } from '../shared/execution-host' import { toRelaySshPtyId } from './providers/ssh-pty-id' +import { + migrateUiHostScopeSshTargetId, + migrateWorkspaceSessionSshTargetId +} from './ssh/ssh-target-id-migration' import { isWslUncPath } from '../shared/wsl-paths' import { isTerminalLeafId, @@ -213,8 +219,10 @@ import { import { collectTerminalScrollbackSnapshotRefs, deleteTerminalScrollbackSnapshotSync, + getProfileTerminalScrollbackSnapshotRoot, migrateWorkspaceSessionTerminalScrollbackSnapshots, - readTerminalScrollbackSnapshotSync + readTerminalScrollbackSnapshotSync, + type TerminalScrollbackSnapshotStorage } from './terminal-scrollback-snapshots' import { track } from './telemetry/client' import { getCohortAtEmit } from './telemetry/cohort-classifier' @@ -336,8 +344,8 @@ function getDataFile(): string { // the content-hash guard by design). It lives in memory during the session // and is snapshotted here best-effort at quit so PR/issue badges still paint // instantly on the next launch. Loss of this file costs nothing. -function getGithubCacheFile(): string { - return join(dirname(getDataFile()), 'orca-github-cache.json') +function getGithubCacheFile(dataFile = getDataFile()): string { + return join(dirname(dataFile), 'orca-github-cache.json') } // Why: worktrees deleted outside Orca (git CLI worktree remove, rm -rf, @@ -416,9 +424,9 @@ function gcStaleWorktreeMeta(state: PersistedState): number { return removed } -function readGithubCacheSnapshot(): PersistedState['githubCache'] | null { +function readGithubCacheSnapshot(dataFile: string): PersistedState['githubCache'] | null { try { - const parsed = JSON.parse(readFileSync(getGithubCacheFile(), 'utf-8')) as unknown + const parsed = JSON.parse(readFileSync(getGithubCacheFile(dataFile), 'utf-8')) as unknown const isPlainRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) if ( @@ -1342,10 +1350,6 @@ function resolveSetupGuideSidebarDismissedOnLoad( return onboarding.closedAt !== null || persistedDismissed === true } -function shouldDefaultNewWorktreeCardStyleOn(onboarding: OnboardingState): boolean { - return onboarding.closedAt === null -} - // Why: read a settings field that was removed from GlobalSettings but can // still exist on disk. One-shot use for the inline-agents migration. function readDeprecatedExperimentFlag(parsed: PersistedState | undefined): boolean { @@ -2181,6 +2185,10 @@ function remapAcknowledgedAgentPaneKeys( // handful of Claude sessions a daemon can realistically keep alive. const MAX_CLAUDE_LIVE_PTY_SESSION_IDS = 200 +// Why: bound the removed-SSH-target history so remove/re-add churn can't grow +// the state file without limit. Re-adoption only needs recent removals. +const MAX_REMOVED_SSH_TARGET_TOMBSTONES = 50 + function normalizeClaudeLivePtySessionIds(value: unknown): string[] { if (!Array.isArray(value)) { return [] @@ -2564,7 +2572,8 @@ function backfillFolderScopeConnectionIds(state: PersistedState): { function deleteRemovedTerminalScrollbackSnapshots( prior: WorkspaceSessionState | undefined, - next: WorkspaceSessionState + next: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage ): void { if (!prior) { return @@ -2572,16 +2581,26 @@ function deleteRemovedTerminalScrollbackSnapshots( const nextRefs = collectTerminalScrollbackSnapshotRefs(next) for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { if (!nextRefs.has(ref)) { - deleteTerminalScrollbackSnapshotSync(ref) + deleteTerminalScrollbackSnapshotSync(ref, storage) } } } +export type StoreOptions = { + dataFile?: string +} + export class Store { private state: PersistedState + private readonly dataFile: string + private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage private writeTimer: ReturnType | null = null private pendingWrite: Promise | null = null private writeGeneration = 0 + // Why: after a profile transfer rewrites this store's file on disk behind + // its back, the stale in-memory state must never be persisted again — a + // late sync flush before the relaunch would resurrect the moved project. + private writesFrozen = false // Why: hash of the plaintext state as of the last successful write. Saves // triggered by mutations that net out to identical state skip the full // 1.6MB pretty-print + tmp write + rename. Hashing plaintext (not the @@ -2601,7 +2620,16 @@ export class Store { >() private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() - constructor() { + constructor(options: StoreOptions = {}) { + // Why: profile switching creates more than one possible state path. Capture + // the path per Store instance so late async writes cannot follow a global path. + this.dataFile = options.dataFile ?? getDataFile() + const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) + const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) + this.terminalScrollbackSnapshotStorage = { + snapshotRoot: profileSnapshotRoot, + fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot + } const loaded = this.load() const normalized = normalizePersistedPaneIdentityState(loaded) this.state = normalized.state @@ -2810,7 +2838,7 @@ export class Store { // would be absent on every pre-telemetry install and misclassify existing // users as fresh, flipping them to default-on in violation of the // social contract we installed them under. - const dataFile = getDataFile() + const dataFile = this.dataFile const fileExistedOnLoad = existsSync(dataFile) logPersistenceStartupMilestone('persistence-load-start', { fileExists: fileExistedOnLoad @@ -3035,16 +3063,6 @@ export class Store { if (!parsed.onboarding) { this.loadNeedsSave = true } - const defaultNewWorktreeCardStyle = - shouldDefaultNewWorktreeCardStyleOn(normalizedOnboarding) - const migratedExperimentalNewWorktreeCardStyle = - parsed.settings?.experimentalNewWorktreeCardStyle ?? defaultNewWorktreeCardStyle - if ( - parsed.settings?.experimentalNewWorktreeCardStyle === undefined && - defaultNewWorktreeCardStyle - ) { - this.loadNeedsSave = true - } const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) const loadedCompactWorktreeCards = parsed.settings?.compactWorktreeCards ?? @@ -3076,6 +3094,12 @@ export class Store { ), settings: { ...defaults.settings, + // Why (#7977): a persisted experimentalNewWorktreeCardStyle:true is + // kept even though the default is now false. The v1.4.130 open- + // onboarding auto-default wrote the same plain boolean as a real + // opt-in, so a rollback migration would also revert genuine opt-ins; + // product intent was only to change the default, and the setting + // stays user-toggleable. ...stripLegacyTerminalScrollbackBytes(parsed.settings), // Why: v1.3.42 renamed the cosmetic sidekick setting to pet. Carry // the old persisted flag forward once so enabled users don't lose it. @@ -3098,9 +3122,6 @@ export class Store { ...migratedTerminalTuiScrollSensitivity.settings, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, - // Why: open first-run onboarding is the local fresh-install signal; - // closed/backfilled onboarding identifies existing profiles. - experimentalNewWorktreeCardStyle: migratedExperimentalNewWorktreeCardStyle, // Why: compact worktree cards graduated from Experimental; preserve // the old opt-in for profiles written during the rollout. compactWorktreeCards: loadedCompactWorktreeCards, @@ -3339,6 +3360,11 @@ export class Store { defaults.workspaceSession ), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), + deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) + ? parsed.deletedSshConfigAliases.filter( + (alias): alias is string => typeof alias === 'string' + ) + : [], sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) .map(normalizeSshRemotePtyLease) .filter((lease): lease is SshRemotePtyLease => lease !== null), @@ -3380,24 +3406,16 @@ export class Store { } if (result === null) { - const defaults = getDefaultPersistedState(homedir()) - const isFreshDefaultProfile = - !fileExistedOnLoad && shouldDefaultNewWorktreeCardStyleOn(defaults.onboarding) - result = { - ...defaults, - settings: { - ...defaults.settings, - // Why: a corrupt existing data file also falls back to defaults; only - // the absent-file path is a true fresh install. - experimentalNewWorktreeCardStyle: isFreshDefaultProfile - } - } + result = getDefaultPersistedState(homedir()) } const workspaceSession = pruneWorkspaceSessionBrowserHistory( pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) ) - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(workspaceSession) + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + workspaceSession, + this.terminalScrollbackSnapshotStorage + ) if (migratedScrollback.changed) { this.loadNeedsSave = true } @@ -3453,7 +3471,7 @@ export class Store { // poll refresh happens this session — the seed survives the migration. this.githubCacheDirty = true } else { - migrated.githubCache = readGithubCacheSnapshot() ?? migrated.githubCache + migrated.githubCache = readGithubCacheSnapshot(this.dataFile) ?? migrated.githubCache } logPersistenceStartupMilestone('persistence-load-done', { @@ -3601,6 +3619,9 @@ export class Store { // Why: async writes avoid blocking the main Electron thread on every // debounced save during active use. private async writeToDiskAsync(): Promise { + if (this.writesFrozen) { + return + } const gen = this.writeGeneration const stateHash = this.computeStateHash() // Why: a mutation burst that nets out to already-persisted state (or a @@ -3609,7 +3630,7 @@ export class Store { return } const payload = this.buildStateToSave() - const dataFile = getDataFile() + const dataFile = this.dataFile const dir = dirname(dataFile) await mkdir(dir, { recursive: true }).catch(() => {}) const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` @@ -3654,6 +3675,9 @@ export class Store { // Why: synchronous variant kept only for flush() at shutdown, where the // process may exit before an async write completes. private writeToDiskSync(opts: { force?: boolean } = {}): void { + if (this.writesFrozen) { + return + } const stateHash = this.computeStateHash() // Why: skipping is safe under flushOrThrow's durability contract — a // matching hash means this exact state is already the file's content. @@ -3663,7 +3687,7 @@ export class Store { if (!opts.force && stateHash === this.lastWrittenStateHash) { return } - const dataFile = getDataFile() + const dataFile = this.dataFile const dir = dirname(dataFile) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }) @@ -4167,30 +4191,84 @@ export class Store { // Why: presets are repo-scoped, so removing the repo means the presets // can never be referenced again — drop them with the parent. delete this.state.sparsePresetsByRepo[id] - // Clean up worktree meta for this repo + this.pruneWorktreeStateForRepo(id, null) + this.scheduleSave() + } + + // Why: the same repo id can exist on more than one execution host (local, an + // SSH target, a re-added SSH target). Forgetting one host's copy must remove + // only that host's repo row and worktree metadata — never the local or + // another host's records that happen to share the id. + removeProjectForHost(id: string, hostId: ExecutionHostId): void { + this.state.repos = this.state.repos.filter( + (r) => !(r.id === id && getRepoExecutionHostId(r) === hostId) + ) + const idStillPresent = this.state.repos.some((r) => r.id === id) + // Why: presets are repo-id-scoped (not host-scoped); only drop them once the + // last host's copy of this repo is gone, or a surviving host loses its presets. + if (!idStillPresent) { + delete this.state.sparsePresetsByRepo[id] + } + this.syncProjectHostSetupCompatibilityState() + // Why: if the id survives on another host, prune only this host's worktree + // metas; otherwise prune everything for the id (matches removeProject). + this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null) + this.scheduleSave() + } + + // Clean up worktree meta, lineage, and workspace lineage for a repo id. + // When hostId is null, prune all of the repo's entries; when a hostId is + // given, prune only entries whose meta.hostId resolves to that host (a + // missing hostId is treated as local). + private pruneWorktreeStateForRepo(id: string, hostId: ExecutionHostId | null): void { const prefix = `${id}::` + // Why: snapshot host membership up front. Lineage pruning below checks the + // meta.hostId of worktree keys that may already have been deleted from + // worktreeMeta in the first loop, so reading hostId live would misclassify + // an SSH worktree as local once its meta is gone. + const hostMembership = new Map() + const belongsToHost = (key: string): boolean => { + if (!key.startsWith(prefix)) { + return false + } + if (hostId === null) { + return true + } + const cached = hostMembership.get(key) + if (cached !== undefined) { + return cached + } + // Why default to local: worktree metas created on/after host-ownership + // stamping carry hostId. A metas without it predates that and is treated as + // local, so a host-scoped (non-local) prune conservatively leaves it — it + // may leak a stale entry for a legacy SSH worktree sharing a repo id with a + // local repo, but it never deletes the wrong host's live meta. + const metaHostId = this.state.worktreeMeta[key]?.hostId ?? LOCAL_EXECUTION_HOST_ID + const result = metaHostId === hostId + hostMembership.set(key, result) + return result + } for (const key of Object.keys(this.state.worktreeMeta)) { - if (key.startsWith(prefix)) { + if (belongsToHost(key)) { delete this.state.worktreeMeta[key] } } for (const [childId, lineage] of Object.entries(this.state.worktreeLineageById)) { - if (childId.startsWith(prefix) || lineage.parentWorktreeId.startsWith(prefix)) { + if (belongsToHost(childId) || belongsToHost(lineage.parentWorktreeId)) { delete this.state.worktreeLineageById[childId] } } for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { const childScope = parseWorkspaceKey(childKey) const parentScope = parseWorkspaceKey(lineage.parentWorkspaceKey) - if (childScope?.type === 'worktree' && childScope.worktreeId.startsWith(prefix)) { + if (childScope?.type === 'worktree' && belongsToHost(childScope.worktreeId)) { delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] continue } - if (parentScope?.type === 'worktree' && parentScope.worktreeId.startsWith(prefix)) { + if (parentScope?.type === 'worktree' && belongsToHost(parentScope.worktreeId)) { delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] } } - this.scheduleSave() } updateRepo( @@ -4207,6 +4285,7 @@ export class Store { | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' + | 'executionHostId' | 'symlinkPaths' | 'issueSourcePreference' | 'forkSyncMode' @@ -5463,7 +5542,7 @@ export class Store { } readTerminalScrollbackSnapshot(ref: string): string | null { - return readTerminalScrollbackSnapshotSync(ref) + return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage) } /** Resolve the worktree a terminal tab belongs to, from the session's @@ -5637,9 +5716,12 @@ export class Store { } } session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots(session) + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + session, + this.terminalScrollbackSnapshotStorage + ) session = migratedScrollback.session - deleteRemovedTerminalScrollbackSnapshots(prior, session) + deleteRemovedTerminalScrollbackSnapshots(prior, session, this.terminalScrollbackSnapshotStorage) this.state.workspaceSession = session this.scheduleSave() } @@ -5973,6 +6055,168 @@ export class Store { this.scheduleSave() } + getDeletedSshConfigAliases(): string[] { + return [...(this.state.deletedSshConfigAliases ?? [])] + } + + addDeletedSshConfigAlias(alias: string): void { + this.state.deletedSshConfigAliases ??= [] + if (!this.state.deletedSshConfigAliases.includes(alias)) { + this.state.deletedSshConfigAliases.push(alias) + this.scheduleSave() + } + } + + removeDeletedSshConfigAlias(alias: string): void { + const current = this.state.deletedSshConfigAliases + if (!current || !current.includes(alias)) { + return + } + this.state.deletedSshConfigAliases = current.filter((entry) => entry !== alias) + this.scheduleSave() + } + + clearDeletedSshConfigAliases(): void { + if (this.state.deletedSshConfigAliases && this.state.deletedSshConfigAliases.length > 0) { + this.state.deletedSshConfigAliases = [] + this.scheduleSave() + } + } + + getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { + return [...(this.state.removedSshTargetTombstones ?? [])] + } + + addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { + const existing = this.state.removedSshTargetTombstones ?? [] + // Why: dedupe by oldTargetId so a remove/re-remove of the same id can't + // stack duplicate tombstones. Newest wins. + const filtered = existing.filter((t) => t.oldTargetId !== tombstone.oldTargetId) + // Cap the history so pathological churn can't grow the state file unbounded. + this.state.removedSshTargetTombstones = [...filtered, tombstone].slice( + -MAX_REMOVED_SSH_TARGET_TOMBSTONES + ) + this.scheduleSave() + } + + removeRemovedSshTargetTombstone(oldTargetId: string): void { + const existing = this.state.removedSshTargetTombstones + if (!existing?.some((t) => t.oldTargetId === oldTargetId)) { + return + } + this.state.removedSshTargetTombstones = existing.filter((t) => t.oldTargetId !== oldTargetId) + this.scheduleSave() + } + + /** + * Re-point every repo and worktree meta pinned to a removed SSH target id + * onto a re-added target's id, so orphaned workspaces reattach to the live + * host instead of remaining un-removable ghosts. Returns the number of repos + * re-pointed (0 when nothing referenced the old id). + */ + reassignSshTargetId(oldTargetId: string, newTargetId: string): number { + if (oldTargetId === newTargetId) { + return 0 + } + const oldHostId = toSshExecutionHostId(oldTargetId) + const newHostId = toSshExecutionHostId(newTargetId) + let repoCount = 0 + for (const repo of this.state.repos) { + const matchesConnection = repo.connectionId === oldTargetId + const matchesHost = repo.executionHostId === oldHostId + if (!matchesConnection && !matchesHost) { + continue + } + if (matchesConnection) { + repo.connectionId = newTargetId + } + // Why: only rewrite executionHostId when it was actually set to the old + // SSH host. SSH repos created via addRemoteRepoFromPath leave it unset and + // derive the host from connectionId, so we must not stamp a value where + // there wasn't one. + if (matchesHost) { + repo.executionHostId = newHostId + } + repoCount++ + } + // Re-point worktree metas whose hostId pointed at the old SSH host. + let metaChanged = false + for (const meta of Object.values(this.state.worktreeMeta)) { + if (meta.hostId === oldHostId) { + meta.hostId = newHostId + metaChanged = true + } + } + // Why: the old id also survives in session pty ids, the startup reconnect + // list, sleeping-agent records, host setups, host-scope UI, and pty leases; + // any un-migrated carrier later throws `SSH target not found` (STA-1468). + let carrierChanged = migrateWorkspaceSessionSshTargetId( + this.state.workspaceSession, + oldTargetId, + newTargetId + ) + for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) { + if (session && migrateWorkspaceSessionSshTargetId(session, oldTargetId, newTargetId)) { + carrierChanged = true + } + } + // Why: partitions are read by host id, so one stored under the removed id + // would be orphaned. No writer keys partitions by ssh host today, but the + // schema tolerates it — re-key rather than strand it. If the new key + // already has a partition, that one is live; drop the dead old one. + const partitions = this.state.workspaceSessionsByHostId + const oldPartition = partitions?.[oldHostId] + if (partitions && oldPartition) { + delete partitions[oldHostId] + partitions[newHostId] ??= oldPartition + carrierChanged = true + } + if (migrateUiHostScopeSshTargetId(this.state.ui, oldTargetId, newTargetId)) { + carrierChanged = true + } + for (const lease of this.state.sshRemotePtyLeases ?? []) { + if (lease.targetId === oldTargetId) { + lease.targetId = newTargetId + carrierChanged = true + } + } + let setupsChanged = false + const keptSetups: ProjectHostSetup[] = [] + for (const setup of this.state.projectHostSetups) { + if (setup.hostId !== oldHostId) { + keptSetups.push(setup) + continue + } + const duplicate = this.state.projectHostSetups.some( + (entry) => + entry !== setup && entry.projectId === setup.projectId && entry.hostId === newHostId + ) + // Why: a setup already exists for the re-added host — the old row is a + // stale ghost that would violate the (projectId, hostId) uniqueness. + if (duplicate) { + setupsChanged = true + continue + } + setup.hostId = newHostId + setup.updatedAt = Date.now() + keptSetups.push(setup) + setupsChanged = true + } + if (setupsChanged) { + this.state.projectHostSetups = keptSetups + } + // Why: repo-row and host-setup rewrites can affect host-setup compatibility, + // but meta-only rewrites cannot — keep that sync under this gate. Persist + // whenever anything changed, so partial re-points aren't lost on quit. + if (repoCount > 0 || setupsChanged) { + this.syncProjectHostSetupCompatibilityState() + } + if (repoCount > 0 || metaChanged || carrierChanged || setupsChanged) { + this.scheduleSave() + } + return repoCount + } + // ── SSH Remote PTY Leases ────────────────────────────────────────── getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { @@ -6182,13 +6426,24 @@ export class Store { this.writeGithubCacheSnapshotSync() } + // Why: called after a project move rewrote this store's data file directly. + // From that point until relaunch, the in-memory state is stale and any + // write (debounced, sync, or shutdown flush) would undo the transfer. + freezeWrites(): void { + this.writesFrozen = true + if (this.writeTimer) { + clearTimeout(this.writeTimer) + this.writeTimer = null + } + } + // Why best-effort: the sidecar is a refetchable cache — a failed write only // costs a cold badge paint on next launch, never data. private writeGithubCacheSnapshotSync(): void { if (!this.githubCacheDirty) { return } - const cacheFile = getGithubCacheFile() + const cacheFile = getGithubCacheFile(this.dataFile) const tmpFile = `${cacheFile}.${process.pid}.tmp` try { writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts new file mode 100644 index 00000000000..f6532c94c16 --- /dev/null +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -0,0 +1,316 @@ +import { runInNewContext } from 'node:vm' +import ts from 'typescript' +import { describe, expect, it, vi } from 'vitest' + +import { getPiAgentStatusExtensionSource } from './agent-status-extension-source' + +type HookHandler = (event?: unknown) => Promise | void + +type FakeCurlChild = { + on: ReturnType + stdin: { + on: ReturnType + end: ReturnType + } +} + +type Harness = { + fetchMock: ReturnType + spawnMock: ReturnType + spawnedChildren: FakeCurlChild[] + fsMock: { + existsSync: ReturnType + readFileSync: ReturnType + } + handlers: Record + callHook: (name: string, event?: unknown) => Promise +} + +const BASE_ENV = { + ORCA_PANE_KEY: 'pane-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'tree-1', + ORCA_AGENT_HOOK_PORT: '4321', + ORCA_AGENT_HOOK_TOKEN: 'token-1', + ORCA_AGENT_HOOK_ENV: 'env-1', + ORCA_AGENT_HOOK_VERSION: '1.2.3' +} satisfies Record + +function createHarness(args: { + kind: 'pi' | 'omp' + env?: Record + title?: string + argv?: string[] + existsSync?: (path: string) => boolean + readFileSync?: (path: string, encoding: string) => string + fetchImpl?: (...params: Parameters) => Promise +}): Harness { + const fetchMock = vi.fn( + args.fetchImpl ?? + (async () => ({ + ok: true + })) + ) + + const spawnedChildren: FakeCurlChild[] = [] + const spawnMock = vi.fn(() => { + const child: FakeCurlChild = { + on: vi.fn(), + stdin: { + on: vi.fn(), + end: vi.fn() + } + } + spawnedChildren.push(child) + return child + }) + + const fsMock = { + existsSync: vi.fn(args.existsSync ?? (() => false)), + readFileSync: vi.fn( + args.readFileSync ?? + ((path: string) => { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }) + ) + } + + const module = { + exports: {} as { default?: (pi: { on: (name: string, handler: HookHandler) => void }) => void } + } + const requireMock = vi.fn((specifier: string) => { + if (specifier === 'fs') { + return fsMock + } + if (specifier === 'child_process') { + return { spawn: spawnMock } + } + throw new Error(`unexpected require(${specifier})`) + }) + + const processMock = { + env: { + ...BASE_ENV, + ...args.env + }, + title: args.title ?? 'node', + argv: args.argv ?? ['node', '/usr/bin/orca'] + } + + const context = { + module, + exports: module.exports, + require: requireMock, + process: processMock, + fetch: fetchMock, + console: { + warn: vi.fn(), + error: vi.fn(), + log: vi.fn() + }, + Promise, + Buffer, + URL, + setTimeout, + clearTimeout + } as Record + context.globalThis = context + + const source = getPiAgentStatusExtensionSource(args.kind) + const output = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020 + } + }).outputText + runInNewContext(output, context) + + const register = module.exports.default + if (!register) { + throw new Error('expected default export from generated source') + } + + const handlers: Record = {} + register({ + on(name: string, handler: HookHandler) { + handlers[name] = handler + } + }) + + return { + fetchMock, + spawnMock, + spawnedChildren, + fsMock, + handlers, + callHook: async (name, event) => { + await handlers[name]?.(event) + } + } +} + +describe('getPiAgentStatusExtensionSource', () => { + it('routes an OMP executable through /hook/omp', async () => { + const harness = createHarness({ + kind: 'pi', + title: 'omp', + existsSync: () => false + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.fetchMock.mock.calls[0]?.[0]).toBe('http://127.0.0.1:4321/hook/omp') + expect(harness.spawnMock).not.toHaveBeenCalled() + }) + + it('keeps native fetch as the only path even when the runtime looks like WSL', async () => { + const harness = createHarness({ + kind: 'omp', + env: { WSL_DISTRO_NAME: 'Ubuntu' }, + existsSync: () => true + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.spawnMock).not.toHaveBeenCalled() + }) + + it('falls back to Windows curl from WSL when fetch fails', async () => { + const harness = createHarness({ + kind: 'omp', + env: { WSL_DISTRO_NAME: 'Ubuntu' }, + existsSync: (path) => path === '/mnt/c/Windows/System32/curl.exe', + fetchImpl: vi.fn(async () => { + throw new Error('loopback unreachable') + }) + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.spawnMock).toHaveBeenCalledTimes(1) + + const [command, args, options] = harness.spawnMock.mock.calls[0] ?? [] + expect(command).toBe('/mnt/c/Windows/System32/curl.exe') + expect(args).toEqual([ + '-sS', + '--connect-timeout', + '3', + '--max-time', + '10', + '--noproxy', + '127.0.0.1', + '-o', + 'NUL', + '-X', + 'POST', + '-H', + 'Content-Type: application/json', + '-H', + 'X-Orca-Agent-Hook-Token: token-1', + '--data-binary', + '@-', + 'http://127.0.0.1:4321/hook/omp' + ]) + // Why: delivery must be fire-and-forget off the pi event loop — no + // blocking wait — with the payload fed via stdin, never argv. + expect(options).toEqual({ stdio: ['pipe', 'ignore', 'ignore'] }) + const child = harness.spawnedChildren[0] + expect(child?.on).toHaveBeenCalledWith('error', expect.any(Function)) + expect(child?.stdin.on).toHaveBeenCalledWith('error', expect.any(Function)) + expect(child?.stdin.end).toHaveBeenCalledWith( + JSON.stringify({ + paneKey: 'pane-1', + launchToken: 'launch-1', + tabId: 'tab-1', + worktreeId: 'tree-1', + env: 'env-1', + version: '1.2.3', + payload: { hook_event_name: 'agent_start' } + }) + ) + }) + + it('probes WSL evidence and the curl path once per process', async () => { + const harness = createHarness({ + kind: 'omp', + existsSync: (path) => path === '/mnt/c/Windows/System32/curl.exe', + readFileSync: (path) => { + if (path === '/proc/sys/kernel/osrelease') { + return 'microsoft-standard-WSL2' + } + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }, + fetchImpl: vi.fn(async () => { + throw new Error('loopback unreachable') + }) + }) + + await harness.callHook('agent_start') + await harness.callHook('agent_end') + + expect(harness.spawnMock).toHaveBeenCalledTimes(2) + // Why: WSL-ness and curl.exe presence are process-lifetime constants; + // the per-event failure path must not re-probe /proc or /mnt/c. + const procReads = harness.fsMock.readFileSync.mock.calls.filter(([path]) => + String(path).startsWith('/proc/') + ) + expect(procReads).toHaveLength(1) + expect(harness.fsMock.existsSync).toHaveBeenCalledTimes(1) + }) + + it('stays fail-open on ordinary Linux', async () => { + const harness = createHarness({ + kind: 'omp', + existsSync: () => true, + fetchImpl: vi.fn(async () => { + throw new Error('loopback unreachable') + }) + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.spawnMock).not.toHaveBeenCalled() + }) + + it('does not treat WSLENV alone as WSL evidence', async () => { + const harness = createHarness({ + kind: 'omp', + env: { WSLENV: 'FOO/u' }, + existsSync: () => true, + readFileSync: (path) => { + if (path === '/proc/sys/kernel/osrelease' || path === '/proc/version') { + return 'Linux 6.8.0 generic' + } + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }, + fetchImpl: vi.fn(async () => { + throw new Error('loopback unreachable') + }) + }) + + await harness.callHook('agent_start') + + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.spawnMock).not.toHaveBeenCalled() + }) + + it('remains fail-open when the Windows curl bridge is missing', async () => { + const harness = createHarness({ + kind: 'omp', + env: { WSL_DISTRO_NAME: 'Ubuntu' }, + existsSync: () => false, + fetchImpl: vi.fn(async () => { + throw new Error('loopback unreachable') + }) + }) + + await expect(harness.callHook('agent_start')).resolves.toBeUndefined() + expect(harness.fetchMock).toHaveBeenCalledTimes(1) + expect(harness.spawnMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index 48172dfbf0c..a607199e81d 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -130,6 +130,88 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' } catch {', ' // Why: status reporting must never fail the pi run just because Orca', ' // is unavailable or the loopback request failed (e.g. Orca restart).', + ' if (!isWslRuntime()) return', + ' postViaWindowsCurl(url, coords, body)', + ' }', + '}', + '', + '// Why: WSL-ness and curl.exe presence cannot change within a process', + '// lifetime; re-probing /proc and /mnt/c on every failed event would add', + '// filesystem work to the per-event path.', + 'let cachedIsWslRuntime: boolean | null = null', + 'let cachedWindowsCurlPath: string | null | undefined', + '', + 'function isWslRuntime(): boolean {', + ' if (cachedIsWslRuntime !== null) return cachedIsWslRuntime', + ' cachedIsWslRuntime = detectWslRuntime()', + ' return cachedIsWslRuntime', + '}', + '', + 'function detectWslRuntime(): boolean {', + ' if (process.env.WSL_DISTRO_NAME) return true', + ' try {', + " const fs = require('fs')", + " for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {", + ' try {', + " const contents = String(fs.readFileSync(path, 'utf8'))", + ' if (/microsoft|wsl/i.test(contents)) return true', + ' } catch {', + ' // Why: probe the next runtime hint when a proc file is absent or unreadable.', + ' }', + ' }', + ' } catch {', + ' return false', + ' }', + ' return false', + '}', + '', + 'function resolveWindowsCurlPath(): string | null {', + ' if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath', + ' try {', + " const fs = require('fs')", + " const curlPath = '/mnt/c/Windows/System32/curl.exe'", + ' cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null', + ' } catch {', + ' cachedWindowsCurlPath = null', + ' }', + ' return cachedWindowsCurlPath', + '}', + '', + '// Why: WSL loopback is not the Windows loopback, so a WSL-side POST cannot', + '// reach Orca. curl.exe runs on the Windows side, where 127.0.0.1 IS the', + '// listener Orca binds. Fire-and-forget: blocking on the spawn would stall', + '// the pi event loop (and the TUI) on every hook event.', + 'function postViaWindowsCurl(url: string, coords: { token: string }, body: string): void {', + ' const curlPath = resolveWindowsCurlPath()', + ' if (!curlPath) return', + ' try {', + " const { spawn } = require('child_process')", + ' const child = spawn(', + ' curlPath,', + ' [', + " '-sS',", + ' // Why: the spawn is detached from the event loop, so these bounds', + ' // size a background process, not TUI latency. WSL->Win32 interop', + ' // connects can exceed 0.5s on loaded machines (observed 3/3 drops', + ' // to a healthy listener); size for delivery, not snappiness.', + " '--connect-timeout', '3',", + " '--max-time', '10',", + " '--noproxy', '127.0.0.1',", + " '-o', 'NUL',", + " '-X', 'POST',", + " '-H', 'Content-Type: application/json',", + " '-H', `X-Orca-Agent-Hook-Token: ${coords.token}`,", + " '--data-binary', '@-',", + ' url', + ' ],', + " { stdio: ['pipe', 'ignore', 'ignore'] }", + ' )', + " child.on('error', () => {})", + " child.stdin.on('error', () => {})", + ' child.stdin.end(body)', + ' } catch {', + ' // Why: the bridge is best-effort; a failed spawn must not surface', + ' // inside the pi TUI.', ' }', '}', '', diff --git a/src/main/pi/legacy-omp-overlay-migration.ts b/src/main/pi/legacy-omp-overlay-migration.ts new file mode 100644 index 00000000000..88baf78ea9f --- /dev/null +++ b/src/main/pi/legacy-omp-overlay-migration.ts @@ -0,0 +1,242 @@ +import { cpSync, lstatSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs' +import type { Dirent, Stats } from 'node:fs' +import { dirname, join } from 'node:path' +import { ORCA_PI_AGENT_STATUS_EXTENSION_FILE } from './agent-status-extension-source' +import { ORCA_PI_PREFILL_EXTENSION_FILE } from './prefill-extension-source' +import { ORCA_PI_EXTENSION_FILE } from './titlebar-extension-source' +import { isSafeDescendCandidate } from '../pty/overlay-mirror' + +const LEGACY_PI_OVERLAY_MANIFEST_FILE = '.orca-pi-overlay-manifest.json' +const LEGACY_OMP_OVERLAY_MIGRATION_MARKER_FILE = '.orca-omp-overlay-migration-complete' +const PI_AGENT_SETTINGS_FILE = 'settings.json' +const SQLITE_DATABASE_EXTENSION = '.db' +const SQLITE_SIDECAR_SUFFIXES = ['-wal', '-shm', '-journal'] as const +const MANAGED_EXTENSION_FILES = new Set([ + ORCA_PI_EXTENSION_FILE, + ORCA_PI_PREFILL_EXTENSION_FILE, + ORCA_PI_AGENT_STATUS_EXTENSION_FILE +]) + +type DeferredSidecar = { + baseTargetPath: string + entry: Dirent + nextSegments: string[] +} + +function getPathStats(path: string): Stats | undefined { + try { + return lstatSync(path) + } catch { + return undefined + } +} + +function getSqliteSidecarBaseName(name: string): string | undefined { + for (const suffix of SQLITE_SIDECAR_SUFFIXES) { + if (!name.endsWith(suffix)) { + continue + } + const baseName = name.slice(0, -suffix.length) + return baseName.endsWith(SQLITE_DATABASE_EXTENSION) ? baseName : undefined + } + return undefined +} + +function shouldSkipLegacyOverlayEntry(pathSegments: string[]): boolean { + const name = pathSegments.at(-1) + if (!name) { + return true + } + if (pathSegments.length === 1) { + return ( + name === LEGACY_PI_OVERLAY_MANIFEST_FILE || + name === LEGACY_OMP_OVERLAY_MIGRATION_MARKER_FILE || + name === PI_AGENT_SETTINGS_FILE + ) + } + return ( + pathSegments.length === 2 && + pathSegments[0] === 'extensions' && + MANAGED_EXTENSION_FILES.has(name) + ) +} + +function copyLegacyFile( + overlayPath: string, + targetPath: string, + copiedFilePaths: Set +): boolean { + if (getPathStats(targetPath)) { + return true + } + try { + mkdirSync(dirname(targetPath), { recursive: true }) + cpSync(overlayPath, targetPath, { + errorOnExist: true, + force: false, + preserveTimestamps: true + }) + copiedFilePaths.add(targetPath) + return true + } catch { + return false + } +} + +function removeCopiedFiles(paths: string[], copiedFilePaths: Set): void { + for (const path of paths) { + if (!copiedFilePaths.delete(path)) { + continue + } + try { + unlinkSync(path) + } catch { + // Best-effort: withholding the marker lets later spawns retry. + } + } +} + +function copyDeferredSidecars( + overlayDir: string, + sourceAgentDir: string, + copiedFilePaths: Set, + deferredSidecars: DeferredSidecar[] +): boolean { + let completed = true + const copiedSidecarsByBase = new Map() + for (const { baseTargetPath, entry, nextSegments } of deferredSidecars) { + if (!copiedFilePaths.has(baseTargetPath)) { + continue + } + + const overlayPath = join(overlayDir, entry.name) + const targetPath = join(sourceAgentDir, ...nextSegments) + const stats = getPathStats(overlayPath) + if (!stats) { + removeCopiedFiles( + [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], + copiedFilePaths + ) + completed = false + continue + } + if (stats.isSymbolicLink() || !stats.isFile()) { + continue + } + const wasCopied = copiedFilePaths.has(targetPath) + if (!copyLegacyFile(overlayPath, targetPath, copiedFilePaths)) { + removeCopiedFiles( + [baseTargetPath, ...(copiedSidecarsByBase.get(baseTargetPath) ?? [])], + copiedFilePaths + ) + completed = false + continue + } + if (!wasCopied && copiedFilePaths.has(targetPath)) { + copiedSidecarsByBase.set(baseTargetPath, [ + ...(copiedSidecarsByBase.get(baseTargetPath) ?? []), + targetPath + ]) + } + } + return completed +} + +function copyMissingLegacyOmpOverlayEntries( + overlayDir: string, + sourceAgentDir: string, + pathSegments: string[] = [], + copiedFilePaths: Set = new Set() +): boolean { + let completed = true + let entries: Dirent[] + try { + entries = readdirSync(overlayDir, { withFileTypes: true }) + } catch { + return false + } + + const deferredSidecars: DeferredSidecar[] = [] + for (const entry of entries) { + const nextSegments = [...pathSegments, entry.name] + if (shouldSkipLegacyOverlayEntry(nextSegments)) { + continue + } + + const sidecarBaseName = getSqliteSidecarBaseName(entry.name) + if (sidecarBaseName) { + deferredSidecars.push({ + baseTargetPath: join(sourceAgentDir, ...pathSegments, sidecarBaseName), + entry, + nextSegments + }) + continue + } + + const overlayPath = join(overlayDir, entry.name) + const targetPath = join(sourceAgentDir, ...nextSegments) + const stats = getPathStats(overlayPath) + if (!stats) { + completed = false + continue + } + if (stats.isSymbolicLink()) { + continue + } + if (stats.isDirectory()) { + const targetStats = getPathStats(targetPath) + if (targetStats && !isSafeDescendCandidate(targetStats)) { + continue + } + if (!targetStats) { + try { + mkdirSync(targetPath, { recursive: true }) + } catch { + completed = false + continue + } + } + completed = + copyMissingLegacyOmpOverlayEntries( + overlayPath, + sourceAgentDir, + nextSegments, + copiedFilePaths + ) && completed + continue + } + if (!stats.isFile()) { + continue + } + completed = copyLegacyFile(overlayPath, targetPath, copiedFilePaths) && completed + } + + return ( + copyDeferredSidecars(overlayDir, sourceAgentDir, copiedFilePaths, deferredSidecars) && completed + ) +} + +export function migrateLegacyOmpOverlayState(sourceAgentDir: string, overlayDir: string): void { + // Why: temporary rescue shim for OMP builds from the legacy overlay window. + // Remove after 2026-08-07 once affected users have had a full upgrade window. + const overlayStats = getPathStats(overlayDir) + if (!overlayStats || overlayStats.isSymbolicLink() || !overlayStats.isDirectory()) { + return + } + const markerPath = join(overlayDir, LEGACY_OMP_OVERLAY_MIGRATION_MARKER_FILE) + if (getPathStats(markerPath)) { + return + } + try { + mkdirSync(sourceAgentDir, { recursive: true }) + // Why: some pre-managed-extension builds pointed OMP at this overlay, so + // first-login auth/session files can exist only there after an update. + if (copyMissingLegacyOmpOverlayEntries(overlayDir, sourceAgentDir)) { + // Why: legacy source overlays can be large and are intentionally kept + // for recovery; mark clean migrations so future OMP spawns stay cheap. + writeFileSync(markerPath, 'complete\n') + } + } catch (error) { + console.warn('[pi-titlebar-extension] failed to migrate legacy OMP overlay state:', error) + } +} diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index 75f01583154..7516f04dad9 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { + chmodSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, @@ -11,6 +15,7 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import type * as osModule from 'node:os' +import type * as fsModule from 'node:fs' import { join } from 'node:path' // The service calls app.getPath('userData') for its overlay root. Point that @@ -50,6 +55,12 @@ function legacyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string { return join(userDataDir, rootDir, ptyId) } +function legacySourceOverlayPath(kind: 'pi' | 'omp', sourceAgentDir: string): string { + const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays' + const hashed = createHash('sha256').update(`source:${sourceAgentDir}`).digest('hex').slice(0, 32) + return join(userDataDir, rootDir, hashed) +} + describe('PiTitlebarExtensionService', () => { let piHome: string @@ -185,6 +196,242 @@ describe('PiTitlebarExtensionService', () => { expect(readFileSync(sourcePath, 'utf-8')).toBe(content) }) + it('migrates missing OMP state from the old source overlay without overwriting source files', () => { + rmSync(join(piHome, 'sessions'), { recursive: true, force: true }) + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(join(overlayDir, 'sessions'), { recursive: true }) + mkdirSync(join(overlayDir, 'extensions'), { recursive: true }) + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + writeFileSync(join(overlayDir, 'agent.db-wal'), 'legacy sqlite wal') + writeFileSync(join(overlayDir, 'sessions', 'legacy-session.jsonl'), 'legacy transcript') + writeFileSync(join(overlayDir, 'auth.json'), 'legacy token should not overwrite') + writeFileSync(join(overlayDir, 'settings.json'), '{"overlayOnly":true}') + writeFileSync(join(overlayDir, '.orca-pi-overlay-manifest.json'), '{}') + writeFileSync(join(overlayDir, 'extensions', 'orca-agent-status.ts'), 'stale managed extension') + writeFileSync(join(overlayDir, 'extensions', 'legacy-user-ext.ts'), 'legacy user extension') + mkdirSync(join(overlayDir, 'extensions', 'legacy-package'), { recursive: true }) + writeFileSync( + join(overlayDir, 'extensions', 'legacy-package', 'orca-prefill.ts'), + 'user package file' + ) + + const svc = new PiTitlebarExtensionService() + const env = svc.buildPtyEnv('pty-omp-migrate', piHome, 'omp') + + expect(env.PI_CODING_AGENT_DIR).toBeUndefined() + expect(readFileSync(join(piHome, 'agent.db'), 'utf-8')).toBe('legacy sqlite credentials') + expect(readFileSync(join(piHome, 'agent.db-wal'), 'utf-8')).toBe('legacy sqlite wal') + expect(readFileSync(join(piHome, 'sessions', 'legacy-session.jsonl'), 'utf-8')).toBe( + 'legacy transcript' + ) + expect(readFileSync(join(piHome, 'auth.json'), 'utf-8')).toBe('secret token') + expect(JSON.parse(readFileSync(join(piHome, 'settings.json'), 'utf-8'))).toEqual({ + defaultProvider: 'amazon-bedrock', + hideThinkingBlock: false, + packages: ['npm:pi-web-access'], + terminal: { + showImages: false, + clearOnShrink: false + } + }) + expect(readFileSync(join(piHome, 'extensions', 'legacy-user-ext.ts'), 'utf-8')).toBe( + 'legacy user extension' + ) + expect( + readFileSync(join(piHome, 'extensions', 'legacy-package', 'orca-prefill.ts'), 'utf-8') + ).toBe('user package file') + expect(readFileSync(join(piHome, 'extensions', 'orca-agent-status.ts'), 'utf-8')).toContain( + '/hook/omp' + ) + expect(readFileSync(join(overlayDir, '.orca-omp-overlay-migration-complete'), 'utf-8')).toBe( + 'complete\n' + ) + }) + + it('does not copy stale SQLite sidecars when the target database already exists', () => { + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(overlayDir, { recursive: true }) + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + writeFileSync(join(overlayDir, 'agent.db-wal'), 'legacy sqlite wal') + writeFileSync(join(overlayDir, 'agent.db-shm'), 'legacy sqlite shm') + writeFileSync(join(piHome, 'agent.db'), 'fresh sqlite credentials') + + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-stale-sidecars', piHome, 'omp') + + expect(readFileSync(join(piHome, 'agent.db'), 'utf-8')).toBe('fresh sqlite credentials') + expect(existsSync(join(piHome, 'agent.db-wal'))).toBe(false) + expect(existsSync(join(piHome, 'agent.db-shm'))).toBe(false) + expect(readFileSync(join(overlayDir, '.orca-omp-overlay-migration-complete'), 'utf-8')).toBe( + 'complete\n' + ) + }) + + it.skipIf(process.platform === 'win32')( + 'retries a legacy SQLite migration as a whole set after sidecar copy failure', + () => { + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(overlayDir, { recursive: true }) + const walPath = join(overlayDir, 'agent.db-wal') + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + writeFileSync(walPath, 'legacy sqlite wal') + chmodSync(walPath, 0o000) + + try { + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-sidecar-fail-1', piHome, 'omp') + + expect(existsSync(join(piHome, 'agent.db'))).toBe(false) + expect(existsSync(join(piHome, 'agent.db-wal'))).toBe(false) + expect(existsSync(join(overlayDir, '.orca-omp-overlay-migration-complete'))).toBe(false) + + chmodSync(walPath, 0o600) + svc.buildPtyEnv('pty-omp-sidecar-fail-2', piHome, 'omp') + + expect(readFileSync(join(piHome, 'agent.db'), 'utf-8')).toBe('legacy sqlite credentials') + expect(readFileSync(join(piHome, 'agent.db-wal'), 'utf-8')).toBe('legacy sqlite wal') + expect( + readFileSync(join(overlayDir, '.orca-omp-overlay-migration-complete'), 'utf-8') + ).toBe('complete\n') + } finally { + chmodSync(walPath, 0o600) + } + } + ) + + it('retries a legacy SQLite migration as a whole set after sidecar stat failure', async () => { + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(overlayDir, { recursive: true }) + const walPath = join(overlayDir, 'agent.db-wal') + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + writeFileSync(walPath, 'legacy sqlite wal') + + let failNextWalStat = true + vi.resetModules() + vi.doMock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + lstatSync: (path: Parameters[0]) => { + if (String(path) === walPath && failNextWalStat) { + failNextWalStat = false + throw new Error('transient lstat failure') + } + return actual.lstatSync(path) + } + } + }) + + try { + const { migrateLegacyOmpOverlayState } = await import('./legacy-omp-overlay-migration') + migrateLegacyOmpOverlayState(piHome, overlayDir) + + expect(existsSync(join(piHome, 'agent.db'))).toBe(false) + expect(existsSync(join(piHome, 'agent.db-wal'))).toBe(false) + expect(existsSync(join(overlayDir, '.orca-omp-overlay-migration-complete'))).toBe(false) + + migrateLegacyOmpOverlayState(piHome, overlayDir) + + expect(readFileSync(join(piHome, 'agent.db'), 'utf-8')).toBe('legacy sqlite credentials') + expect(readFileSync(join(piHome, 'agent.db-wal'), 'utf-8')).toBe('legacy sqlite wal') + expect(readFileSync(join(overlayDir, '.orca-omp-overlay-migration-complete'), 'utf-8')).toBe( + 'complete\n' + ) + } finally { + vi.doUnmock('node:fs') + vi.resetModules() + } + }) + + it('marks successful legacy OMP migrations so old overlays are not re-scanned', () => { + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(overlayDir, { recursive: true }) + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-migrate-once-1', piHome, 'omp') + + expect(readFileSync(join(piHome, 'agent.db'), 'utf-8')).toBe('legacy sqlite credentials') + expect(readFileSync(join(overlayDir, '.orca-omp-overlay-migration-complete'), 'utf-8')).toBe( + 'complete\n' + ) + + writeFileSync(join(overlayDir, 'later-overlay-only-file'), 'should not migrate') + svc.buildPtyEnv('pty-omp-migrate-once-2', piHome, 'omp') + + expect(existsSync(join(piHome, 'later-overlay-only-file'))).toBe(false) + }) + + it.skipIf(process.platform === 'win32')( + 'skips special legacy overlay entries while continuing the OMP migration', + () => { + rmSync(join(piHome, 'sessions'), { recursive: true, force: true }) + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(join(overlayDir, 'sessions'), { recursive: true }) + execFileSync('mkfifo', [join(overlayDir, 'stray-fifo')]) + writeFileSync(join(overlayDir, 'sessions', 'legacy-session.jsonl'), 'legacy transcript') + + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-special-entry', piHome, 'omp') + + expect(existsSync(join(piHome, 'stray-fifo'))).toBe(false) + expect(readFileSync(join(piHome, 'sessions', 'legacy-session.jsonl'), 'utf-8')).toBe( + 'legacy transcript' + ) + } + ) + + it.skipIf(process.platform === 'win32')( + 'does not descend through existing target directory symlinks while migrating OMP state', + () => { + rmSync(join(piHome, 'sessions'), { recursive: true, force: true }) + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(join(overlayDir, 'sessions'), { recursive: true }) + writeFileSync(join(overlayDir, 'sessions', 'legacy-session.jsonl'), 'legacy transcript') + const outsideDir = mkdtempSync(join(tmpdir(), 'orca-omp-target-junction-')) + const sessionsPath = join(piHome, 'sessions') + + try { + symlinkSync(outsideDir, sessionsPath, 'dir') + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-target-dir-symlink', piHome, 'omp') + + expect(lstatSync(sessionsPath).isSymbolicLink()).toBe(true) + expect(existsSync(join(outsideDir, 'legacy-session.jsonl'))).toBe(false) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'does not follow existing target symlinks while migrating OMP state', + () => { + rmSync(join(piHome, 'sessions'), { recursive: true, force: true }) + const overlayDir = legacySourceOverlayPath('omp', piHome) + mkdirSync(join(overlayDir, 'sessions'), { recursive: true }) + writeFileSync(join(overlayDir, 'agent.db'), 'legacy sqlite credentials') + writeFileSync(join(overlayDir, 'sessions', 'legacy-session.jsonl'), 'legacy transcript') + const outsideDir = mkdtempSync(join(tmpdir(), 'orca-omp-dangling-target-')) + + try { + const outsideTarget = join(outsideDir, 'agent.db') + symlinkSync(outsideTarget, join(piHome, 'agent.db'), 'file') + + const svc = new PiTitlebarExtensionService() + svc.buildPtyEnv('pty-omp-target-symlink', piHome, 'omp') + + expect(existsSync(outsideTarget)).toBe(false) + expect(lstatSync(join(piHome, 'agent.db')).isSymbolicLink()).toBe(true) + expect(readFileSync(join(piHome, 'sessions', 'legacy-session.jsonl'), 'utf-8')).toBe( + 'legacy transcript' + ) + } finally { + rmSync(outsideDir, { recursive: true, force: true }) + } + } + ) + it('rebuilding managed extensions for the same ptyId does not corrupt the user Pi dir', () => { const svc = new PiTitlebarExtensionService() svc.buildPtyEnv('pty-3', piHome, 'pi') diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index d5b3c0a87c5..37d030b5e31 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -17,6 +17,7 @@ import { isSafeDescendCandidate as sharedIsSafeDescendCandidate, safeRemoveOverlay } from '../pty/overlay-mirror' +import { migrateLegacyOmpOverlayState } from './legacy-omp-overlay-migration' import type { PiAgentKind } from '../../shared/pi-agent-kind' // Why: the Pi test suite imports `isSafeDescendCandidate` from this module's @@ -74,6 +75,12 @@ export class PiTitlebarExtensionService { return join(app.getPath('userData'), OVERLAY_ROOT_DIR_NAME[kind]) } + private getSourceOverlayDir(sourceAgentDir: string, kind: PiAgentKind): string { + // Why: builds before managed extensions stored Pi/OMP state in source-scoped + // overlays. Resolve the old path so OMP upgrades can rescue stranded state. + return join(this.getOverlayRoot(kind), toSafeOverlayDirName(`source:${sourceAgentDir}`)) + } + private getPtyOverlayDir(ptyId: string, kind: PiAgentKind): string { // Why: old Orca versions used PTY-scoped hashed overlays. Keep resolving // that path so new spawns/teardowns can clean stale pre-migration dirs. @@ -173,6 +180,10 @@ export class PiTitlebarExtensionService { // directory should not prevent the terminal from starting. } + if (kind === 'omp') { + migrateLegacyOmpOverlayState(sourceAgentDir, this.getSourceOverlayDir(sourceAgentDir, 'omp')) + } + const installed = this.installManagedExtensions(sourceAgentDir, kind) const env: Record = {} if (kind === 'omp') { diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index e736e236c9a..d0a6ba13764 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -14,20 +14,24 @@ import { writeStartupCommandWhenShellReady } from './local-pty-shell-ready' -const { getUserDataPathMock } = vi.hoisted(() => ({ - getUserDataPathMock: vi.fn<() => string>() -})) +// Why: the wrapper root is resolved from ORCA_USER_DATA_PATH (main +// canonicalizes it at startup; the daemon fork sets it explicitly). This +// module must not import electron because it is bundled into the plain-node +// daemon-entry fork, so tests point the root through the env var rather than +// mocking electron's app. +function setTestUserDataPath(path: string): void { + process.env.ORCA_USER_DATA_PATH = path +} -vi.mock('electron', () => ({ - app: { - getPath: (name: string) => { - if (name === 'userData') { - return getUserDataPathMock() - } - throw new Error(`unexpected app.getPath(${name})`) - } +const ORIGINAL_ORCA_USER_DATA_PATH = process.env.ORCA_USER_DATA_PATH + +afterEach(() => { + if (ORIGINAL_ORCA_USER_DATA_PATH === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = ORIGINAL_ORCA_USER_DATA_PATH } -})) +}) async function importFreshLocalPtyShellReady(): Promise { vi.resetModules() @@ -279,6 +283,23 @@ describe('scanForShellReady', () => { }) }) +describe('shell-ready wrapper root resolution', () => { + // Why: regression guard — the daemon-entry fork runs as plain Node and cannot + // import electron, so the wrapper root must resolve from ORCA_USER_DATA_PATH + // (set by main at startup and by the daemon fork) rather than app.getPath. + it('resolves the wrapper root from ORCA_USER_DATA_PATH', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-userdata-env-')) + try { + setTestUserDataPath(root) + const { getShellReadyLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellReadyLaunchConfig('/bin/zsh') + expect(config.env.ZDOTDIR).toBe(`${root}/shell-ready/zsh`) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) + const describePosix = process.platform === 'win32' ? describe.skip : describe const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 const itWithBash = hasBash ? it : it.skip @@ -342,7 +363,7 @@ describePosix('local PTY shell-ready launch config', () => { previousOrcaOrigZdotdir = process.env.ORCA_ORIG_ZDOTDIR delete process.env.ORCA_ORIG_ZDOTDIR userDataPath = mkdtempSync(join(tmpdir(), 'local-pty-shell-ready-test-')) - getUserDataPathMock.mockReturnValue(userDataPath) + setTestUserDataPath(userDataPath) }) afterEach(() => { @@ -723,7 +744,7 @@ describePosix('live zsh subprocess tests', () => { beforeEach(async () => { testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-test-home-')) userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-test-userdata-')) - getUserDataPathMock.mockReturnValue(userDataPath) + setTestUserDataPath(userDataPath) }) afterEach(() => { @@ -957,7 +978,7 @@ export MY_VAR=foo beforeEach(async () => { testHome = mkdtempSync(join(tmpdir(), 'orca-zsh-edge-')) userDataPath = mkdtempSync(join(tmpdir(), 'orca-zsh-userdata-')) - getUserDataPathMock.mockReturnValue(userDataPath) + setTestUserDataPath(userDataPath) }) afterEach(() => { @@ -1283,7 +1304,7 @@ export MY_VAR=foo beforeEach(async () => { testHome = mkdtempSync(join(tmpdir(), 'orca-term-')) userDataPath = mkdtempSync(join(tmpdir(), 'orca-term-userdata-')) - getUserDataPathMock.mockReturnValue(userDataPath) + setTestUserDataPath(userDataPath) }) afterEach(() => { @@ -1516,7 +1537,7 @@ export MY_VAR=foo beforeEach(async () => { testHome = mkdtempSync(join(tmpdir(), 'orca-auto-')) userDataPath = mkdtempSync(join(tmpdir(), 'orca-auto-userdata-')) - getUserDataPathMock.mockReturnValue(userDataPath) + setTestUserDataPath(userDataPath) }) afterEach(() => { diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 978d3e94b2a..e7b5c618f4d 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -13,7 +13,6 @@ import { tmpdir } from 'node:os' import { basename, win32 as pathWin32 } from 'node:path' import { mkdirSync, writeFileSync, chmodSync, existsSync } from 'node:fs' -import { app } from 'electron' import type * as pty from 'node-pty' import { encodePowerShellCommand, @@ -50,7 +49,11 @@ export type ShellReadySignal = { // ── Shell wrapper files ───────────────────────────────────────────── function getShellReadyWrapperRoot(): string { - const userDataPath = app?.getPath?.('userData') ?? process.env.ORCA_USER_DATA_PATH ?? tmpdir() + // Why: bundled into daemon-entry.js (a plain-node fork with no electron + // require), so this must not import electron. Main canonicalizes + // ORCA_USER_DATA_PATH to its own userData at startup (configureOrcaUserDataPathEnv) + // and the daemon fork sets it explicitly, so the env value matches this root. + const userDataPath = process.env.ORCA_USER_DATA_PATH ?? tmpdir() return `${userDataPath}/shell-ready` } @@ -64,8 +67,8 @@ function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): s ] } -function shellReadyWrappersExist(): boolean { - return getRequiredShellReadyWrapperPaths().every((path) => existsSync(path)) +function shellReadyWrappersExist(root = getShellReadyWrapperRoot()): boolean { + return getRequiredShellReadyWrapperPaths(root).every((path) => existsSync(path)) } // Why: if our own process inherited ZDOTDIR from a parent shell that was @@ -288,16 +291,12 @@ fi ` } -function ensureShellReadyWrappers(): void { - if (process.platform === 'win32') { - return - } - if (didEnsureShellReadyWrappers && shellReadyWrappersExist()) { +export function ensureShellReadyWrappersAt(root = getShellReadyWrapperRoot()): void { + if (didEnsureShellReadyWrappers && shellReadyWrappersExist(root)) { return } didEnsureShellReadyWrappers = true - const root = getShellReadyWrapperRoot() const zshDir = `${root}/zsh` const bashDir = `${root}/bash` @@ -365,6 +364,13 @@ ${getZshFinalZdotdirRestoreBlock()} } } +function ensureShellReadyWrappers(): void { + if (process.platform === 'win32') { + return + } + ensureShellReadyWrappersAt() +} + // ── Shell launch config ───────────────────────────────────────────── export type ShellReadyLaunchConfig = { diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 6d4170209e7..d35460bd489 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -417,7 +417,11 @@ describe('SshFilesystemProvider', () => { it('listFiles sends fs.listFiles request', async () => { mux.request.mockResolvedValue(['src/index.ts', 'package.json']) const result = await provider.listFiles('/home/user/project') - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { rootPath: '/home/user/project' }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.listFiles', + { rootPath: '/home/user/project' }, + { signal: undefined } + ) expect(result).toEqual(['src/index.ts', 'package.json']) }) @@ -426,16 +430,35 @@ describe('SshFilesystemProvider', () => { await provider.listFiles('/home/user/project', { excludePaths: ['/home/user/project/worktrees/b'] }) - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { - rootPath: '/home/user/project', - excludePaths: ['/home/user/project/worktrees/b'] - }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.listFiles', + { + rootPath: '/home/user/project', + excludePaths: ['/home/user/project/worktrees/b'] + }, + { signal: undefined } + ) }) it('listFiles omits excludePaths when empty', async () => { mux.request.mockResolvedValue([]) await provider.listFiles('/home/user/project', { excludePaths: [] }) - expect(mux.request).toHaveBeenCalledWith('fs.listFiles', { rootPath: '/home/user/project' }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.listFiles', + { rootPath: '/home/user/project' }, + { signal: undefined } + ) + }) + + it('listFiles forwards the cancellation signal to the mux request (#7721)', async () => { + mux.request.mockResolvedValue([]) + const controller = new AbortController() + await provider.listFiles('/home/user/project', { signal: controller.signal }) + expect(mux.request).toHaveBeenCalledWith( + 'fs.listFiles', + { rootPath: '/home/user/project' }, + { signal: controller.signal } + ) }) describe('watch', () => { diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index 3adeeec6cb8..ce5256d2744 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -283,12 +283,20 @@ export class SshFilesystemProvider implements IFilesystemProvider { return (await this.mux.request('fs.search', opts)) as SearchResult } - async listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise { + async listFiles( + rootPath: string, + options?: { excludePaths?: string[]; signal?: AbortSignal } + ): Promise { const params: Record = { rootPath } if (options?.excludePaths && options.excludePaths.length > 0) { params.excludePaths = options.excludePaths } - return (await this.mux.request('fs.listFiles', params)) as string[] + // Why #7721: the signal lets a workspace switch send rpc.cancel so the + // relay aborts the full-tree scan instead of stacking abandoned scans + // that starve interactive fs.readDir/fs.stat on the shared SSH channel. + return (await this.mux.request('fs.listFiles', params, { + signal: options?.signal + })) as string[] } async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> { diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 28d5d1d9447..c8e2680366b 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -197,7 +197,10 @@ export type IFilesystemProvider = { copy(source: string, destination: string): Promise realpath(filePath: string): Promise search(opts: SearchOptions): Promise - listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise + listFiles( + rootPath: string, + options?: { excludePaths?: string[]; signal?: AbortSignal } + ): Promise scanWorkspaceSpace?( rootPath: string, options?: { signal?: AbortSignal } diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index d68f4014b23..7d0eeb5b4e3 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { encodePowerShellCommand, getPowerShellOsc133Bootstrap @@ -17,6 +20,24 @@ function expectedWslArgs(linuxCwd: string, distro?: string): string[] { } describe('resolveWindowsShellLaunchArgs', () => { + let previousUserDataPath: string | undefined + let userDataPath: string + + beforeEach(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'windows-shell-args-test-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + }) + + afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + }) + it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => { const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice') expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) @@ -201,6 +222,27 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.validationCwd).toBe('C:\\Users\\alice\\code') }) + it('materializes shell-ready wrappers before building WSL shell args', () => { + const result = resolveWindowsShellLaunchArgs( + 'wsl.exe', + 'C:\\Users\\alice\\code', + 'C:\\Users\\alice' + ) + + expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code')) + expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true) + expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true) + + // Why: the point of materializing wrappers for WSL is that a typed `omp` + // picks up Orca's status extension; pin that shim end to end. + const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') + const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') + for (const wrapperFile of [bashRcfile, zshLogin]) { + expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') + expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }') + } + }) + it('translates MSYS drive cwd to /mnt//... for wsl.exe', () => { const result = resolveWindowsShellLaunchArgs( 'wsl.exe', diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 98b119d8597..3889edfc5c1 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -6,6 +6,7 @@ import { escapeWslShCommandForWindows, quotePosixShell } from '../../shared/wsl-login-shell-command' +import { ensureShellReadyWrappersAt } from './local-pty-shell-ready' import { encodePowerShellCommand, getPowerShellOsc133Bootstrap @@ -94,6 +95,7 @@ function getPowerShellEncodedCommand(startupCommand?: string): { * Builds wsl.exe arguments that enter the target directory through the distro shell. */ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { + ensureShellReadyWrappersAt() const setupCommand = [ `cd ${quotePosixShell(linuxCwd)}`, 'export PATH="$HOME/.local/bin:$PATH"', diff --git a/src/main/pty/terminal-cwd-realpath.test.ts b/src/main/pty/terminal-cwd-realpath.test.ts deleted file mode 100644 index 47110648b19..00000000000 --- a/src/main/pty/terminal-cwd-realpath.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterAll, describe, expect, it } from 'vitest' -import { - canonicalizeLocalTerminalPath, - localTerminalCwdCanonicalizer -} from './terminal-cwd-realpath' - -describe('canonicalizeLocalTerminalPath', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-cwd-realpath-')) - - afterAll(() => { - rmSync(root, { recursive: true, force: true }) - }) - - it('resolves symlinks to their target', () => { - const outside = join(root, 'outside') - const worktree = join(root, 'worktree') - mkdirSync(outside) - mkdirSync(worktree) - const link = join(worktree, 'escape') - symlinkSync(outside, link, 'dir') - expect(canonicalizeLocalTerminalPath(link)).toBe(canonicalizeLocalTerminalPath(outside)) - }) - - it('returns null for nonexistent paths', () => { - expect(canonicalizeLocalTerminalPath(join(root, 'does-not-exist'))).toBeNull() - }) - - it('skips WSL UNC paths instead of touching the 9P share', () => { - expect(canonicalizeLocalTerminalPath('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo')).toBeNull() - expect(canonicalizeLocalTerminalPath('//wsl$/Ubuntu/home/jin/repo')).toBeNull() - }) -}) - -describe('localTerminalCwdCanonicalizer', () => { - it('is disabled for SSH connections and enabled locally', () => { - expect(localTerminalCwdCanonicalizer('ssh-1')).toBeUndefined() - expect(localTerminalCwdCanonicalizer(null)).toBe(canonicalizeLocalTerminalPath) - expect(localTerminalCwdCanonicalizer(undefined)).toBe(canonicalizeLocalTerminalPath) - }) -}) diff --git a/src/main/pty/terminal-cwd-realpath.ts b/src/main/pty/terminal-cwd-realpath.ts deleted file mode 100644 index f9131d9f01a..00000000000 --- a/src/main/pty/terminal-cwd-realpath.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { realpathSync } from 'node:fs' -import { isWslUncPath } from '../../shared/wsl-paths' - -// Why: terminal startup cwd containment is string-based; realpath closes the -// symlink escape for local paths. Only for local worktrees — SSH/remote paths -// are not resolvable on this filesystem. -export function canonicalizeLocalTerminalPath(targetPath: string): string | null { - // Why: realpath over the WSL 9P share is unreliable and can block the main - // process while the distro boots; keep the string containment check only. - if (isWslUncPath(targetPath)) { - return null - } - try { - return realpathSync.native(targetPath) - } catch { - try { - // Why: realpathSync.native can fail on network/UNC mounts where the - // JS implementation still succeeds (same fallback as git repo scanning). - return realpathSync(targetPath) - } catch { - return null - } - } -} - -export function localTerminalCwdCanonicalizer( - connectionId: string | null | undefined -): ((path: string) => string | null) | undefined { - return connectionId ? undefined : canonicalizeLocalTerminalPath -} diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index ec210e5db3b..9d7c36c6cfb 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -23,6 +23,7 @@ describe('addOrcaWslInteropEnv', () => { it('marks OMP status and hook env for Windows to WSL import', () => { const env: Record = { ORCA_TERMINAL_HANDLE: 'term_wsl', + ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', ORCA_TAB_ID: 'tab-1', @@ -36,6 +37,7 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') + expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') expect(env.WSLENV).toContain('ORCA_TAB_ID/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 2e4a878f52b..40a694a87ed 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -16,10 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void { export function addOrcaWslInteropEnv(env: Record): void { const entries = parseWslenvEntries(env.WSLENV) - // Why: wsl.exe only imports selected Windows env vars. Agent status in WSL - // needs both the pane identity and the hook/OMP coordinates at process start. + // Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start. const passthroughEntries = [ 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_USER_DATA_PATH/p', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', diff --git a/src/main/rate-limits/auth-filesystem-operation.test.ts b/src/main/rate-limits/auth-filesystem-operation.test.ts new file mode 100644 index 00000000000..92099fa2dcc --- /dev/null +++ b/src/main/rate-limits/auth-filesystem-operation.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAuthFilesystemOperation } from './auth-filesystem-operation' + +describe('createAuthFilesystemOperation', () => { + it('serializes WSL aliases by distro and drops an abandoned queued read', async () => { + let resolveFirst!: (value: string) => void + const firstRaw = vi.fn( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + const secondRaw = vi.fn(async () => 'second') + const first = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json', + firstRaw + ) + const second = createAuthFilesystemOperation( + '\\\\WSL$\\ubuntu\\home\\alice\\managed\\auth.json', + secondRaw + ) + const firstController = new AbortController() + const secondController = new AbortController() + + const firstWait = first.wait(firstController.signal) + const secondWait = second.wait(secondController.signal) + await Promise.resolve() + await Promise.resolve() + expect(firstRaw).toHaveBeenCalledOnce() + expect(secondRaw).not.toHaveBeenCalled() + + const queuedAbort = new Error('queued caller expired') + secondController.abort(queuedAbort) + await expect(secondWait).rejects.toBe(queuedAbort) + + resolveFirst('first') + await expect(firstWait).resolves.toBe('first') + await expect(second.result).rejects.toBe(queuedAbort) + expect(secondRaw).not.toHaveBeenCalled() + }) + + it('allows different WSL distros to probe concurrently', async () => { + const ubuntuRaw = vi.fn(async () => 'ubuntu') + const debianRaw = vi.fn(async () => 'debian') + const ubuntu = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json', + ubuntuRaw + ) + const debian = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Debian\\home\\alice\\.codex\\auth.json', + debianRaw + ) + const controller = new AbortController() + + await expect( + Promise.all([ubuntu.wait(controller.signal), debian.wait(controller.signal)]) + ).resolves.toEqual(['ubuntu', 'debian']) + expect(ubuntuRaw).toHaveBeenCalledOnce() + expect(debianRaw).toHaveBeenCalledOnce() + }) + + it('does not start an operation for an already-aborted waiter', async () => { + const raw = vi.fn(async () => 'unexpected') + const operation = createAuthFilesystemOperation( + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex\\auth.json', + raw + ) + const controller = new AbortController() + const abortError = new Error('already expired') + controller.abort(abortError) + + await expect(operation.wait(controller.signal)).rejects.toBe(abortError) + await expect(operation.result).rejects.toBe(abortError) + expect(raw).not.toHaveBeenCalled() + }) + + it('caps cross-distro operations and never starts an expired queued probe', async () => { + let resolveUbuntu!: (value: string) => void + let resolveDebian!: (value: string) => void + const ubuntuRaw = vi.fn( + () => + new Promise((resolve) => { + resolveUbuntu = resolve + }) + ) + const debianRaw = vi.fn( + () => + new Promise((resolve) => { + resolveDebian = resolve + }) + ) + const fedoraRaw = vi.fn(async () => 'fedora') + const ubuntu = createAuthFilesystemOperation('\\\\wsl$\\Ubuntu\\a\\auth.json', ubuntuRaw) + const debian = createAuthFilesystemOperation('\\\\wsl$\\Debian\\b\\auth.json', debianRaw) + const fedora = createAuthFilesystemOperation('\\\\wsl$\\Fedora\\c\\auth.json', fedoraRaw) + const ubuntuController = new AbortController() + const debianController = new AbortController() + const fedoraController = new AbortController() + + const ubuntuWait = ubuntu.wait(ubuntuController.signal) + const debianWait = debian.wait(debianController.signal) + const fedoraWait = fedora.wait(fedoraController.signal) + await Promise.resolve() + await Promise.resolve() + expect(ubuntuRaw).toHaveBeenCalledOnce() + expect(debianRaw).toHaveBeenCalledOnce() + expect(fedoraRaw).not.toHaveBeenCalled() + + const queuedAbort = new Error('global queue expired') + fedoraController.abort(queuedAbort) + await expect(fedoraWait).rejects.toBe(queuedAbort) + await expect(fedora.result).rejects.toBe(queuedAbort) + + resolveUbuntu('ubuntu') + resolveDebian('debian') + await expect(Promise.all([ubuntuWait, debianWait])).resolves.toEqual(['ubuntu', 'debian']) + expect(fedoraRaw).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/rate-limits/auth-filesystem-operation.ts b/src/main/rate-limits/auth-filesystem-operation.ts new file mode 100644 index 00000000000..228234e92c0 --- /dev/null +++ b/src/main/rate-limits/auth-filesystem-operation.ts @@ -0,0 +1,175 @@ +import { parseWslUncPath } from '../../shared/wsl-paths' + +const MAX_CONCURRENT_WSL_AUTH_OPERATIONS = 2 +const activeWslOperationDistros = new Set() +const queuedWslOperations: QueuedWslOperation[] = [] +let activeWslOperationCount = 0 + +type QueuedWslOperation = { + distroKey: string + neededSignal: AbortSignal + operation: () => Promise + resolve: (value: T) => void + reject: (error: unknown) => void + onAbort: () => void + state: 'queued' | 'running' | 'settled' +} + +function getAbortReason(signal: AbortSignal): unknown { + return signal.reason ?? new Error('Auth filesystem operation aborted') +} + +function finishWslOperation(task: QueuedWslOperation, settle: () => void): void { + if (task.state !== 'running') { + return + } + task.state = 'settled' + activeWslOperationCount -= 1 + activeWslOperationDistros.delete(task.distroKey) + settle() + pumpWslOperations() +} + +function pumpWslOperations(): void { + while (activeWslOperationCount < MAX_CONCURRENT_WSL_AUTH_OPERATIONS) { + const nextIndex = queuedWslOperations.findIndex( + (task) => !activeWslOperationDistros.has(task.distroKey) + ) + if (nextIndex === -1) { + return + } + const task = queuedWslOperations.splice(nextIndex, 1)[0] + if (!task || task.state !== 'queued') { + continue + } + task.neededSignal.removeEventListener('abort', task.onAbort) + if (task.neededSignal.aborted) { + task.state = 'settled' + task.reject(getAbortReason(task.neededSignal)) + continue + } + task.state = 'running' + activeWslOperationCount += 1 + activeWslOperationDistros.add(task.distroKey) + void Promise.resolve() + .then(() => { + if (task.neededSignal.aborted) { + throw getAbortReason(task.neededSignal) + } + return task.operation() + }) + .then( + (value) => finishWslOperation(task, () => task.resolve(value)), + (error: unknown) => finishWslOperation(task, () => task.reject(error)) + ) + } +} + +function scheduleWslAuthFilesystemOperation( + distroKey: string, + neededSignal: AbortSignal, + operation: () => Promise +): Promise { + return new Promise((resolve, reject) => { + const task: QueuedWslOperation = { + distroKey, + neededSignal, + operation, + resolve, + reject, + state: 'queued', + onAbort: () => { + if (task.state !== 'queued') { + return + } + task.state = 'settled' + const index = queuedWslOperations.indexOf(task as QueuedWslOperation) + if (index !== -1) { + queuedWslOperations.splice(index, 1) + } + reject(getAbortReason(neededSignal)) + pumpWslOperations() + } + } + neededSignal.addEventListener('abort', task.onAbort, { once: true }) + queuedWslOperations.push(task as QueuedWslOperation) + queueMicrotask(pumpWslOperations) + }) +} + +function scheduleAuthFilesystemOperation( + authPath: string, + neededSignal: AbortSignal, + operation: () => Promise +): Promise { + const wslInfo = parseWslUncPath(authPath) + if (!wslInfo) { + return Promise.resolve().then(() => { + if (neededSignal.aborted) { + throw getAbortReason(neededSignal) + } + return operation() + }) + } + // Why: a few disconnected distros must not occupy libuv's entire default + // filesystem pool. Distro serialization also folds wsl$/wsl.localhost and + // case aliases without forcing healthy local auth reads through the queue. + return scheduleWslAuthFilesystemOperation( + wslInfo.distro.trim().toLowerCase(), + neededSignal, + operation + ) +} + +export type SharedAuthFilesystemOperation = { + result: Promise + wait: (signal: AbortSignal) => Promise +} + +/** + * Shares one raw operation with all callers for an auth path. WSL paths also + * serialize by normalized distro because one stuck UNC request per account can + * otherwise exhaust libuv's filesystem threadpool. + */ +export function createAuthFilesystemOperation( + authPath: string, + operation: () => Promise +): SharedAuthFilesystemOperation { + const neededController = new AbortController() + const waiters = new Set() + let settled = false + const result = scheduleAuthFilesystemOperation(authPath, neededController.signal, operation) + const markSettled = (): void => { + settled = true + } + void result.then(markSettled, markSettled) + + return { + result, + wait(signal) { + if (signal.aborted) { + if (!settled && waiters.size === 0) { + neededController.abort(getAbortReason(signal)) + } + return Promise.reject(getAbortReason(signal)) + } + + const waiter = Symbol('auth-filesystem-waiter') + waiters.add(waiter) + let onAbort: (() => void) | null = null + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(getAbortReason(signal)) + signal.addEventListener('abort', onAbort, { once: true }) + }) + return Promise.race([result, aborted]).finally(() => { + if (onAbort) { + signal.removeEventListener('abort', onAbort) + } + waiters.delete(waiter) + if (!settled && waiters.size === 0) { + neededController.abort(getAbortReason(signal)) + } + }) + } + } +} diff --git a/src/main/rate-limits/codex-auth-presence.test.ts b/src/main/rate-limits/codex-auth-presence.test.ts index f4e10fa8693..95854350f7f 100644 --- a/src/main/rate-limits/codex-auth-presence.test.ts +++ b/src/main/rate-limits/codex-auth-presence.test.ts @@ -1,13 +1,13 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { existsSyncMock, homedirMock } = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), +const { accessMock, homedirMock } = vi.hoisted(() => ({ + accessMock: vi.fn(), homedirMock: vi.fn() })) -vi.mock('node:fs', () => ({ - existsSync: existsSyncMock +vi.mock('node:fs/promises', () => ({ + access: accessMock })) vi.mock('node:os', () => ({ @@ -33,33 +33,101 @@ describe('codexAuthExists', () => { } }) - it('checks an explicit managed-account home first', () => { - existsSyncMock.mockReturnValue(true) + it('checks an explicit managed-account home first', async () => { + accessMock.mockResolvedValue(undefined) - expect(codexAuthExists('/managed/home')).toBe(true) - expect(existsSyncMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json')) + await expect(codexAuthExists('/managed/home')).resolves.toBe(true) + expect(accessMock).toHaveBeenCalledWith(join('/managed/home', 'auth.json')) }) - it('falls back to CODEX_HOME when no home is provided', () => { + it('falls back to CODEX_HOME when no home is provided', async () => { process.env.CODEX_HOME = '/custom/codex' - existsSyncMock.mockReturnValue(true) + accessMock.mockResolvedValue(undefined) - expect(codexAuthExists()).toBe(true) - expect(existsSyncMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json')) + await expect(codexAuthExists()).resolves.toBe(true) + expect(accessMock).toHaveBeenCalledWith(join('/custom/codex', 'auth.json')) }) - it('falls back to ~/.codex when neither home nor CODEX_HOME is set', () => { - existsSyncMock.mockReturnValue(false) + it('falls back to ~/.codex when neither home nor CODEX_HOME is set', async () => { + accessMock.mockRejectedValue(new Error('ENOENT')) - expect(codexAuthExists()).toBe(false) - expect(existsSyncMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json')) + await expect(codexAuthExists()).resolves.toBe(false) + expect(accessMock).toHaveBeenCalledWith(join('/home/alice', '.codex', 'auth.json')) }) - it('returns false instead of throwing when the fs check fails', () => { - existsSyncMock.mockImplementation(() => { - throw new Error('EACCES') + it('returns false instead of throwing when the fs check fails', async () => { + accessMock.mockRejectedValue(new Error('EACCES')) + + await expect(codexAuthExists('/managed/home')).resolves.toBe(false) + }) + + it('stops waiting for a stalled filesystem check when the caller aborts', async () => { + let resolveAccess!: () => void + accessMock.mockReturnValue( + new Promise((resolve) => { + resolveAccess = resolve + }) + ) + const controller = new AbortController() + + const result = codexAuthExists('/managed/home', { signal: controller.signal }) + await Promise.resolve() + await Promise.resolve() + controller.abort() + + await expect(result).resolves.toBe(false) + resolveAccess() + await Promise.resolve() + }) + + it('bounds a stalled filesystem check even without a caller signal', async () => { + let resolveAccess!: () => void + accessMock.mockReturnValue( + new Promise((resolve) => { + resolveAccess = resolve + }) + ) + const timeoutController = new AbortController() + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutController.signal) + + const result = codexAuthExists('/managed/home') + expect(timeout).toHaveBeenCalledWith(5_000) + await Promise.resolve() + await Promise.resolve() + timeoutController.abort() + + await expect(result).resolves.toBe(false) + resolveAccess() + await Promise.resolve() + timeout.mockRestore() + }) + + it('shares one stalled UNC probe across concurrent callers', async () => { + let resolveAccess!: () => void + accessMock.mockReturnValue( + new Promise((resolve) => { + resolveAccess = resolve + }) + ) + const firstController = new AbortController() + const secondController = new AbortController() + + const first = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', { + signal: firstController.signal + }) + const second = codexAuthExists('\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', { + signal: secondController.signal }) - expect(codexAuthExists('/managed/home')).toBe(false) + await Promise.resolve() + await Promise.resolve() + expect(accessMock).toHaveBeenCalledTimes(1) + firstController.abort() + secondController.abort() + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + + resolveAccess() + await Promise.resolve() }) }) diff --git a/src/main/rate-limits/codex-auth-presence.ts b/src/main/rate-limits/codex-auth-presence.ts index f972fcecbc0..8ebe81bd9be 100644 --- a/src/main/rate-limits/codex-auth-presence.ts +++ b/src/main/rate-limits/codex-auth-presence.ts @@ -1,6 +1,42 @@ -import { existsSync } from 'node:fs' +import { access } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import { + createAuthFilesystemOperation, + type SharedAuthFilesystemOperation +} from './auth-filesystem-operation' + +const AUTH_PRESENCE_TIMEOUT_MS = 5_000 +const authPresenceProbeByPath = new Map>() + +type CodexAuthPresenceOptions = { + signal?: AbortSignal + timeoutMs?: number +} + +function getAuthPresenceProbe(authPath: string): SharedAuthFilesystemOperation { + const existing = authPresenceProbeByPath.get(authPath) + if (existing) { + return existing + } + // Why: aborting a Node fs promise does not necessarily cancel an already + // issued UNC operation. Share the raw probe until it really settles so a + // disconnected WSL home cannot accumulate native requests across polls. + const probe = createAuthFilesystemOperation(authPath, () => + access(authPath).then( + () => true, + () => false + ) + ) + authPresenceProbeByPath.set(authPath, probe) + const clearProbe = (): void => { + if (authPresenceProbeByPath.get(authPath) === probe) { + authPresenceProbeByPath.delete(authPath) + } + } + void probe.result.then(clearProbe, clearProbe) + return probe +} // Why: the background quota poller spawns the real `codex` binary to read rate // limits. For users who installed Codex but never signed in, that spawn can @@ -8,12 +44,21 @@ import { join } from 'node:path' // the background. A signed-in Codex always writes an auth.json under its // CODEX_HOME, so gating the fetch on that file keeps the poller silent until // the user actually uses Codex. -export function codexAuthExists(codexHomePath?: string | null): boolean { +export async function codexAuthExists( + codexHomePath?: string | null, + options: CodexAuthPresenceOptions = {} +): Promise { // Mirror Codex's own home resolution: an explicit managed-account home wins, // then CODEX_HOME, then the default ~/.codex. const home = codexHomePath ?? process.env.CODEX_HOME ?? join(homedir(), '.codex') + const authPath = join(home, 'auth.json') + const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? AUTH_PRESENCE_TIMEOUT_MS) + const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal try { - return existsSync(join(home, 'auth.json')) + // Why: managed WSL homes are UNC paths. A synchronous stat can park + // Electron main while Windows wakes or reconnects the distro; the race + // also keeps a disconnected distro from serializing all later refreshes. + return await getAuthPresenceProbe(authPath).wait(signal) } catch { return false } diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts new file mode 100644 index 00000000000..8a0e2ee7028 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -0,0 +1,194 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, readFileMock, ptySpawnMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + readFileMock: vi.fn(), + ptySpawnMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) +vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) +vi.mock('./codex-auth-presence', () => ({ codexAuthExists: vi.fn(async () => true) })) + +import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' + +describe('Codex backend rate-limit requests', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('uses the official backend usage contract without spawning Codex', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + tokens: { access_token: 'access-token', account_id: 'account-id' } + }) + ) + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + plan_type: 'plus', + rate_limit: { + primary_window: { + used_percent: 12, + limit_window_seconds: 3_600, + reset_at: 1_800_000_000 + }, + secondary_window: { + used_percent: 34, + limit_window_seconds: 86_400, + reset_at: 1_800_100_000 + } + }, + rate_limit_reset_credits: { available_count: 1 } + }) + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + available_count: 1, + credits: [ + { + status: 'available', + expires_at: '2027-01-15T12:00:00Z', + granted_at: '2027-01-08T12:00:00Z' + } + ] + }) + } as Response) + + await expect( + fetchCodexRateLimits({ + codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' + }) + ).resolves.toMatchObject({ + session: { usedPercent: 12, windowMinutes: 60, resetsAt: 1_800_000_000_000 }, + weekly: { usedPercent: 34, windowMinutes: 1440, resetsAt: 1_800_100_000_000 }, + rateLimitResetCredits: { + availableCount: 1, + nextExpiresAt: Date.parse('2027-01-15T12:00:00Z') + }, + status: 'ok' + }) + + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + expect(fetch).toHaveBeenNthCalledWith( + 1, + 'https://chatgpt.com/backend-api/wham/usage', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer access-token' }), + signal: expect.any(AbortSignal) + }) + ) + }) + + it('aborts callers while sharing one stalled backend auth read', async () => { + let resolveRead!: (content: string) => void + readFileMock.mockImplementation( + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + const firstController = new AbortController() + const secondController = new AbortController() + const codexHomePath = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + + const first = fetchCodexRateLimits({ codexHomePath, signal: firstController.signal }) + const second = fetchCodexRateLimits({ codexHomePath, signal: secondController.signal }) + await vi.advanceTimersByTimeAsync(0) + expect(readFileMock).toHaveBeenCalledTimes(1) + expect(readFileMock).toHaveBeenCalledWith(expect.stringContaining('auth.json'), 'utf8') + + firstController.abort() + secondController.abort() + + await expect(first).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + await expect(second).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(fetch).not.toHaveBeenCalled() + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + resolveRead('{}') + await vi.advanceTimersByTimeAsync(0) + }) + + it('applies the backend deadline while auth reading is still pending', async () => { + let resolveRead!: (content: string) => void + readFileMock.mockImplementation( + () => + new Promise((resolve) => { + resolveRead = resolve + }) + ) + const timeoutController = new AbortController() + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValueOnce(timeoutController.signal) + const deadlineError = new Error('backend deadline') + + const result = consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/deadline-home', + idempotencyKey: 'redeem-timeout' + }) + await vi.advanceTimersByTimeAsync(0) + // Why: redeem is user-triggered, so it gets the longer redeem deadline. + expect(timeout).toHaveBeenCalledWith(30_000) + expect(readFileMock).toHaveBeenCalledWith(join('/managed/deadline-home', 'auth.json'), 'utf8') + + timeoutController.abort(deadlineError) + + await expect(result).rejects.toBe(deadlineError) + resolveRead('{}') + await vi.advanceTimersByTimeAsync(0) + }) + + it('consumes a reset credit with the official payload and bounded request signal', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + tokens: { access_token: 'access-token', account_id: 'account-id' } + }) + ) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ code: 'already_redeemed' }) + } as Response) + + await expect( + consumeCodexRateLimitResetCredit({ + codexHomePath: '/managed/codex-home', + idempotencyKey: 'redeem-123' + }) + ).resolves.toBe('alreadyRedeemed') + + expect(fetch).toHaveBeenCalledWith( + 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ redeem_request_id: 'redeem-123' }), + signal: expect.any(AbortSignal), + headers: expect.objectContaining({ + Authorization: 'Bearer access-token', + 'ChatGPT-Account-Id': 'account-id', + 'Content-Type': 'application/json' + }) + }) + ) + }) +}) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index daaec0ce7fd..a2031ac9941 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -77,7 +77,7 @@ describe('fetchCodexRateLimits', () => { vi.useFakeTimers() vi.clearAllMocks() resolveCodexCommandMock.mockReturnValue('codex') - vi.mocked(codexAuthExists).mockReturnValue(true) + vi.mocked(codexAuthExists).mockResolvedValue(true) readFileMock.mockRejectedValue(new Error('no auth fixture')) vi.stubGlobal('fetch', vi.fn()) }) @@ -87,7 +87,7 @@ describe('fetchCodexRateLimits', () => { }) it('does not spawn Codex when the user is not signed in', async () => { - vi.mocked(codexAuthExists).mockReturnValue(false) + vi.mocked(codexAuthExists).mockResolvedValue(false) await expect(fetchCodexRateLimits()).resolves.toMatchObject({ provider: 'codex', @@ -101,6 +101,28 @@ describe('fetchCodexRateLimits', () => { expect(ptySpawnMock).not.toHaveBeenCalled() }) + it('preserves the aborted result when cancellation lands during the auth check', async () => { + let resolveAuth!: (exists: boolean) => void + vi.mocked(codexAuthExists).mockReturnValueOnce( + new Promise((resolve) => { + resolveAuth = resolve + }) + ) + const controller = new AbortController() + + const result = fetchCodexRateLimits({ signal: controller.signal }) + controller.abort() + resolveAuth(false) + + await expect(result).resolves.toMatchObject({ + provider: 'codex', + status: 'error', + error: 'Rate-limit fetch aborted' + }) + expect(childSpawnMock).not.toHaveBeenCalled() + expect(ptySpawnMock).not.toHaveBeenCalled() + }) + it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => { const onDataDisposable = makeDisposable() const onExitDisposable = makeDisposable() @@ -133,6 +155,7 @@ describe('fetchCodexRateLimits', () => { childSpawnMock.mockReturnValue(rpcChild) const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(0) const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string expect(spawnCwd).toContain('rate-limit-pty-cwd') @@ -168,6 +191,7 @@ describe('fetchCodexRateLimits', () => { const controller = new AbortController() const resultPromise = fetchCodexRateLimits({ signal: controller.signal }) + await vi.advanceTimersByTimeAsync(0) controller.abort() @@ -221,6 +245,7 @@ describe('fetchCodexRateLimits', () => { }) const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) @@ -247,6 +272,7 @@ describe('fetchCodexRateLimits', () => { childSpawnMock.mockReturnValue(rpcChild) const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) @@ -421,6 +447,7 @@ describe('fetchCodexRateLimits', () => { expect(fetch).toHaveBeenCalledWith( 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', expect.objectContaining({ + signal: expect.any(AbortSignal), headers: expect.objectContaining({ Authorization: 'Bearer access-token', 'ChatGPT-Account-Id': 'account-id', @@ -536,13 +563,25 @@ describe('fetchCodexRateLimits', () => { const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain( - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'sh', '-c']) + const shellCommand = spawnArgs.at(-1) as string + expect(shellCommand).toContain('_orca_wsl_shell=\\$(getent passwd') + expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).toContain( + 'exec 3<&0\nexec 4>&1\nexec /dev/null\n_orca_wsl_shell=' ) - expect(bashCommand).toContain("exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'") + expect(shellCommand).toContain('mkdir -p "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain('cd "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain( + "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" + ) + expect(shellCommand).toContain( + "exec codex '\\''-s'\\'' '\\''read-only'\\'' '\\''-a'\\'' '\\''untrusted'\\'' '\\''app-server'\\'' <&3 >&4 3<&- 4>&-" + ) + expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) + expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) + expect(shellCommand).not.toContain('_orca_codex') + expect(shellCommand).not.toContain('wsl-codex-path') expect(spawnOptions).toEqual( expect.objectContaining({ cwd: expect.stringContaining('rate-limit-pty-cwd'), @@ -557,6 +596,62 @@ describe('fetchCodexRateLimits', () => { } }) + it('routes Windows host Codex homes through the host RPC path', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { rateLimits: { primary: { usedPercent: 13 } } } + })}\n` + ) + ) + }, 0) + } + }) + + try { + const resultPromise = fetchCodexRateLimits({ codexHomePath: 'C:\\Users\\alice\\.codex' }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + await resultPromise + + const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] + expect(spawnFile).toBe('codex') + expect(spawnArgs).toEqual(['-s', 'read-only', '-a', 'untrusted', 'app-server']) + expect(spawnOptions).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ CODEX_HOME: 'C:\\Users\\alice\\.codex' }) + }) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('runs rate-limit PTY fallback through WSL when RPC cannot read usage', async () => { const originalPlatform = process.platform const originalCodexHome = process.env.CODEX_HOME @@ -583,18 +678,28 @@ describe('fetchCodexRateLimits', () => { const resultPromise = fetchCodexRateLimits({ codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\account\\home' }) + await vi.advanceTimersByTimeAsync(0) rpcChild.emit('close') await vi.advanceTimersByTimeAsync(0) const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0] expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain( - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'sh', '-c']) + const shellCommand = spawnArgs.at(-1) as string + expect(shellCommand).toContain('_orca_wsl_shell=\\$(getent passwd') + expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).not.toContain('exec 3<&0') + expect(shellCommand).not.toContain('exec /dev/null') + expect(shellCommand).not.toContain('<&3 >&4 3<&- 4>&-') + expect(shellCommand).toContain('mkdir -p "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain('cd "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain( + "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" ) - expect(bashCommand).toContain('exec codex ') + expect(shellCommand).toContain('exec codex ') + expect(shellCommand).not.toContain('_orca_codex') + expect(shellCommand).not.toContain('wsl-codex-path') expect(spawnOptions).toEqual( expect.objectContaining({ cwd: expect.stringContaining('rate-limit-pty-cwd'), diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 28ef1b710fd..8a3ed94bdff 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -17,14 +17,26 @@ import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows +} from '../../shared/wsl-login-shell-command' import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' +import { + createAuthFilesystemOperation, + type SharedAuthFilesystemOperation +} from './auth-filesystem-operation' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 const PTY_TIMEOUT_MS = 15_000 +const BACKEND_TIMEOUT_MS = 10_000 +// Why: redeeming a reset credit is an explicit user action, not a background +// poll — give it more room before failing so a slow backend can still finish. +const REDEEM_BACKEND_TIMEOUT_MS = 30_000 const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000 export type FetchCodexRateLimitsOptions = { @@ -98,6 +110,21 @@ type BackendRateLimitResetCreditsResponse = { }[] } +type BackendRateLimitWindow = { + used_percent?: number + limit_window_seconds?: number + reset_at?: number +} + +type BackendUsageResponse = { + plan_type?: string + rate_limit?: { + primary_window?: BackendRateLimitWindow | null + secondary_window?: BackendRateLimitWindow | null + } | null + rate_limit_reset_credits?: BackendRateLimitResetCreditsResponse | null +} + type BackendConsumeRateLimitResetCreditResponse = { code?: string } @@ -106,13 +133,23 @@ type CodexBackendAuthHeaders = { headers: Record } +type BackendAuthReadResult = + | { content: string; error?: never } + | { content?: never; error: unknown } + +const backendAuthReadByPath = new Map< + string, + SharedAuthFilesystemOperation +>() + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } function buildWslCodexCommand( codexHomePath: string, - args: string[] + args: string[], + options?: { isolateRpcStdio?: boolean } ): { command: string args: string[] @@ -121,14 +158,27 @@ function buildWslCodexCommand( if (process.platform !== 'win32' || !wslInfo) { return null } - const script = [ + const setupCommands = [ ...getHiddenRateLimitWslCwdSetupCommands(), - `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`, - `exec codex ${args.map(shellQuote).join(' ')}` + `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}` ].join(' && ') + const execSuffix = `${args.map(shellQuote).join(' ')}${ + options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' + }` + // Why: npm/nvm Codex launchers use `#!/usr/bin/env node`. Resolving an + // absolute launcher in a login shell and later execing it from plain `sh` + // loses the PATH that supplies Node and also pins obsolete installations. + const loginShellCommand = buildWslLoginShellCommand( + [setupCommands, `exec codex ${execSuffix}`].join(' && ') + ) + // Why: keep the outer sh non-login and hide RPC pipes before the configured + // shell startup can read input or print banners. + const command = options?.isolateRpcStdio + ? ['exec 3<&0', 'exec 4>&1', 'exec /dev/null', loginShellCommand].join('\n') + : loginShellCommand return { command: 'wsl.exe', - args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script] + args: ['-d', wslInfo.distro, '--', 'sh', '-c', escapeWslShCommandForWindows(command)] } } @@ -242,14 +292,57 @@ function mapBackendRateLimitResetCredits( } } +function createBackendRequestSignal( + callerSignal?: AbortSignal, + timeoutMs = BACKEND_TIMEOUT_MS +): AbortSignal { + const timeoutSignal = AbortSignal.timeout(timeoutMs) + return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal +} + +function getBackendAuthRead( + authPath: string +): SharedAuthFilesystemOperation { + const existing = backendAuthReadByPath.get(authPath) + if (existing) { + return existing + } + // Why: the caller deadline must settle promptly, but Node cannot guarantee + // cancellation of an already-issued UNC read. Keep one raw read per auth + // path until the OS finishes so repeated quota refreshes cannot stack them. + const read = createAuthFilesystemOperation(authPath, () => + readFile(authPath, 'utf8').then( + (content) => ({ content }), + (error: unknown) => ({ error }) + ) + ) + backendAuthReadByPath.set(authPath, read) + const clearRead = (): void => { + if (backendAuthReadByPath.get(authPath) === read) { + backendAuthReadByPath.delete(authPath) + } + } + void read.result.then(clearRead, clearRead) + return read +} + +async function readBackendAuth(authPath: string, signal: AbortSignal): Promise { + const result = await getBackendAuthRead(authPath).wait(signal) + if ('error' in result) { + throw result.error + } + return result.content +} + async function getCodexBackendAuthHeaders( - options?: FetchCodexRateLimitsOptions + options: FetchCodexRateLimitsOptions | undefined, + signal: AbortSignal ): Promise { - if (options?.signal?.aborted) { + if (signal.aborted) { return null } const authPath = join(getCodexHomePath(options?.codexHomePath), 'auth.json') - const auth = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile + const auth = JSON.parse(await readBackendAuth(authPath, signal)) as CodexAuthFile const accessToken = auth.tokens?.access_token if (!accessToken) { return null @@ -273,18 +366,19 @@ async function fetchBackendRateLimitResetCredits( if (options?.signal?.aborted) { return null } - const auth = await getCodexBackendAuthHeaders(options) + const signal = createBackendRequestSignal(options?.signal) + const auth = await getCodexBackendAuthHeaders(options, signal) if (!auth) { return null } - if (options?.signal?.aborted) { + if (signal.aborted) { return null } // Why: published Codex 0.140 can read windows through app-server but strips // reset-credit metadata that the backend already returns. const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', { ...auth, - signal: options?.signal + signal }) if (!response.ok) { return null @@ -336,7 +430,8 @@ export async function consumeCodexRateLimitResetCredit(options: { if (!options.idempotencyKey.trim()) { throw new Error('Codex reset idempotency key is required') } - const auth = await getCodexBackendAuthHeaders(options) + const signal = createBackendRequestSignal(undefined, REDEEM_BACKEND_TIMEOUT_MS) + const auth = await getCodexBackendAuthHeaders(options, signal) if (!auth) { throw new Error('Codex not signed in') } @@ -348,7 +443,8 @@ export async function consumeCodexRateLimitResetCredit(options: { ...auth.headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ redeem_request_id: options.idempotencyKey }) + body: JSON.stringify({ redeem_request_id: options.idempotencyKey }), + signal } ) if (!response.ok) { @@ -362,13 +458,13 @@ function mapRpcWindow( raw: RpcRateWindow | undefined, expectedWindowMinutes: number ): RateLimitWindow | null { - if (!raw || typeof raw.usedPercent !== 'number') { + if (!raw || typeof raw.usedPercent !== 'number' || !Number.isFinite(raw.usedPercent)) { return null } let resetDescription: string | null = null let resetsAt: number | null = null - if (raw.resetsAt) { + if (typeof raw.resetsAt === 'number' && Number.isFinite(raw.resetsAt) && raw.resetsAt > 0) { // Why: Codex returns resetsAt as Unix seconds, not milliseconds. const date = new Date(raw.resetsAt * 1000) if (!Number.isNaN(date.getTime())) { @@ -395,6 +491,71 @@ function mapRpcWindow( } } +function mapBackendUsageWindow( + raw: BackendRateLimitWindow | null | undefined, + fallbackWindowMinutes: number +): RateLimitWindow | null { + const limitWindowSeconds = raw?.limit_window_seconds + // Match Codex backend-client's `window_minutes_from_seconds`: the backend + // field is the actual bucket duration and rounds partial minutes upward. + const windowMinutes = + typeof limitWindowSeconds === 'number' && + Number.isFinite(limitWindowSeconds) && + limitWindowSeconds > 0 + ? Math.ceil(limitWindowSeconds / 60) + : fallbackWindowMinutes + return mapRpcWindow( + raw + ? { + usedPercent: raw.used_percent, + resetsAt: raw.reset_at + } + : undefined, + windowMinutes + ) +} + +async function fetchViaBackend( + options?: FetchCodexRateLimitsOptions +): Promise { + const signal = createBackendRequestSignal(options?.signal) + const auth = await getCodexBackendAuthHeaders(options, signal) + if (!auth || signal.aborted) { + return null + } + // Why: Codex itself reads this endpoint in backend-client's + // `get_rate_limit_status`; using the same contract avoids launching a hidden + // app-server (and a WSL login shell) for every routine quota refresh. + const response = await fetch('https://chatgpt.com/backend-api/wham/usage', { + ...auth, + signal + }) + if (!response.ok) { + return null + } + const payload = (await response.json()) as BackendUsageResponse + // `plan_type` is required by Codex's RateLimitStatusPayload. Reject a + // superficially successful but unrelated/malformed JSON response so the + // established app-server fallback still gets a chance. + if (typeof payload.plan_type !== 'string') { + return null + } + return { + provider: 'codex', + session: mapBackendUsageWindow(payload.rate_limit?.primary_window, 300), + weekly: mapBackendUsageWindow(payload.rate_limit?.secondary_window, 10080), + ...(payload.rate_limit_reset_credits !== undefined + ? { + rateLimitResetCredits: + mapBackendRateLimitResetCredits(payload.rate_limit_reset_credits) ?? null + } + : {}), + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + // --------------------------------------------------------------------------- // RPC fetch — spawn `codex -s read-only -a untrusted app-server` // --------------------------------------------------------------------------- @@ -411,7 +572,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { listWorktreesMock.mockResolvedValue([ { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } ]) + const signal = new AbortController().signal - const result = await listRepoWorktrees({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - kind: 'git' - }) + const result = await listRepoWorktrees( + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' + }, + { signal } + ) - expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo') + expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { signal }) expect(result).toHaveLength(1) }) diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index e02ba35f9c7..4f45e7d8564 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -9,6 +9,10 @@ type LocalRepoWorktreeListOptions = { signal?: AbortSignal } +function hasLocalRepoWorktreeListOptions(options: LocalRepoWorktreeListOptions | undefined) { + return options?.wslDistro !== undefined || options?.signal !== undefined +} + export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean { return repos.some( (repo) => !repo.connectionId && areWorktreePathsEqual(repo.path, resolvedTarget) @@ -30,7 +34,7 @@ export function createFolderWorktree(repo: Repo): GitWorktreeInfo { export async function listRepoWorktrees( repo: Repo, - options: LocalRepoWorktreeListOptions = {} + options?: LocalRepoWorktreeListOptions ): Promise { if (isFolderRepo(repo)) { return [createFolderWorktree(repo)] @@ -42,7 +46,7 @@ export async function listRepoWorktrees( // local git against a server path. return provider ? await provider.listWorktrees(repo.path) : [] } - return options.wslDistro + return hasLocalRepoWorktreeListOptions(options) ? await listWorktrees(repo.path, options) : await listWorktrees(repo.path) } diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index 56581919edc..a38084c5199 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -74,13 +74,21 @@ const store = { }) } -function createRuntime() { - const runtime = new OrcaRuntimeService(store) +// Why (#7588): the held-modal repro needs indefinite hold (null) while the +// legacy tests rely on the finite 5s default. Wrap getSettings per-test so a +// caller can pick the hold without mutating the shared stub. +function createRuntime(mobileAutoRestoreFitMs: number | null = 5_000) { + const effectiveStore = { + ...store, + getSettings: () => ({ ...store.getSettings(), mobileAutoRestoreFitMs }) + } + const runtime = new OrcaRuntimeService(effectiveStore) const ptySizes = new Map([ ['pty-1', { cols: 150, rows: 40 }] ]) const resizes: { ptyId: string; cols: number; rows: number }[] = [] const driverEvents: { ptyId: string; driver: { kind: string; clientId?: string } }[] = [] + const fitOverrideEvents: { ptyId: string; mode: string; cols: number; rows: number }[] = [] let resizeSucceeds = true runtime.setPtyController({ @@ -107,7 +115,9 @@ function createRuntime() { focusTerminal: vi.fn(), closeTerminal: vi.fn(), sleepWorktree: vi.fn(), - terminalFitOverrideChanged: vi.fn(), + terminalFitOverrideChanged: (ptyId, mode, cols, rows) => { + fitOverrideEvents.push({ ptyId, mode, cols, rows }) + }, terminalDriverChanged: (ptyId, driver) => { driverEvents.push({ ptyId, driver: { ...driver } }) } @@ -118,6 +128,7 @@ function createRuntime() { ptySizes, resizes, driverEvents, + fitOverrideEvents, setResizeSucceeds: (next: boolean) => { resizeSucceeds = next } @@ -314,11 +325,12 @@ describe('mobile presence lock — multi-mobile semantics', () => { }) it('updateMobileViewport re-fits PTY without flipping the driver', async () => { - const { runtime, ptySizes, driverEvents } = createRuntime() + const { runtime, ptySizes, driverEvents, fitOverrideEvents } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 }) expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 38 }) expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) const before = driverEvents.length + const fitEventsBefore = fitOverrideEvents.length // Keyboard opens — viewport shrinks. await expect( @@ -330,6 +342,10 @@ describe('mobile presence lock — multi-mobile semantics', () => { // Why: a viewport update may re-emit driver to refresh listener // wiring, but it must never go through `idle` (no banner flash). expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true) + // Why: phone→phone dim ticks (keyboard show/hide) are the hottest layout + // path and must not wake the renderer's fit-override listeners — the + // emit gate opens only when layout kind or override presence changes. + expect(fitOverrideEvents.length).toBe(fitEventsBefore) }) it('updateMobileViewport late-binds a viewport-less mobile subscriber', async () => { @@ -467,3 +483,252 @@ describe('mobile presence lock — multi-mobile semantics', () => { expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) }) }) + +// Why (#7588): drive the runtime into the reported held-modal state — a phone +// fit that an indefinite hold left behind, followed by a null-viewport +// resubscribe (app update / WebView reload) that re-registers an active +// subscriber with wasResizedToPhone=false while the override is still held. +// This is the state where the desktop "Your phone left this at phone size" +// modal's Restore buttons used to silently no-op. +async function reachHeldModalWithNullViewportResubscribe( + runtime: OrcaRuntimeService +): Promise { + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(300) + // Production RPC passes `params.viewport` straight through, so a client + // that hasn't measured yet arrives here as `undefined`. + await runtime.handleMobileSubscribe('pty-1', 'phone-A', undefined) +} + +describe('mobile presence lock — issue #7588 held-modal restore convergence', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + // Scenario 1: the reported repro end-to-end. Restore must converge and + // notify BOTH the renderer notifier and a runtime listener (paired), since + // remote/web viewers ride the listener channel. + it('reclaim after a null-viewport resubscribe restores dims, clears override, notifies both channels', async () => { + const { runtime, ptySizes, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await reachHeldModalWithNullViewportResubscribe(runtime) + // Held state: driver idle, override still present, phone-sized PTY. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'idle' }) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(true) + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + }) + + // Scenario 2: a second click after success is an idempotent no-op. The + // persistent null-viewport subscriber keeps reclaim in the active-subscriber + // branch; assert only "returns true, no new PTY resize, no new fit-override + // event" — a benign mobile-facing mode-change notify is acceptable. + it('second Restore click after success returns true with no new resize or fit-override event', async () => { + const { runtime, resizes, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await reachHeldModalWithNullViewportResubscribe(runtime) + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + + const resizeCount = resizes.length + const notifierCount = fitOverrideEvents.length + const listenerCount = listenerEvents.length + + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true) + expect(resizes.length).toBe(resizeCount) + expect(fitOverrideEvents.length).toBe(notifierCount) + expect(listenerEvents.length).toBe(listenerCount) + }) + + // Scenario 3: the existing driving take-back is unregressed — a phone that is + // actively driving (wasResizedToPhone=true) still flips to desktop, clears + // the override, and notifies both channels. + it('driving take-back still converges: driver → desktop, override cleared, both channels notified', async () => { + const { runtime, ptySizes, fitOverrideEvents } = createRuntime() + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(runtime.getDriver('pty-1').kind).toBe('mobile') + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(true) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' }) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(fitOverrideEvents.some((e) => e.mode === 'desktop-fit')).toBe(true) + expect(listenerEvents.some((e) => e.mode === 'desktop-fit')).toBe(true) + }) + + // Scenario 4: a failing resize during a HELD (no-subscriber) restore returns + // false and keeps the override — no lying, no phantom desktop-fit event. + it('held restore with a failing resize returns false and keeps the override', async () => { + const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + // Last leaver under indefinite hold → override held, no active subscriber. + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + await vi.advanceTimersByTimeAsync(300) + expect(runtime.isMobileSubscriberActive('pty-1')).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + setResizeSucceeds(false) + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe( + false + ) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(false) + }) + + // Scenario 5: a failing resize during an ACTIVE-SUBSCRIBER take-back returns + // false, keeps the override, leaves the driver on its mobile lock, and + // restores the prior display mode ('auto') — the fix #3 P1 correction. + it('active-subscriber take-back with a failing resize returns false and preserves the mobile lock', async () => { + const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime() + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + setResizeSucceeds(false) + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + // Driver stays mobile (lock retained) and mode is not left lying at 'desktop'. + expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' }) + expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto') + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe( + false + ) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(false) + }) + + // Scenario 5b: after a FAILED active-subscriber take-back, wasResizedToPhone + // must be re-armed so a later unsubscribe under a finite auto-restore setting + // still schedules its timer and eventually clears the override. Without the + // re-arm the flag would be stuck false and the phone-fit would strand. + it('failed take-back re-arms wasResizedToPhone so a later unsubscribe still auto-restores', async () => { + // Finite auto-restore (5s default from the rig store). + const { runtime, ptySizes, setResizeSucceeds } = createRuntime() + + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 }) + + // Take-back fails → false, flag re-armed, mode rolled back to 'auto'. + setResizeSucceeds(false) + expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + // Phone then leaves the terminal. Resize works again for the auto-restore. + setResizeSucceeds(true) + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + // Soft-leave grace, then the finite auto-restore timer. + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(5_000) + + // The scheduled auto-restore fired: override cleared, PTY back to desktop. + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + }) + + // Scenario 6: a phone-initiated setDisplayMode('desktop') against a stale + // held override converges through the shared applyMobileDisplayMode seam. + it('phone-initiated setDisplayMode(desktop) against a stale held override converges', async () => { + const { runtime, ptySizes, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + await reachHeldModalWithNullViewportResubscribe(runtime) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const notifierBefore = fitOverrideEvents.length + const listenerBefore = listenerEvents.length + + runtime.setMobileDisplayMode('pty-1', 'desktop') + const converged = await runtime.applyMobileDisplayMode('pty-1') + + expect(converged).toBe(true) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 }) + expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true) + }) + + // Scenario 7 (white-box): a held override whose `layouts` entry is gone is + // unreachable via public APIs (onPtyExit deletes both in lockstep), so seed + // it directly. Reclaim must still delete the override and emit a paired + // desktop-fit 0×0 rather than stranding the modal on the next hydrate. + it('orphan cleanup: reclaim on a held override with no layout entry converges', async () => { + const { runtime, fitOverrideEvents } = createRuntime(null) + const listenerEvents: { mode: string; cols: number; rows: number }[] = [] + runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e)) + + const internal = runtime as unknown as { + terminalFitOverrides: Map< + string, + { + mode: string + cols: number + rows: number + previousCols: number | null + previousRows: number | null + updatedAt: number + clientId: string + } + > + } + internal.terminalFitOverrides.set('pty-1', { + mode: 'mobile-fit', + cols: 45, + rows: 20, + previousCols: 150, + previousRows: 40, + updatedAt: Date.now(), + clientId: 'phone-A' + }) + expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull() + + const restored = await runtime.reclaimTerminalForDesktop('pty-1') + + expect(restored).toBe(true) + expect(runtime.getTerminalFitOverride('pty-1')).toBeNull() + expect(fitOverrideEvents.find((e) => e.mode === 'desktop-fit')).toEqual({ + ptyId: 'pty-1', + mode: 'desktop-fit', + cols: 0, + rows: 0 + }) + expect(listenerEvents.find((e) => e.mode === 'desktop-fit')).toEqual({ + mode: 'desktop-fit', + cols: 0, + rows: 0 + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-terminal-cwd.test.ts b/src/main/runtime/orca-runtime-terminal-cwd.test.ts index 214d2a20894..976b8169fdc 100644 --- a/src/main/runtime/orca-runtime-terminal-cwd.test.ts +++ b/src/main/runtime/orca-runtime-terminal-cwd.test.ts @@ -52,7 +52,7 @@ describe('OrcaRuntimeService terminal startup cwd', () => { ) }) - it('rejects requested terminal cwd values outside the selected worktree', async () => { + it('spawns terminals at a requested cwd outside the selected worktree (#7685)', async () => { const runtime = new OrcaRuntimeService() stubLaunchScope(runtime) const spawn = vi.fn().mockResolvedValue({ id: 'pty-1' }) @@ -63,10 +63,14 @@ describe('OrcaRuntimeService terminal startup cwd', () => { getForegroundProcess: async () => null }) - await expect(runtime.createTerminal('id:wt-1', { cwd: '/repo/app-other' })).rejects.toThrow( - 'Terminal cwd must be inside the selected worktree.' + await runtime.createTerminal('id:wt-1', { cwd: '/repo/app-other' }) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/repo/app-other', + worktreeId: 'wt-1' + }) ) - expect(spawn).not.toHaveBeenCalled() }) it('reveals main-spawned terminals with their nested startup cwd', async () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d2655b3d372..f72d94c4f9f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -5,6 +5,7 @@ import { performance } from 'node:perf_hooks' import { EventEmitter } from 'node:events' import { randomUUID } from 'node:crypto' import { execFileSync } from 'node:child_process' +import { mkdirSync } from 'node:fs' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,7 +21,7 @@ import type { WorkspaceSessionState } from '../../shared/types' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' -import { MAX_OSC_TITLE_CHARS } from '../../shared/agent-detection' +import { detectAgentStatusFromTitle, MAX_OSC_TITLE_CHARS } from '../../shared/agent-detection' import { addWorktree, assertWorktreeCleanForRemoval, @@ -60,7 +61,13 @@ import { TERMINAL_INPUT_MAX_BYTES, TERMINAL_INPUT_TOO_LARGE_ERROR } from '../../shared/terminal-input' +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + AGENT_PROMPT_BRACKETED_PASTE_START, + buildAgentPromptPasteBytes +} from '../../shared/agent-prompt-injection' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider @@ -5250,6 +5257,406 @@ describe('OrcaRuntimeService', () => { } }) + it('keeps existing-folder imports split by runtime host on the same normalized path', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-project-host-')) + const repos: Record[] = [] + getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + }, + getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never, + getProjectHostSetups: () => + projectHostSetupProjectionFromRepos(repos as never).setups as never + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + execFileSync('git', ['init'], { cwd: tempRoot, stdio: 'ignore' }) + const first = await runtime.setupProjectExistingFolder({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:env-1', + path: tempRoot, + kind: 'git', + setupMethod: 'imported-existing-folder' + }) + const second = await runtime.setupProjectExistingFolder({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:env-2', + path: tempRoot, + kind: 'git', + setupMethod: 'imported-existing-folder' + }) + + expect(repos).toHaveLength(2) + expect(repos).toEqual([ + expect.objectContaining({ + path: tempRoot, + executionHostId: 'runtime:env-1' + }), + expect.objectContaining({ + path: tempRoot, + executionHostId: 'runtime:env-2' + }) + ]) + expect(first.repo).toMatchObject({ + path: tempRoot, + executionHostId: 'runtime:env-1' + }) + expect(first.setup).toMatchObject({ + repoId: first.repo.id, + hostId: 'runtime:env-1' + }) + expect(second.repo).toMatchObject({ + path: tempRoot, + executionHostId: 'runtime:env-2' + }) + expect(second.setup).toMatchObject({ + repoId: second.repo.id, + hostId: 'runtime:env-2' + }) + expect(first.repo.id).not.toBe(second.repo.id) + expect(first.setup.repoId).not.toBe(second.setup.repoId) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('keeps path-only runtime addRepo reuse working when a host-qualified repo already exists', async () => { + const repos: Record[] = [ + { + id: 'repo-runtime-1', + path: '/tmp/runtime-shared', + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + kind: 'folder', + executionHostId: 'runtime:env-1' + } + ] + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const repo = await runtime.addRepo('/tmp/runtime-shared', 'folder') + + expect(repo).toMatchObject({ + id: 'repo-runtime-1', + path: '/tmp/runtime-shared', + executionHostId: 'runtime:env-1' + }) + expect(repos).toHaveLength(1) + }) + + it('does not hijack a legacy SSH repo at the same path into a runtime host', async () => { + // A legacy SSH repo resolves to `ssh:` even with a null executionHostId, + // so a same-path runtime import must create a new repo instead of adopting it. + const repos: Record[] = [ + { + id: 'repo-ssh-1', + path: '/workspace', + displayName: 'workspace', + badgeColor: 'blue', + addedAt: 1, + kind: 'folder', + connectionId: 'ssh-target-1' + } + ] + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const repo = await runtime.addRepo('/workspace', 'folder', 'runtime:env-1') + + expect(repos).toHaveLength(2) + expect(repo.id).not.toBe('repo-ssh-1') + expect(repo).toMatchObject({ path: '/workspace', executionHostId: 'runtime:env-1' }) + // The legacy SSH repo must be untouched (no executionHostId stamped onto it). + expect(repos[0]).toMatchObject({ id: 'repo-ssh-1', connectionId: 'ssh-target-1' }) + expect(repos[0]).not.toHaveProperty('executionHostId') + }) + + it('only a runtime host adopts an unstamped repo; local/ssh imports never stamp it', async () => { + // A genuine local repo is indistinguishable from a legacy runtime repo (both have null + // executionHostId and connectionId). Only a runtime host may backfill it — a local or ssh + // import at the same path must leave it untouched rather than re-attribute it to that host. + for (const importHostId of ['local', 'ssh:ssh-target-9'] as const) { + const repos: Record[] = [ + { + id: 'repo-local-1', + path: '/workspace', + displayName: 'workspace', + badgeColor: 'blue', + addedAt: 1, + kind: 'folder' + } + ] + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const repo = await runtime.addRepo('/workspace', 'folder', importHostId) + + // The matched repo is returned unchanged — no new repo, no executionHostId stamped. + expect(repos).toHaveLength(1) + expect(repo.id).toBe('repo-local-1') + expect(repos[0]).not.toHaveProperty('executionHostId') + } + }) + + it('keeps project clone setup on the cloned host-qualified repo', async () => { + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) + const clonePath = join(destination, 'orca') + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const repos: Record[] = [] + getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + }, + getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never, + getProjectHostSetups: () => + projectHostSetupProjectionFromRepos(repos as never).setups as never + } + spawnSpy.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => { + mkdirSync(clonePath, { recursive: true }) + execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) + proc.emit('close', 0, null) + }) + return proc as never + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + const result = await runtime.setupProjectClone({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:env-1', + url: 'https://example.com/orca.git', + destination + }) + + expect(repos).toHaveLength(1) + expect(result.repo).toMatchObject({ + path: clonePath, + executionHostId: 'runtime:env-1', + projectHostSetupMethod: 'cloned' + }) + expect(result.setup).toMatchObject({ + repoId: result.repo.id, + hostId: 'runtime:env-1', + setupMethod: 'cloned' + }) + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + + it('adopts public clone repos into host-qualified project setup', async () => { + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) + const clonePath = join(destination, 'orca') + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const repos: Record[] = [] + getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + }, + getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never, + getProjectHostSetups: () => + projectHostSetupProjectionFromRepos(repos as never).setups as never + } + spawnSpy.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => { + mkdirSync(clonePath, { recursive: true }) + execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) + proc.emit('close', 0, null) + }) + return proc as never + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + const cloned = await runtime.cloneRepo('https://example.com/orca.git', destination) + expect(cloned).toMatchObject({ + path: clonePath + }) + expect(cloned).not.toHaveProperty('executionHostId') + + const result = await runtime.setupProjectExistingFolder({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:env-1', + path: clonePath, + kind: 'git', + setupMethod: 'cloned' + }) + + expect(repos).toHaveLength(1) + expect(result.repo).toMatchObject({ + id: cloned.id, + path: clonePath, + executionHostId: 'runtime:env-1', + projectHostSetupMethod: 'cloned' + }) + expect(result.setup).toMatchObject({ + repoId: cloned.id, + hostId: 'runtime:env-1', + setupMethod: 'cloned' + }) + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + + it('keeps project clone repos split by runtime host on the same clone path', async () => { + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) + const clonePath = join(destination, 'orca') + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const repos: Record[] = [ + { + id: 'repo-host-a', + path: clonePath, + displayName: 'orca', + badgeColor: 'blue', + addedAt: 1, + kind: 'git', + executionHostId: 'runtime:env-1' + } + ] + getRepoUpstreamMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + updateRepo: (id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + }, + getProjects: () => projectHostSetupProjectionFromRepos(repos as never).projects as never, + getProjectHostSetups: () => + projectHostSetupProjectionFromRepos(repos as never).setups as never + } + spawnSpy.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => { + mkdirSync(clonePath, { recursive: true }) + execFileSync('git', ['init'], { cwd: clonePath, stdio: 'ignore' }) + proc.emit('close', 0, null) + }) + return proc as never + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + const result = await runtime.setupProjectClone({ + projectId: 'github:stablyai/orca', + hostId: 'runtime:env-2', + url: 'https://example.com/orca.git', + destination + }) + + expect(repos).toHaveLength(2) + expect(repos[0]).toMatchObject({ + id: 'repo-host-a', + executionHostId: 'runtime:env-1' + }) + expect(result.repo).toMatchObject({ + path: clonePath, + executionHostId: 'runtime:env-2' + }) + expect(result.repo.id).not.toBe('repo-host-a') + expect(result.setup).toMatchObject({ + repoId: result.repo.id, + hostId: 'runtime:env-2', + setupMethod: 'cloned' + }) + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + it('defaults runtime createRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { const added: Record[] = [] const colorStore = { @@ -9298,6 +9705,110 @@ describe('OrcaRuntimeService', () => { expect(writes).toEqual(['continue', '\r']) }) + it('sends agent prompts as bracketed paste before submit', async () => { + vi.useFakeTimers() + try { + const writes: string[] = [] + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + const prompt = 'line one\nline two\x1b[201~' + + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + await vi.runAllTimersAsync() + const result = await sendPromise + + const pasted = [ + AGENT_PROMPT_BRACKETED_PASTE_START, + 'line one\nline two[201~', + AGENT_PROMPT_BRACKETED_PASTE_END + ].join('') + expect(result).toMatchObject({ + handle, + accepted: true, + bytesWritten: Buffer.byteLength(`${pasted}\r`, 'utf8') + }) + expect(writes).toEqual([pasted, '\r']) + } finally { + vi.useRealTimers() + } + }) + + it('chunks large agent prompt paste frames before delayed submit', async () => { + vi.useFakeTimers() + try { + const writes: string[] = [] + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + const prompt = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\ntail` + + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + await vi.runAllTimersAsync() + const result = await sendPromise + + const pasteWrites = writes.slice(0, -1) + expect(result.bytesWritten).toBe( + Buffer.byteLength(`${buildAgentPromptPasteBytes(prompt)}\r`, 'utf8') + ) + expect(writes.at(-1)).toBe('\r') + expect(pasteWrites.length).toBeGreaterThan(1) + expect(pasteWrites.join('')).toBe(buildAgentPromptPasteBytes(prompt)) + expect(pasteWrites[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_START) + expect(pasteWrites.at(-1)).toContain(AGENT_PROMPT_BRACKETED_PASTE_END) + } finally { + vi.useRealTimers() + } + }) + + it('closes an incomplete agent prompt paste when a later chunk write fails', async () => { + vi.useFakeTimers() + try { + const writes: string[] = [] + let writeCount = 0 + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: (_ptyId, data) => { + writeCount += 1 + writes.push(data) + return writeCount !== 2 + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + const prompt = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1) + + const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt) + const sendRejection = expect(sendPromise).rejects.toThrow('terminal_not_writable') + await vi.runAllTimersAsync() + + await sendRejection + expect(writes[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_START) + expect(writes.at(-1)).toBe(AGENT_PROMPT_BRACKETED_PASTE_END) + expect(writes).not.toContain('\r') + } finally { + vi.useRealTimers() + } + }) + it('chunks large terminal.send text before provider writes', async () => { const writes: string[] = [] const runtime = new OrcaRuntimeService(store) @@ -10761,6 +11272,237 @@ describe('OrcaRuntimeService', () => { expect(pty?.lastAgentStatus).toBe('idle') }) + it('normalizes rotating Grok working-frame OSC titles to one stable stored title', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;⠋ - Waiting for response… - grok\x07', 100) + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('⠋ Grok') + expect(pty?.lastAgentStatus).toBe('working') + + // A different rotating frame must store an identical title — title + // equality is what stops per-frame session-tab and mobile-snapshot touch. + runtime.onPtyData('pty-1', '\x1b]0;⠴ - Thinking - grok\x07', 101) + expect(pty?.lastOscTitle).toBe('⠋ Grok') + expect(pty?.lastAgentStatus).toBe('working') + }) + + it('does not republish mobile session tabs for same-status Grok title frames', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'laptop-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + runtime.onPtyData('laptop-created-pty', '\x1b]0;⠋ - Waiting for response… - grok\x07', 100) + runtime.onPtyData('laptop-created-pty', '\x1b]0;⠴ - Thinking - grok\x07', 101) + runtime.onPtyData('laptop-created-pty', '\x1b]0;⠙ - Responding - grok\x07', 102) + + expect(events).toHaveLength(1) + expect(events[0]?.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: '⠋ Grok', + agentStatus: expect.objectContaining({ state: 'working' }) + }) + ) + + unsubscribe() + }) + + // #7970: on a headless serve there is no renderer syncing tab.agentStatus, so + // hook-only transitions must republish the PTY-backed snapshot themselves and + // the snapshot must carry the retained hook payload, not just title evidence. + it('republishes mobile session tabs with hook payloads for title-less OSC 9999 transitions', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'hook-only-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'hook-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + runtime.onPtyData( + 'hook-only-pty', + '\x1b]9999;{"state":"working","prompt":"fix the tests","agentType":"opencode"}\x07', + 100 + ) + + expect(events).toHaveLength(1) + expect(events[0]?.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ + state: 'working', + prompt: 'fix the tests', + agentType: 'opencode' + }) + }) + ) + + runtime.onPtyData( + 'hook-only-pty', + '\x1b]9999;{"state":"waiting","prompt":"fix the tests","agentType":"opencode"}\x07', + 101 + ) + + expect(events).toHaveLength(2) + expect(events[1]?.tabs[0]?.type === 'terminal' && events[1].tabs[0].agentStatus).toEqual( + expect.objectContaining({ state: 'waiting' }) + ) + + unsubscribe() + }) + + it('does not republish mobile session tabs for repeated identical OSC 9999 payloads', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'hook-ping-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'hook-ping-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + const payload = '\x1b]9999;{"state":"working","prompt":"same","agentType":"codex"}\x07' + runtime.onPtyData('hook-ping-pty', payload, 100) + runtime.onPtyData('hook-ping-pty', payload, 101) + runtime.onPtyData('hook-ping-pty', payload, 102) + + expect(events).toHaveLength(1) + + unsubscribe() + }) + + it('suppresses a retained hook working status once the shell owns the pane title again', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'hook-exit-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'hook-exit-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + runtime.onPtyData( + 'hook-exit-pty', + '\x1b]9999;{"state":"working","prompt":"long task","agentType":"codex"}\x07', + 100 + ) + // Agent exits without a hook done event; the shell takes the title back. + // The stuck-spinner guard (#1437) must win over the retained hook row. + runtime.onPtyData('hook-exit-pty', '\x1b]0;zsh\x07', 101) + + const last = events.at(-1)?.tabs[0] + expect(last?.type).toBe('terminal') + expect(last?.type === 'terminal' ? last.agentStatus : null).toBeFalsy() + + unsubscribe() + }) + + it('stores normalized Pi idle OSC titles that still classify as idle', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;π - my-project\x07', 100) + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('Pi') + expect(pty?.lastAgentStatus).toBe('idle') + // Why: worktree.ps / mobile re-detect from stored lastOscTitle, not the + // raw OSC frame. Bare "Pi" must still classify as idle after normalize. + expect(detectAgentStatusFromTitle(pty?.lastOscTitle ?? '')).toBe('idle') + }) + + it('normalizes hydration-seeded Grok and Pi titles the same as live OSC frames', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + ;( + runtime as unknown as { + applySeededAgentStatus: (ptyId: string, title: string) => void + } + ).applySeededAgentStatus('pty-1', '⠴ - Thinking - grok') + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('⠋ Grok') + // Seed writes leaf status only; re-detect from the stored title must still + // report working so later live frames compare equal and do not thrash. + expect(detectAgentStatusFromTitle(pty?.lastOscTitle ?? '')).toBe('working') + + ;( + runtime as unknown as { + applySeededAgentStatus: (ptyId: string, title: string) => void + } + ).applySeededAgentStatus('pty-1', 'π - my-project') + expect(pty?.lastOscTitle).toBe('Pi') + expect(detectAgentStatusFromTitle(pty?.lastOscTitle ?? '')).toBe('idle') + }) + + it('stores other-agent OSC titles that merely end in grok unchanged', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;⠋ wire up grok\x07', 100) + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('⠋ wire up grok') + + // Claude/Codex braille + task ending " - grok" is not a Grok frame shape. + runtime.onPtyData('pty-1', '\x1b]0;⠋ fix the flaky suite - grok\x07', 101) + expect(pty?.lastOscTitle).toBe('⠋ fix the flaky suite - grok') + }) + it('seeds newly synced leaves from PTY pending ANSI state', async () => { const runtime = new OrcaRuntimeService(store) runtime.registerPty('pty-1', TEST_WORKTREE_ID) @@ -14145,6 +14887,188 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).not.toHaveBeenCalled() }) + it('wakes slept agents on the host renderer when a phone activates a worktree', async () => { + // Seed isUnread:false so the unread-clear branch stays quiet and the + // assertions isolate the mobile slept-agent wake. + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) + } + const activateWorktree = vi.fn() + const resumeSleepingAgents = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorkspaceSession: () => ({ + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'test', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + } + }) + } as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + // A renderer must be attached to receive the wake — headless serve reports + // the explicit 'unsupported-headless' outcome instead (see below). + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + // INV-2: mobile wake never navigates the desktop (no activateWorktree); it + // routes exclusively through the renderer's own navigation-free wake. + expect(resumeSleepingAgents).toHaveBeenCalledWith(TEST_WORKTREE_ID) + expect(activateWorktree).not.toHaveBeenCalled() + expect(result.sleepingAgentWake).toBe('requested') + }) + + it('reports the wake as unsupported when a phone activates a worktree on headless serve', async () => { + // Why: without an attached renderer nothing holds the sleeping records, so + // no agent actually wakes. The result must say so explicitly — activating + // silently would let the phone present slept agents as resumed (#7906). + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) + } + const resumeSleepingAgents = vi.fn() + const getWorkspaceSession = vi.fn(() => ({ + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'test', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + } + })) + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getWorkspaceSession + } as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + // Headless/no-live-renderer: the graph is ready but no BrowserWindow backs + // the authoritative id, so getAvailableAuthoritativeWindow() is null. + electronMocks.BrowserWindow.fromId.mockReturnValue(null as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + expect(result.activated).toBe(true) + expect(result.sleepingAgentWake).toBe('unsupported-headless') + // Why: sleeping records are host-partitioned; the check must read the + // repo's execution host partition, not always the local one. + expect(getWorkspaceSession).toHaveBeenCalledWith('local') + expect(resumeSleepingAgents).not.toHaveBeenCalled() + }) + + it('does not report headless wake degradation without sleeping records', async () => { + const runtime = new OrcaRuntimeService(store as never) + electronMocks.BrowserWindow.fromId.mockReturnValue(null as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + expect(result.sleepingAgentWake).toBe('not-applicable') + }) + + it('does not wake slept agents for non-mobile session-only activation', async () => { + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) + } + const resumeSleepingAgents = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + // INV-3: web/desktop runtime clients keep their existing wake-on-activation + // paths untouched — the renderer notifier wake is mobile-scoped. + const result = await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'runtime' + }) + + expect(resumeSleepingAgents).not.toHaveBeenCalled() + expect(result.sleepingAgentWake).toBe('not-applicable') + }) + it('does not rewrite unread metadata when a mobile activation finds the worktree already read', async () => { // Why: seed instanceId so worktree resolution does not emit its own // metadata-stamp write, isolating the assertion to the unread clear. @@ -16042,6 +16966,157 @@ describe('OrcaRuntimeService', () => { }) }) + function makePendingAgentTabActivationRuntime(opts: { disabledTuiAgents?: string[] } = {}): { + runtime: OrcaRuntimeService + spawn: ReturnType + } { + const spawn = vi.fn().mockResolvedValue({ id: 'serve-materialized-pty' }) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: 'serve-dead-pty', + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'claude' + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'serve-dead-pty' }) + } + }) + ) + const runtime = new OrcaRuntimeService({ + ...runtimeStore, + getSettings: () => ({ + ...store.getSettings(), + disabledTuiAgents: opts.disabledTuiAgents ?? [] + }) + } as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + return { runtime, spawn } + } + + it('launches the pending agent when mobile activation materializes an agent tab', async () => { + const { runtime, spawn } = makePendingAgentTabActivationRuntime() + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect(listed.tabs[0]).toMatchObject({ + type: 'terminal', + launchAgent: 'claude', + status: 'pending-handle' + }) + + // Why notifyClients false: this mirrors the phone tapping the tab, which is + // the path that materializes pending tabs headlessly (#7587 aftermath). + const activated = await runtime.activateMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + `host-tab::${HEADLESS_LEAF_ID}`, + undefined, + { notifyClients: false } + ) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining('claude'), + sessionId: 'serve-dead-pty', + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + worktreeId: TEST_WORKTREE_ID + }) + ) + expect(activated.tabs[0]).toMatchObject({ + type: 'terminal', + launchAgent: 'claude', + status: 'ready' + }) + }) + + it('materializes a plain shell when the pending tab has no launch agent', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'serve-materialized-pty' }) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: 'serve-dead-pty', + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'serve-dead-pty' }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.activateMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + `host-tab::${HEADLESS_LEAF_ID}`, + undefined, + { notifyClients: false } + ) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'serve-dead-pty', worktreeId: TEST_WORKTREE_ID }) + ) + expect(spawn.mock.calls[0]![0].command).toBeUndefined() + }) + + it('falls back to a plain shell when the pending tab agent is disabled', async () => { + const { runtime, spawn } = makePendingAgentTabActivationRuntime({ + disabledTuiAgents: ['claude'] + }) + + const activated = await runtime.activateMobileSessionTab( + `id:${TEST_WORKTREE_ID}`, + `host-tab::${HEADLESS_LEAF_ID}`, + undefined, + { notifyClients: false } + ) + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'serve-dead-pty', worktreeId: TEST_WORKTREE_ID }) + ) + expect(spawn.mock.calls[0]![0].command).toBeUndefined() + // Why: the disabled-agent fallback keeps the tab's agent identity; only the + // startup command is skipped. + expect(activated.tabs[0]).toMatchObject({ + type: 'terminal', + status: 'ready', + launchAgent: 'claude' + }) + }) + it('collapses duplicate mobile terminal entries when renderer and headless leaf ids diverge for the same pty', async () => { const rendererLeafId = HEADLESS_SECOND_LEAF_ID const ptyId = 'serve-persisted-pty' @@ -17957,6 +19032,413 @@ describe('OrcaRuntimeService', () => { } }) + // Why: the five #7587 mobile-create tests need an identical full notifier where + // only closeTerminal is observed; one factory keeps notifier-interface changes + // in a single place. + function createMobileCreateTestNotifier( + closeTerminal: (tabId: string, paneRuntimeId?: number) => void + ) { + return { + focusTerminal: vi.fn(), + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + closeTerminal, + closeSessionTab: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } + } + + it('rolls back a mobile create when the materialize spawn fails and no live PTY backs the tab', async () => { + vi.useFakeTimers() + try { + const pendingLeafId = '99999999-9999-4999-8999-999999999999' + const closeTerminal = vi.fn() + // Why: the renderer publishes a handle-less shell surface (no ptyId) and the + // runtime materialize spawn then fails, so NO live PTY ever backs the tab. + // The catch must roll back — the #7587 rescue is gated on a live PTY, not on + // the mere presence of a surface, or a dead shell would resolve as success. + const spawn = vi.fn().mockRejectedValue(new Error('spawn failed')) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-pending', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: true + }) + const settled = create.then( + () => ({ ok: true as const }), + (error: Error) => ({ ok: false as const, error }) + ) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // Only the tab shell publishes (no ptyId → never ready); no PTY ever binds. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-pending', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: `tab-pending::${pendingLeafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-pending::${pendingLeafId}`, + parentTabId: 'tab-pending', + leafId: pendingLeafId, + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + + // Ready-fallback (1s) expires → materialize runs → spawn rejects → catch. + await vi.advanceTimersByTimeAsync(2_000) + const outcome = await settled + + expect(outcome.ok).toBe(false) + expect(closeTerminal).toHaveBeenCalledWith('tab-pending') + } finally { + vi.useRealTimers() + } + }) + + it('keeps a mobile-created terminal alive when the renderer never publishes the surface', async () => { + vi.useFakeTimers() + try { + const leafId = '44444444-4444-4444-8444-444444444444' + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + // Why: reply with a tabId but NEVER sync a matching mobileSessionTabs graph, + // reproducing a throttled/hidden renderer that spawns the PTY yet stalls its + // graph-sync publication past the surface timeout (#7587). + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-alive', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: true + }) + let settled = false + const settledCreate = create.finally(() => { + settled = true + }) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // The renderer's own PTY spawn registers with the tab binding — the same + // call the pty IPC layer now makes — without any mobileSessionTabs sync. + runtime.registerPty('pty-alive', TEST_WORKTREE_ID, null, { + tabId: 'tab-alive', + leafId + }) + + // Resolves promptly, well under MOBILE_TERMINAL_SURFACE_TIMEOUT_MS (10s). + await vi.advanceTimersByTimeAsync(50) + const result = await settledCreate + + expect(settled).toBe(true) + expect(result.tab).toMatchObject({ + type: 'terminal', + parentTabId: 'tab-alive', + leafId, + status: 'ready', + terminal: expect.stringMatching(/^term_/) + }) + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a mobile-created terminal alive when the renderer PTY spawn races ahead of the reply', async () => { + vi.useFakeTimers() + try { + const leafId = '55555555-5555-4555-8555-555555555555' + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + // Why: the spawn and the tabCreate reply travel independent IPC channels; + // here the renderer registers the live PTY before it replies, so only the + // create's immediate pre-wait check can resolve it — asserting it settles + // well under the surface timeout pins that site, not the catch backstop. + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + runtime.registerPty('pty-early', TEST_WORKTREE_ID, null, { + tabId: 'tab-early', + leafId + }) + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-early', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: true + }) + let settled = false + const settledCreate = create.finally(() => { + settled = true + }) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // Resolves via the immediate pre-wait rescue, well under the 10s timeout; + // reverting that site would only resolve later through the catch path. + await vi.advanceTimersByTimeAsync(50) + expect(settled).toBe(true) + const result = await settledCreate + + expect(result.tab).toMatchObject({ + type: 'terminal', + parentTabId: 'tab-early', + leafId, + status: 'ready', + terminal: expect.stringMatching(/^term_/) + }) + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a mobile-created terminal alive when the renderer snapshot is rejected by the version guard', async () => { + vi.useFakeTimers() + try { + const leafId = '66666666-6666-4666-8666-666666666666' + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-guard', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + // Why: seed an inflated stored version under a stable epoch (no headless / + // serve-owned tabs, so the preserve-merge can't lift the version via + // Math.max) — this is the state prior renderer publications leave behind. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-guard', + snapshotVersion: 50, + activeGroupId: 'group-guard', + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: true + }) + let settled = false + const settledCreate = create.finally(() => { + settled = true + }) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // The renderer publishes the new tab, but with a stale (lower) version + // under the same epoch, so syncMobileSessionTabs rejects it and the surface + // never lands via graph-sync — the reporter's exact stall variant (#7587). + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-guard', + snapshotVersion: 1, + activeGroupId: 'group-guard', + activeTabId: `tab-guard::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-guard::${leafId}`, + parentTabId: 'tab-guard', + leafId, + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + await vi.advanceTimersByTimeAsync(50) + // The rejected renderer sync must not have resolved the create. + expect(settled).toBe(false) + // Prove the rejection: the stored snapshot still lacks the tab. + const beforeRescue = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + expect( + beforeRescue.tabs.some((tab) => tab.type === 'terminal' && tab.parentTabId === 'tab-guard') + ).toBe(false) + + // The renderer's own PTY spawn registers with the binding and rescues it. + runtime.registerPty('pty-guard', TEST_WORKTREE_ID, null, { + tabId: 'tab-guard', + leafId + }) + await vi.advanceTimersByTimeAsync(50) + const result = await settledCreate + + expect(settled).toBe(true) + expect(result.tab).toMatchObject({ + type: 'terminal', + parentTabId: 'tab-guard', + leafId, + status: 'ready', + terminal: expect.stringMatching(/^term_/) + }) + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a mobile-created terminal alive at the surface timeout when only a leaf-synced PTY backs the tab', async () => { + vi.useFakeTimers() + try { + const leafId = '77777777-7777-4777-8777-777777777777' + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + // Why: the renderer pane identity can reach the runtime via leaf graph-sync + // (recordPtyWorktree) without registerPty, and leaf-sync never calls the + // pty-backed rescue; so when the mobileSessionTabs publication stalls past + // the surface timeout, only the catch-path rescue saves the live session — + // this pins the third #7587 rescue site, which the other tests never reach. + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-catch', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: true + }) + let settled = false + const settledCreate = create.finally(() => { + settled = true + }) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // Let the create clear its immediate pre-wait check (no live PTY yet) and + // park in waitForMobileTerminalSurface before any identity arrives. + await vi.advanceTimersByTimeAsync(50) + + // Renderer pane identity arrives via leaf graph-sync — NOT registerPty — so + // no rescue fires here, and the mobileSessionTabs surface is never published. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-catch', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-catch' + } + ] + }) + + // Surface still unpublished, so the create is still pending. + expect(settled).toBe(false) + + // Cross MOBILE_TERMINAL_SURFACE_TIMEOUT_MS (10s): the catch path must rescue + // from the live PTY instead of rolling the session back destructively. + await vi.advanceTimersByTimeAsync(11_000) + const result = await settledCreate + + expect(settled).toBe(true) + expect(result.tab).toMatchObject({ + type: 'terminal', + parentTabId: 'tab-catch', + leafId, + status: 'ready', + terminal: expect.stringMatching(/^term_/) + }) + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('reports browser tab creation as unsupported for a windowless host with no offscreen backend', async () => { const runtime = new OrcaRuntimeService(store) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) @@ -25979,4 +27461,226 @@ describe('OrcaRuntimeService', () => { expect(preDaemonProvider.shutdown).not.toHaveBeenCalled() }) }) + describe('stale terminal handle resolution (#7718)', () => { + function syncSingleTerminalGraph(runtime: OrcaRuntimeService, ptyId: string): void { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'Terminal 1', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId, + paneTitle: 'Terminal 1' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Terminal 1', + isActive: true + } + ] + } + ] + }) + } + + // Why: session-tab mirrors travel as PTY handles, but client-created + // terminals (terminal.create) hold LEAF handles — the kind that can + // diverge from the pane's current PTY across reconnects. Issue one + // directly from the leaf record to pin the resolver semantics. + function issueLeafHandle(runtime: OrcaRuntimeService, ptyId: string): string { + const internals = runtime as unknown as { + leaves: Map + issueHandle: (leaf: unknown) => string + } + const leaf = Array.from(internals.leaves.values()).find( + (candidate) => candidate.ptyId === ptyId + ) + if (!leaf) { + throw new Error('expected leaf record') + } + return internals.issueHandle(leaf) + } + + it('errors with terminal_handle_stale instead of adopting a replacement PTY', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + syncSingleTerminalGraph(runtime, 'pty-a') + const handle = issueLeafHandle(runtime, 'pty-a') + + // Simulate the pane's PTY being replaced while the remote client still + // holds the old handle (the record missed the swap, e.g. across a + // renderer graph reload that preserved live PTY records). + const internals = runtime as unknown as { + leaves: Map + } + for (const leaf of internals.leaves.values()) { + if (leaf.ptyId === 'pty-a') { + leaf.ptyId = 'pty-b' + } + } + + // The unguarded resolver silently adopts the new PTY — the misroute. + expect(runtime.resolveLeafForHandle(handle)).toEqual({ ptyId: 'pty-b' }) + // The guarded resolver surfaces the staleness so clients can re-derive. + expect(() => runtime.resolveLiveLeafForHandle(handle)).toThrow('terminal_handle_stale') + }) + + it('lets a handle issued before its first PTY adopt that PTY without erroring', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + syncSingleTerminalGraph(runtime, 'pty-a') + const handle = issueLeafHandle(runtime, 'pty-a') + + // Simulate the mobile pre-spawn flow: the handle record predates the + // PTY (ptyId null); the pane's first PTY must still resolve. + const internals = runtime as unknown as { + handles: Map + } + const record = internals.handles.get(handle) + if (!record) { + throw new Error('expected handle record') + } + record.ptyId = null + + expect(runtime.resolveLiveLeafForHandle(handle)).toEqual({ ptyId: 'pty-a' }) + }) + }) + + describe('mobile terminal create resilience (#7718)', () => { + it('cancels the surface wait without rolling back when the client connection dies', async () => { + vi.useFakeTimers() + try { + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + const abort = new AbortController() + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-abort', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false, + signal: abort.signal + }) + const settled = create.then( + () => ({ ok: true as const }), + (error: Error) => ({ ok: false as const, error }) + ) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // The client's socket dies mid-wait. The create must settle right away + // (not run down the 10s surface timeout) and must NOT close the tab — + // the renderer spawned a real terminal the host user can see. + abort.abort() + await vi.advanceTimersByTimeAsync(0) + const outcome = await settled + + expect(outcome.ok).toBe(false) + if (outcome.ok === false) { + expect(outcome.error.message).toBe('client_disconnected') + } + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a mobile-created terminal alive when its live shell has no registered pane key', async () => { + vi.useFakeTimers() + try { + const closeTerminal = vi.fn() + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier(createMobileCreateTestNotifier(closeTerminal)) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-stall', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false + }) + const settled = create.then( + () => ({ ok: true as const }), + (error: Error) => ({ ok: false as const, error }) + ) + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + + // A live shell backs the tab, but its leaf id is not a terminal UUID + // so no pane key registers: the surface rescue can't publish it, and + // the surface itself never publishes (stalled renderer publication). + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-stall', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-stall', + paneTitle: null + } + ] + }) + + await vi.advanceTimersByTimeAsync(11_000) + const outcome = await settled + + // The create still fails (nothing to return), but the timeout must not + // kill the live terminal — that is the "tab dies after ~10s" symptom. + expect(outcome.ok).toBe(false) + if (outcome.ok === false) { + expect(outcome.error.message).toContain('Timed out') + } + expect(closeTerminal).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e6a5bcbfa95..43ea05133bb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5,7 +5,8 @@ import { extractLastOscTitle, detectAgentStatusFromTitle, isClaudeManagementTitle, - isShellProcess + isShellProcess, + normalizeTerminalTitle } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' @@ -34,6 +35,12 @@ import { TERMINAL_INPUT_TOO_LARGE_ERROR, iterateTerminalInputChunks } from '../../shared/terminal-input' +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + AGENT_PROMPT_SUBMIT, + AGENT_PROMPT_SUBMIT_DELAY_MS, + buildAgentPromptPasteBytes +} from '../../shared/agent-prompt-injection' import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { cleanupClaimedCloneTarget, @@ -120,9 +127,15 @@ import type { WorkspaceSessionState, DirEntry } from '../../shared/types' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' +import type { SshConnectionState } from '../../shared/ssh-types' import type { LinearCurrentIssueContextHints, LinearAttachResult, @@ -175,7 +188,7 @@ import { TASK_PROVIDERS } from '../../shared/task-providers' import { FIRST_PANE_ID } from '../../shared/pane-key' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' -import { isValidHostTerminalTabId } from '../../shared/terminal-tab-id' +import { isValidHostTerminalTabId, isValidTerminalTabId } from '../../shared/terminal-tab-id' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { repoIsRemote } from '../../shared/agent-launch-remote' import { @@ -209,7 +222,6 @@ import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' -import { localTerminalCwdCanonicalizer } from '../pty/terminal-cwd-realpath' import { isWslUncPath } from '../../shared/wsl-paths' import { folderWorkspaceKey, @@ -1177,6 +1189,10 @@ const RECENT_PTY_PATH_CANDIDATE_LIMIT = 1024 const RECENT_PTY_PATH_CANDIDATE_MAX_BYTES = 4 * 1024 const RECENT_PTY_PATH_CANDIDATE_TOTAL_BYTES = 64 * 1024 +function isClientDisconnectedError(error: unknown): boolean { + return error instanceof Error && error.message === 'client_disconnected' +} + function createTerminalRevealWarning(handle: string, error?: unknown): string { const reason = error instanceof Error && error.message.trim().length > 0 @@ -1281,6 +1297,11 @@ type RuntimeNotifier = { ): Promise closeTerminal(tabId: string, paneRuntimeId?: number): void sleepWorktree(worktreeId: string): void + // Why: a phone opening a worktree wakes its slept agents by asking the host + // renderer to run its own navigation-free wake (experimental agent sleep); + // the runtime has no in-memory sleeping records or wake authority. Optional to + // match the many renderer-backed notifier methods only the real bridge wires. + resumeSleepingAgents?(worktreeId: string): void terminalFitOverrideChanged( ptyId: string, mode: 'mobile-fit' | 'desktop-fit', @@ -1409,6 +1430,22 @@ function getRuntimeFolderWorkspaceRootId(repo: Repo): string { return `${repo.id}::${repo.path}` } +// Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime +// host can adopt a legacy (unstamped) repo. But an unstamped repo with a connectionId is an SSH repo +// (resolves to ssh:), so it must not be adopted/matched by a runtime host at the same path. +function runtimeRepoMatchesExecutionHost( + repo: Pick, + executionHostId?: ExecutionHostId | null +): boolean { + if (executionHostId == null) { + return true + } + if (repo.executionHostId != null) { + return repo.executionHostId === executionHostId + } + return repo.connectionId == null +} + function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` } @@ -2011,6 +2048,15 @@ export class OrcaRuntimeService { string, Promise >() + // Why: a mobile create waits for the renderer to publish the new tab's surface + // via graph-sync, but a throttled/hidden renderer can park that past the surface + // timeout and the create would then destroy the live PTY (#7587). This lets the + // renderer's own PTY spawn publish the surface main-side, scoped to in-flight + // creates so ordinary renderer spawns never publish here. + private pendingMobileTerminalCreatesByKey = new Map< + string, + { activate: boolean; selectIfNoActiveTab: boolean } + >() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by @@ -2775,6 +2821,12 @@ export class OrcaRuntimeService { this.emitClientEvent({ type: 'reposChanged' }) } + // Why: SSH state changes originate in main's ssh handlers, not in runtime + // methods, so they need a public entry point onto the client-event stream. + notifySshStateChanged(targetId: string, state: SshConnectionState): void { + this.emitClientEvent({ type: 'sshStateChanged', targetId, state }) + } + private notifyActivateWorktree( repoId: string, worktreeId: string, @@ -3959,6 +4011,25 @@ export class OrcaRuntimeService { const targetGroupId = snapshot?.tabGroups?.find((group) => group.tabOrder.includes(tab.parentTabId) )?.id + // Why: a pending agent tab may exist without its startup command ever + // having been delivered (the create's renderer stalled, #7587), so a + // bare materialize would put a plain shell under the agent icon. + // Re-resolve the launch like the create path; providers skip startup + // commands when attaching to live sessions, so this cannot double-launch. + let agentStartup: Awaited< + ReturnType + > = {} + if (tab.launchAgent) { + try { + const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) + agentStartup = await this.resolveMobileSessionTerminalCommand(workspace, { + agent: tab.launchAgent + }) + } catch { + // Why: a disabled or unresolvable agent must not make the tab + // untappable; fall back to the plain-shell materialize. + } + } try { await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, { identity: { @@ -3967,6 +4038,10 @@ export class OrcaRuntimeService { sessionId }, cwd: tab.startupCwd, + command: agentStartup.command, + env: agentStartup.env, + startupCommandDelivery: agentStartup.startupCommandDelivery, + launchConfig: agentStartup.launchConfig, launchAgent: tab.launchAgent, targetGroupId }) @@ -5213,8 +5288,28 @@ export class OrcaRuntimeService { } } - registerPty(ptyId: string, worktreeId: string, connectionId: string | null = null): void { - this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId }) + registerPty( + ptyId: string, + worktreeId: string, + connectionId: string | null = null, + binding?: { tabId: string; leafId: string } + ): void { + // Why: record the renderer pane identity at spawn time so a stalled graph + // sync can't hide that a live PTY already backs a pending mobile create. + const paneKey = + binding && isValidTerminalTabId(binding.tabId) && isTerminalLeafId(binding.leafId) + ? makePaneKey(binding.tabId, binding.leafId) + : null + this.recordPtyWorktree(ptyId, worktreeId, { + connected: true, + connectionId, + ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}) + }) + // Why: the renderer's own PTY spawn is the reliable signal that the pending + // mobile create's tab is live; publish its surface main-side (#7587). + if (binding && paneKey) { + this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, binding.tabId) + } } /** @@ -5251,8 +5346,15 @@ export class OrcaRuntimeService { // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) // announce status via OSC 0/1/2 title sequences — this is the same // detection path the renderer uses for notifications and sidebar badges. - const oscTitle = this.extractLastOscTitleForPty(ptyId, data) - const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null + const rawOscTitle = this.extractLastOscTitleForPty(ptyId, data) + // Why: collapse high-churn agent titles (Grok/Pi spinner frames, Gemini + // per-keystroke updates) once at the observation boundary so lastOscTitle — + // and the mobile session-tab titles/snapshots derived from it — stays + // stable instead of changing every animation frame. Status is detected + // from the raw title (mirroring the renderer's tracker) so working/idle + // transitions are unaffected by normalization. + const oscTitle = rawOscTitle === null ? null : normalizeTerminalTitle(rawOscTitle) + const agentStatus = rawOscTitle ? detectAgentStatusFromTitle(rawOscTitle) : null const pty = this.getOrCreatePtyWorktreeRecord(ptyId) let shouldTouchPtyBackedSessionTabs = false @@ -5435,8 +5537,11 @@ export class OrcaRuntimeService { } } - this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) - if (shouldTouchPtyBackedSessionTabs) { + const retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + // Why: hook (OSC 9999) transitions often arrive without a title change, so + // headless-serve snapshots would never republish and paired remote clients + // kept the stale agent state until the next title change (#7970). + if (shouldTouchPtyBackedSessionTabs || retainedAgentStatusChanged) { this.touchMobileSessionSnapshotsForPty(ptyId) } @@ -5533,12 +5638,14 @@ export class OrcaRuntimeService { return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' } - private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void { + /** Returns true when any retained agent-row snapshot changed in a + * client-visible way, so the caller can republish session snapshots. */ + private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): boolean { // Why: snapshot retention (for mobile worktree.ps) must run even when no // renderer listener is attached, so we don't early-return on a missing // onTerminalAgentStatus — only the per-target emit below is gated on it. if (chunk.payloads.length === 0) { - return + return false } const targets = new Map< string, @@ -5571,9 +5678,17 @@ export class OrcaRuntimeService { connectionId }) } + let retainedChanged = false for (const payload of chunk.payloads) { for (const target of targets.values()) { - this.retainAgentRowSnapshot(ptyId, target.paneKey, target.worktreeId, target.tabId, payload) + retainedChanged = + this.retainAgentRowSnapshot( + ptyId, + target.paneKey, + target.worktreeId, + target.tabId, + payload + ) || retainedChanged if (!this.onTerminalAgentStatus) { continue } @@ -5594,6 +5709,7 @@ export class OrcaRuntimeService { } } } + return retainedChanged } private retainAgentRowSnapshot( @@ -5602,7 +5718,7 @@ export class OrcaRuntimeService { worktreeId: string | undefined, tabId: string | undefined, payload: ParsedAgentStatusPayload - ): void { + ): boolean { const now = Date.now() const previous = this.latestAgentStatusByPaneKey.get(paneKey) // Why: stateStartedAt must mark the transition into the current state, not @@ -5619,6 +5735,17 @@ export class OrcaRuntimeService { stateStartedAt, updatedAt: now }) + // Client-visible change detection: snapshot republish is gated on this so + // repeated same-state hook pings don't fan a rebuild out to every client. + return ( + !previous || + previous.payload.state !== payload.state || + previous.payload.prompt !== payload.prompt || + (previous.payload.agentType ?? null) !== (payload.agentType ?? null) || + (previous.payload.toolName ?? null) !== (payload.toolName ?? null) || + (previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) || + (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) + ) } private clearAgentRowSnapshotsForPty(ptyId: string): void { @@ -5679,6 +5806,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } @@ -5713,6 +5841,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, @@ -5888,18 +6017,22 @@ export class OrcaRuntimeService { return } const status = detectAgentStatusFromTitle(title) + // Why: live observations store normalized titles, so seeds must match — + // otherwise the first live frame after hydration compares unequal and + // touches session tabs once for no visible change. + const seededTitle = normalizeTerminalTitle(title) const pty = this.ptysById.get(ptyId) if (pty) { const observedAt = this.nextTitleObservationSequence() - pty.lastOscTitle = title + pty.lastOscTitle = seededTitle pty.lastOscTitleAt = observedAt - this.setPtyManagementTitleFromObservedTitle(pty, title, observedAt) + this.setPtyManagementTitleFromObservedTitle(pty, seededTitle, observedAt) } for (const leaf of this.getLeavesForPty(ptyId)) { // Why: seed lastOscTitle even when the seeded title doesn't classify // as an agent state, so worktree.ps recomputes status from the live // title rather than treating the leaf as agentless. - leaf.lastOscTitle = title + leaf.lastOscTitle = seededTitle leaf.lastOscTitleAt = this.nextTitleObservationSequence() if (status !== null) { leaf.lastAgentStatus = status @@ -5993,6 +6126,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) if (headlessSnapshot) { @@ -6110,6 +6244,10 @@ export class OrcaRuntimeService { source?: 'headless' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + // Why: dangling mid-escape tail the restorer must write LAST, after any + // reset, so the next live chunk completes it instead of rendering it + // literally (#7329). + pendingEscapeTailAnsi?: string } | null> { const state = this.headlessTerminals.get(ptyId) if (!state) { @@ -6138,6 +6276,9 @@ export class OrcaRuntimeService { seq: state.outputSequence, source: 'headless', oscLinks: snapshot.oscLinks, + ...(snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } + : {}), // Why: lets the renderer skip the destructive scrollback clear when // restoring an alt-screen snapshot — clearing wipes xterm's own // history that the TUI relies on for scroll-up after a tab return. @@ -6171,6 +6312,32 @@ export class OrcaRuntimeService { return { ptyId: leaf.ptyId } } + // Why: remote clients hold handles across transport reconnects. A handle + // minted for a concrete PTY must never silently adopt a different PTY that + // later occupies the same pane — that misroutes keystrokes (#7718). Handles + // still awaiting their first PTY (ptyId null) may adopt it, which preserves + // the mobile pre-spawn subscribe flow. + resolveLiveLeafForHandle(handle: string): { ptyId: string | null } | null { + const record = this.handles.get(handle) + if (!record) { + return null + } + if (record.tabId.startsWith('pty:')) { + return { ptyId: record.ptyId } + } + const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId)) + if (!leaf) { + return null + } + if ( + record.ptyId !== null && + (leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) + ) { + throw new Error('terminal_handle_stale') + } + return { ptyId: leaf.ptyId } + } + async resolveTerminalCwd(handle: string): Promise { const ptyId = this.resolveLeafForHandle(handle)?.ptyId if (!ptyId) { @@ -7256,13 +7423,30 @@ export class OrcaRuntimeService { // the existing 'resized' event reaches the phone. // 2. Held with no mobile subscriber (post-indefinite-hold): no inner // subscriber to notify; resolve restore target and enqueueLayout - // directly. applyLayout is the SOLE writer of terminalFitOverrides; - // the held branch must not duplicate that mutation. See - // docs/mobile-fit-hold.md. + // directly. applyLayout is the SOLE writer of terminalFitOverrides on + // the normal path, so the held branch defers the mutation to it — the + // one exception is the dead-pty orphan cleanup below, which mirrors + // onPtyExit's sanctioned delete. See docs/mobile-fit-hold.md. + // + // Returns `true` only when the pty ends converged (no fit-override held); + // `false` when a restore was attempted but the resize failed (#7588), or + // when there was nothing to reclaim. On a failed-restore `false`, driver/mode + // are left unchanged so a driving phone keeps its lock and the modal + // truthfully stays — the user can retry. async reclaimTerminalForDesktop(ptyId: string): Promise { if (this.isMobileSubscriberActive(ptyId)) { + // Why (#7588): capture the prior mode so a failed restore can roll the + // routing-only 'desktop' write back — driver/mode transitions must be + // gated on convergence, not committed before we know the resize took. + const priorMode = this.getMobileDisplayMode(ptyId) this.setMobileDisplayMode(ptyId, 'desktop') - await this.applyMobileDisplayMode(ptyId) + const converged = await this.applyMobileDisplayMode(ptyId) + if (!converged) { + // Resize failed — override still held. Leave the driver on its mobile + // lock and undo the mode write so nothing is left lying at 'desktop'. + this.setMobileDisplayMode(ptyId, priorMode) + return false + } this.setDriver(ptyId, { kind: 'desktop' }) // Why: a desktop-initiated reclaim is "I'm taking over right now", // not a sticky preference. The next mobile subscribe (e.g. user @@ -7286,15 +7470,32 @@ export class OrcaRuntimeService { const renderer = this.lastRendererSizes.get(ptyId) const cols = renderer?.cols ?? heldOverride.previousCols ?? fallback.cols const rows = renderer?.rows ?? heldOverride.previousRows ?? fallback.rows - await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) - this.setDriver(ptyId, { kind: 'desktop' }) - // Why: a desktop-initiated reclaim is "I'm taking over right now", - // not a sticky preference. Reset to auto so the next mobile subscribe - // re-enters phone-fit. (Held-PTY branch may not have an entry, but - // calling setMobileDisplayMode('auto') is a no-op deletion in that - // case — safe and idempotent.) - this.setMobileDisplayMode(ptyId, 'auto') - return true + const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows }) + if (result.ok) { + this.setDriver(ptyId, { kind: 'desktop' }) + // Why: a desktop-initiated reclaim is "I'm taking over right now", + // not a sticky preference. Reset to auto so the next mobile subscribe + // re-enters phone-fit. (Held-PTY branch may not have an entry, but + // calling setMobileDisplayMode('auto') is a no-op deletion in that + // case — safe and idempotent.) + this.setMobileDisplayMode(ptyId, 'auto') + return true + } + // Why (#7588): the layout entry is gone (pty exited) but the override is + // still held — unreachable via public APIs today (onPtyExit deletes both + // in lockstep), but reporting success while stranding the override would + // re-show the modal on the next hydrate. Run the same cleanup onPtyExit + // does (delete override + paired desktop-fit 0×0) so the renderer + // converges; nothing to resize on a dead pty. + if (result.reason === 'pty-exited' && this.terminalFitOverrides.has(ptyId)) { + this.terminalFitOverrides.delete(ptyId) + this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0) + this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0) + return true + } + // resize-failed: the override remains held; report the truth so the + // modal correctly stays and the user can retry. Driver/mode untouched. + return false } return false } @@ -7410,7 +7611,8 @@ export class OrcaRuntimeService { // // applyLayout is the SOLE writer of: // - this.layouts - // - this.terminalFitOverrides + // - this.terminalFitOverrides (except the sanctioned dead-pty cleanups in + // onPtyExit and reclaimTerminalForDesktop's orphan branch, which delete) // - this.ptyController.resize (i.e. the actual PTY dims) // // Every trigger that wants to change PTY dims or flip mode goes through @@ -7605,11 +7807,17 @@ export class OrcaRuntimeService { this.resizeHeadlessTerminal(ptyId, target.cols, target.rows) } - // Why: emit fit-override-changed only when the *mode* flips. Layouts - // can change dims without flipping mode (keyboard show/hide while - // phone), and waking the renderer on every viewport tick is wasteful - // churn. - if (modeChanged) { + // Why: emit fit-override-changed when the *mode* flips. Layouts can + // change dims without flipping mode (keyboard show/hide while phone), + // and waking the renderer on every viewport tick is wasteful churn. + // Defense-in-depth (#7588): also emit when the override's presence + // changed even without a kind flip. applyLayout is the sole writer and + // keeps override presence in lockstep with layout kind, so overrideChanged + // ≡ modeChanged in every reachable state today; the extra clause fires + // only if that invariant is ever violated, repairing the renderer instead + // of stranding the held modal. + const overrideChanged = (prevFitOverride != null) !== (target.kind === 'phone') + if (modeChanged || overrideChanged) { // Why: phone→desktop arms the renderer-cascade suppress window // before the collateral safeFit IPCs arrive. See "Renderer cascade // suppression". @@ -8007,7 +8215,13 @@ export class OrcaRuntimeService { // Multi-mobile: the most recent mobile actor's viewport drives the active // phone-fit dims. The earliest-by-subscribe-time subscriber's // previousCols/Rows drive the desktop-restore target. - async applyMobileDisplayMode(ptyId: string): Promise { + // + // Returns the post-condition "no fit-override remains held" (#7588): `true` + // when it cleared a held override OR nothing was held to begin with, `false` + // only when a restore was attempted and the resize failed (override rolled + // back, still held). reclaimTerminalForDesktop gates its driver/mode + // transitions on this; other callers ignore it. + async applyMobileDisplayMode(ptyId: string): Promise { const mode = this.getMobileDisplayMode(ptyId) const inner = this.mobileSubscribers.get(ptyId) const subscriber = inner ? this.pickMostRecentActor(inner) : null @@ -8016,25 +8230,39 @@ export class OrcaRuntimeService { if (mode === 'desktop') { // Reset wasResizedToPhone on every fitted subscriber so a future // toggle back to auto re-issues the resize. applyLayout owns the - // actual PTY resize + override delete + renderer notify. - let anyWasResized = false - if (inner) { - for (const sub of inner.values()) { - if (sub.wasResizedToPhone) { - anyWasResized = true - sub.wasResizedToPhone = false - } - } + // actual PTY resize + override delete + renderer notify. Track which + // subscribers we cleared so a failed resize can re-arm them. + const clearedFitSubscribers = inner + ? [...inner.values()].filter((sub) => sub.wasResizedToPhone) + : [] + for (const sub of clearedFitSubscribers) { + sub.wasResizedToPhone = false } - if (anyWasResized) { + const anyWasResized = clearedFitSubscribers.length > 0 + // Why (#7588): also restore when a fit-override is still held but no + // subscriber carries wasResizedToPhone — e.g. a null-viewport resubscribe + // after an indefinite hold resets the flag yet leaves the override, + // stranding the desktop "phone size" modal. Reuse resolveDesktopRestoreTarget + // (the same resolver the anyWasResized branch uses) so the two adjacent + // restore paths can never resolve to different dims for the same state. + if (anyWasResized || this.terminalFitOverrides.has(ptyId)) { const restore = this.resolveDesktopRestoreTarget(ptyId) - await this.enqueueLayout(ptyId, { + const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols: restore.cols, rows: restore.rows }) + // Why (#7588): a failed resize rolls the override back (still held), so + // re-arm the flags we cleared. Otherwise a later unsubscribe under a + // finite mobileAutoRestoreFitMs would see wasResizedToPhone=false, skip + // scheduling its auto-restore timer, and strand the held phone-fit. + if (!result.ok) { + for (const sub of clearedFitSubscribers) { + sub.wasResizedToPhone = true + } + } } else { - // No subscriber was fitted — emit a mode-change resize event so + // Nothing was fitted or held — emit a mode-change resize event so // the mobile client still learns the toggle landed. const size = this.getTerminalSize(ptyId) this.notifyTerminalResize(ptyId, { @@ -8053,7 +8281,10 @@ export class OrcaRuntimeService { const viewport = subscriberRecord.viewport if (viewport) { await this.handleMobileSubscribe(ptyId, subscriberRecord.clientId, viewport) - return + // After a phone-fit an override IS held, so this reports false. The + // auto branch is never reached from reclaim (it sets 'desktop' + // first); computed here only to keep the post-condition uniform. + return !this.terminalFitOverrides.has(ptyId) } } // Why: always emit the mode change even when no resize occurred — the @@ -8069,6 +8300,7 @@ export class OrcaRuntimeService { seq: this.layouts.get(ptyId)?.seq }) } + return !this.terminalFitOverrides.has(ptyId) } // Why: called after a desktop renderer path has successfully resized the @@ -8734,6 +8966,35 @@ export class OrcaRuntimeService { } } + async sendTerminalAgentPrompt( + handle: string, + prompt: string, + options: { + beforeWrite?: (ptyId: string) => void | Promise + suffixFailureError?: string + } = {} + ): Promise { + const payload = buildAgentPromptPasteBytes(prompt) + const bytesWritten = Buffer.byteLength(`${payload}${AGENT_PROMPT_SUBMIT}`, 'utf8') + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_not_writable') + } + await assertTerminalInputWithinLimitWithYield(payload) + await this.writeTerminalAgentPrompt(pty.pty.ptyId, payload, options) + return { handle, accepted: true, bytesWritten } + } + + const { leaf } = this.getLiveLeafForHandle(handle) + if (!leaf.writable || !leaf.ptyId) { + throw new Error('terminal_not_writable') + } + await assertTerminalInputWithinLimitWithYield(payload) + await this.writeTerminalAgentPrompt(leaf.ptyId, payload, options) + return { handle, accepted: true, bytesWritten } + } + async getTerminalAgentStatus(handle: string): Promise { const terminal = this.getTerminalAgentStatusSnapshot(handle) const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) @@ -9091,6 +9352,54 @@ export class OrcaRuntimeService { } } + private async writeTerminalAgentPrompt( + ptyId: string, + pastePayload: string, + options: { + beforeWrite?: (ptyId: string) => void | Promise + suffixFailureError?: string + } = {} + ): Promise { + let wrotePasteBytes = false + let completedPaste = false + try { + const chunks = iterateTerminalInputChunks(pastePayload) + let chunk = chunks.next() + while (!chunk.done) { + await options.beforeWrite?.(ptyId) + const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false + if (!wrote) { + throw new Error('terminal_not_writable') + } + wrotePasteBytes = true + chunk = chunks.next() + if (!chunk.done) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + completedPaste = true + } catch (error) { + if (wrotePasteBytes && !completedPaste) { + this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END) + } + throw error + } + + await new Promise((resolve) => setTimeout(resolve, AGENT_PROMPT_SUBMIT_DELAY_MS)) + try { + await options.beforeWrite?.(ptyId) + } catch (error) { + if (options.suffixFailureError) { + throw new Error(options.suffixFailureError) + } + throw error + } + const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false + if (!suffixWrote) { + throw new Error(options.suffixFailureError ?? 'terminal_not_writable') + } + } + async waitForTerminal( handle: string, options?: { @@ -9687,7 +9996,7 @@ export class OrcaRuntimeService { if (!this.store) { throw new Error('runtime_unavailable') } - let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git') + let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId) let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) if (setup.projectId !== args.projectId) { const existingProject = this.listProjects().find((project) => project.id === args.projectId) @@ -9726,7 +10035,7 @@ export class OrcaRuntimeService { } async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise { - const repo = await this.cloneRepo(args.url, args.destination) + const repo = await this.cloneRepo(args.url, args.destination, args.hostId) return await this.setupProjectExistingFolder({ projectId: args.projectId, hostId: args.hostId, @@ -10127,7 +10436,11 @@ export class OrcaRuntimeService { }) } - async addRepo(path: string, kind: 'git' | 'folder' = 'git'): Promise { + async addRepo( + path: string, + kind: 'git' | 'folder' = 'git', + executionHostId?: ExecutionHostId | null + ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } @@ -10140,8 +10453,29 @@ export class OrcaRuntimeService { throw new Error(`Not a valid git repository: ${path}`) } - const existing = this.store.getRepos().find((repo) => runtimePathsEqual(repo.path, path)) + const existing = this.store.getRepos().find((repo) => { + if (!runtimePathsEqual(repo.path, path)) { + return false + } + return runtimeRepoMatchesExecutionHost(repo, executionHostId) + }) if (existing) { + // Only a runtime host backfills a legacy unstamped repo. An unstamped repo is + // indistinguishable from a genuine local repo (both have null executionHostId and + // connectionId), so we never stamp local/ssh onto it — that would re-attribute a + // real local project to the wrong host. Runtime is the only host that lost its + // identity to the pre-#7018 path-only import and needs the backfill. + if ( + existing.executionHostId == null && + parseExecutionHostId(executionHostId)?.kind === 'runtime' + ) { + const adopted = + this.store.updateRepo(existing.id, { executionHostId }) ?? + ({ ...existing, executionHostId } as Repo) + this.invalidateResolvedWorktreeCache() + this.notifyReposChanged() + return adopted + } return existing } @@ -10151,6 +10485,7 @@ export class OrcaRuntimeService { path, displayName: getRepoName(path), badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(executionHostId != null ? { executionHostId } : {}), ...detected, addedAt: Date.now(), kind, @@ -10289,7 +10624,11 @@ export class OrcaRuntimeService { return { repo: this.store.getRepo(repo.id) ?? repo } } - async cloneRepo(url: string, destination: string): Promise { + async cloneRepo( + url: string, + destination: string, + executionHostId?: ExecutionHostId | null + ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } @@ -10317,7 +10656,8 @@ export class OrcaRuntimeService { trimmedUrl, trimmedDestination, clonePath, - clonePathKey + clonePathKey, + executionHostId ) } finally { release() @@ -10331,14 +10671,19 @@ export class OrcaRuntimeService { trimmedUrl: string, trimmedDestination: string, clonePath: string, - clonePathKey: string + clonePathKey: string, + executionHostId?: ExecutionHostId | null ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const existingBeforeClone = this.store .getRepos() - .find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey) + .find( + (repo) => + getClonePathComparisonKey(repo.path) === clonePathKey && + runtimeRepoMatchesExecutionHost(repo, executionHostId) + ) if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) { return existingBeforeClone } @@ -10398,7 +10743,11 @@ export class OrcaRuntimeService { const existing = this.store .getRepos() - .find((repo) => getClonePathComparisonKey(repo.path) === clonePathKey) + .find( + (repo) => + getClonePathComparisonKey(repo.path) === clonePathKey && + runtimeRepoMatchesExecutionHost(repo, executionHostId) + ) if (existing) { if (isFolderRepo(existing)) { const updated = this.store.updateRepo(existing.id, { kind: 'git' }) @@ -10419,6 +10768,7 @@ export class OrcaRuntimeService { path: clonePath, displayName: getRepoName(clonePath), badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(executionHostId != null ? { executionHostId } : {}), ...detected, addedAt: Date.now(), kind: 'git', @@ -12271,11 +12621,15 @@ export class OrcaRuntimeService { async activateManagedWorktree( worktreeSelector: string, - opts: { notifyClients?: boolean } = {} + opts: { notifyClients?: boolean; clientKind?: 'mobile' | 'runtime' } = {} ): Promise<{ repoId: string worktreeId: string activated: boolean + /** Mobile-scoped slept-agent wake outcome. `unsupported-headless` means no + * renderer holds the sleeping records (headless `orca serve`), so nothing + * woke — clients must not present the worktree's agents as resumed. */ + sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' }> { this.assertGraphReady() const worktree = await this.resolveWorktreeSelector(worktreeSelector) @@ -12291,6 +12645,8 @@ export class OrcaRuntimeService { this.notifyWorktreesChanged(repo.id) } + let sleepingAgentWake: 'requested' | 'unsupported-headless' | 'not-applicable' = + 'not-applicable' if (opts.notifyClients !== false) { // Why: inactive worktree terminal panes are renderer-owned and may not have // live PTYs until the desktop activates the worktree and mounts them. @@ -12303,8 +12659,32 @@ export class OrcaRuntimeService { }) await this.refreshMobileSessionPtyRecords() this.notifyMobileSessionTabsChanged(worktree.id) + // Why: a phone open must also wake the worktree's slept agents (experimental + // agent sleep). Only the host renderer holds the sleeping records + wake + // authority, so fire-and-forget ask it — mobile-scoped so web/desktop are + // unaffected. Headless serve has no renderer to wake anything, so report + // that explicitly instead of letting mobile assume the agents resumed. + if (opts.clientKind === 'mobile') { + if (this.getAvailableAuthoritativeWindow()) { + this.notifier?.resumeSleepingAgents?.(worktree.id) + sleepingAgentWake = 'requested' + } else if ( + // Why: sleeping records are partitioned by execution host; reading + // only the local partition would miss slept agents on SSH-host + // worktrees and skip the headless warning for them. + Object.values( + this.store?.getWorkspaceSession?.(getRepoExecutionHostId(repo)) + .sleepingAgentSessionsByPaneKey ?? {} + ).some((record) => record.worktreeId === worktree.id) + ) { + // Why: headless is only degraded when this worktree actually has a + // persisted resume record. Ordinary mobile activation must not show + // an unsupported warning merely because no desktop window is open. + sleepingAgentWake = 'unsupported-headless' + } + } } - return { repoId: repo.id, worktreeId: worktree.id, activated: true } + return { repoId: repo.id, worktreeId: worktree.id, activated: true, sleepingAgentWake } } private async buildStartupForDraft( @@ -15721,7 +16101,7 @@ export class OrcaRuntimeService { const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts) const cwd = - this.resolveGuardedWorkspaceTerminalCwd(workspace, launchOpts.cwd) ?? workspace.path + this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path const preAllocatedHandle = this.createPreAllocatedTerminalHandle() // Why: mint tabId in main before spawn so paneKey is known at PTY env // build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys @@ -15914,7 +16294,7 @@ export class OrcaRuntimeService { : opts const worktreeId = workspace?.id const cwd = workspace - ? this.resolveGuardedWorkspaceTerminalCwd(workspace, launchOpts.cwd) + ? this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) : launchOpts.cwd const requestId = randomUUID() @@ -16012,6 +16392,7 @@ export class OrcaRuntimeService { launchAgent?: TuiAgent activate?: boolean clientMutationId?: string + signal?: AbortSignal } = {} ): Promise { const mutationId = opts.clientMutationId @@ -16053,12 +16434,13 @@ export class OrcaRuntimeService { launchAgent?: TuiAgent activate?: boolean clientMutationId?: string + signal?: AbortSignal } = {} ): Promise { this.assertGraphReady() const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector) const worktreeId = workspace.id - const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd) + const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) let afterDesktopTabId: string | undefined if (opts.afterTabId) { @@ -16092,8 +16474,16 @@ export class OrcaRuntimeService { 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, @@ -16104,12 +16494,14 @@ export class OrcaRuntimeService { } 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, @@ -16130,20 +16522,42 @@ export class OrcaRuntimeService { if (opts.activate !== false) { this.notifier?.focusTerminal(reply.tabId, worktreeId, null) } + // 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 + }) 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 + 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 + requireReady: true, + signal: opts.signal }).catch(() => null) if (readySurface) { return readySurface } + if (opts.signal?.aborted) { + // Why: nobody is waiting for this create anymore; do not 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') @@ -16167,12 +16581,34 @@ export class OrcaRuntimeService { } ) } catch (error) { - // Why: the renderer created the tab but its terminal surface never - // published (PTY spawn/handle failure). Roll the half-created tab back via - // the renderer close path so it can't linger as a ghost in mobile - // snapshots, then surface the failure to the caller. + // Why: publication latency (throttled/hidden renderer), not spawn failure, + // can trip the surface timeout. Rescue only when a live PTY actually backs + // the tab — gating on a surface would let a handle-less shell (or a failed + // materialize) resolve as success and skip the ghost-tab rollback (#7587). + if (this.findLiveRegisteredPtyForRendererTab(worktreeId, reply.tabId)) { + const rescued = this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, reply.tabId) + if (rescued) { + return rescued + } + } + // Why: don't roll back when (a) the client connection died — the wait + // was cancelled, not the spawn — or (b) a live shell already backs the + // tab (its pane key may simply not be registered yet). Killing a real + // terminal the host user can see is the "tab dies after ~10s" bug (#7718). + if ( + isClientDisconnectedError(error) || + this.hasLiveShellForRendererTab(worktreeId, reply.tabId) + ) { + throw error + } + // Why: the renderer created the tab but no live PTY backs it (true PTY + // spawn/handle failure). Roll the half-created tab back via the renderer + // close path so it can't linger as a ghost in mobile snapshots, then + // surface the failure to the caller. this.notifier?.closeTerminal(reply.tabId) throw error + } finally { + this.pendingMobileTerminalCreatesByKey.delete(pendingCreateKey) } } @@ -16268,7 +16704,7 @@ export class OrcaRuntimeService { } = {} ): Promise { const workspace = await this.resolveTerminalWorkspaceLaunchScope(`id:${worktreeId}`) - const cwd = this.resolveGuardedWorkspaceTerminalCwd(workspace, opts.cwd) + const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd) // Why: SshPtyProvider treats sessionId as a relay reattach request. Only // synthesize local serve ids; SSH fresh terminals must call pty.spawn. const stableSessionId = @@ -16384,33 +16820,44 @@ export class OrcaRuntimeService { private waitForMobileTerminalSurface( worktreeId: string, parentTabId: string, - options: { timeoutMs?: number; requireReady?: boolean } = {} + options: { timeoutMs?: number; requireReady?: boolean; signal?: AbortSignal } = {} ): Promise { const timeoutMs = options.timeoutMs ?? MOBILE_TERMINAL_SURFACE_TIMEOUT_MS const existing = this.findMobileTerminalSurface(worktreeId, parentTabId, options) if (existing) { return Promise.resolve(existing) } + if (options.signal?.aborted) { + return Promise.reject(new Error('client_disconnected')) + } return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + const cleanup = (): void => { + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) const idx = this.graphSyncCallbacks.indexOf(check) if (idx !== -1) { this.graphSyncCallbacks.splice(idx, 1) } + } + const timer = setTimeout(() => { + cleanup() reject(new Error('Timed out waiting for terminal surface after creation')) }, timeoutMs) + // Why: a dead client connection cancels the wait immediately instead of + // running down the timeout and triggering rollback (#7718). + const onAbort = (): void => { + cleanup() + reject(new Error('client_disconnected')) + } + options.signal?.addEventListener('abort', onAbort, { once: true }) const check = (): void => { const next = this.findMobileTerminalSurface(worktreeId, parentTabId, options) if (!next) { return } - clearTimeout(timer) - const idx = this.graphSyncCallbacks.indexOf(check) - if (idx !== -1) { - this.graphSyncCallbacks.splice(idx, 1) - } + cleanup() resolve(next) } this.graphSyncCallbacks.push(check) @@ -16445,6 +16892,73 @@ export class OrcaRuntimeService { return surface } + // Why: for an in-flight mobile create whose surface hasn't published yet, + // publish it main-side from the live renderer PTY so the create doesn't wait + // on a stalled graph sync and destroy the session (#7587). No-op unless a + // matching create is pending and a live bound PTY exists; never double-inserts. + private ensurePtyBackedMobileSurfaceForRendererTab( + worktreeId: string, + tabId: string + ): RuntimeMobileSessionCreateTerminalResult | null { + const pending = this.pendingMobileTerminalCreatesByKey.get(`${worktreeId}::${tabId}`) + if (!pending) { + return null + } + const existing = this.findMobileTerminalSurface(worktreeId, tabId) + if (existing) { + // Why: the renderer's own publication already landed; stay idempotent. + return existing + } + const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) + const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined + if (!pty || !leafId) { + return null + } + this.publishPtyBackedMobileSessionTerminal(worktreeId, pty, { + tabId, + leafId, + title: null, + activate: pending.activate, + selectIfNoActiveTab: pending.selectIfNoActiveTab + }) + // Why: waitForMobileTerminalSurface's check closures are drained only inside + // syncWindowGraph; a main-side publish must drain them too or the pending + // wait won't observe the insertion (mirrors syncWindowGraph's drain). + for (const cb of [...this.graphSyncCallbacks]) { + cb() + } + return this.findMobileTerminalSurface(worktreeId, tabId) + } + + private findLiveRegisteredPtyForRendererTab( + worktreeId: string, + tabId: string + ): RuntimePtyWorktreeRecord | null { + for (const pty of this.ptysById.values()) { + if ( + pty.worktreeId === worktreeId && + pty.tabId === tabId && + pty.connected && + parsePaneKey(pty.paneKey ?? '')?.leafId + ) { + return pty + } + } + return null + } + + // Why: rollback guard, looser than findLiveRegisteredPtyForRendererTab — a + // shell whose pane key hasn't registered yet can't be surface-rescued, but + // it is still a real terminal the create timeout must not kill (#7718). + private hasLiveShellForRendererTab(worktreeId: string, tabId: string): boolean { + for (const pty of this.ptysById.values()) { + if (pty.worktreeId === worktreeId && pty.tabId === tabId && pty.connected) { + return true + } + } + return false + } + private isReadyMobileTerminalSurface( surface: RuntimeMobileSessionCreateTerminalResult | null ): boolean { @@ -17137,15 +17651,11 @@ export class OrcaRuntimeService { } } - // Why: every terminal-creation path must apply the same symlink-aware cwd - // guard; keep the canonicalizer wiring in one place. - private resolveGuardedWorkspaceTerminalCwd( - workspace: Pick, + private resolveWorkspaceTerminalStartupCwd( + workspace: Pick, requestedCwd?: string | null ): string | undefined { - return resolveTerminalStartupCwd(workspace.path, requestedCwd, { - canonicalizePath: localTerminalCwdCanonicalizer(workspace.connectionId) - }) + return resolveTerminalStartupCwd(workspace.path, requestedCwd) } private async resolveTerminalWorkspaceLaunchScope( @@ -18862,45 +19372,117 @@ export class OrcaRuntimeService { tab: RuntimeMobileSessionTerminalTab, terminalHandle: string | null ): { agentStatus: AgentStatusEntry } | Record { - if (!pty?.lastAgentStatus) { + const paneKey = this.getMobileTerminalPaneKey(tab) + const retained = this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab) + if (!pty?.lastAgentStatus && !retained) { return {} } - const ptyTitle = getLatestAgentCandidateTitle( - { title: pty.title, updatedAt: pty.titleUpdatedAt }, - { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } - ) + const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null + const ptyTitle = pty + ? getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ) + : leaf + ? getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + ) + : null const ptyTitleClassification = classifyAgentTitle(ptyTitle) if (ptyTitle !== null && ptyTitleClassification !== 'agent') { - return {} + // Why: a non-agent title means the shell owns the pane again (the agent + // exited or was replaced) — suppressing here is what clears stuck + // spinners (#1437). A live hook signal (question card / active tool) is + // authoritative agent activity even under a task-named title, so it + // survives the suppression, mirroring the renderer-synced branch above. + const hasLiveHookSignal = + retained?.payload.interactivePrompt != null || retained?.payload.toolName != null + if (!hasLiveHookSignal) { + return {} + } } - const now = pty.lastOutputAt ?? Date.now() - const ownerAgent = tab.launchAgent ?? pty.launchAgent ?? pty.foregroundAgent ?? null + const ownerAgent = tab.launchAgent ?? pty?.launchAgent ?? pty?.foregroundAgent ?? null + const terminalTitle = normalizeCompatibleAgentTitleForOwner( + (pty ? getLatestPtyTitle(pty) : null) ?? tab.title, + ownerAgent + ) + // Why: hook (OSC 9999) payloads carry the real state, prompt, and agent + // identity; the title heuristic below is a fallback with none of that. + // Without this, headless-serve clients only ever saw title-derived rows + // and hook-only transitions (e.g. opencode waiting) never surfaced (#7970). + if (retained) { + return { + agentStatus: normalizeCompatibleAgentStatusEntryForOwner( + { + ...retained.payload, + paneKey, + updatedAt: retained.updatedAt, + stateStartedAt: retained.stateStartedAt, + stateHistory: [], + ...(terminalHandle ? { terminalHandle } : {}), + ...((pty?.worktreeId ?? retained.worktreeId) + ? { worktreeId: pty?.worktreeId ?? retained.worktreeId } + : {}), + tabId: tab.parentTabId, + terminalTitle + }, + ownerAgent + ) + } + } + const now = pty!.lastOutputAt ?? Date.now() const agentType = ownerAgent ?? undefined return { agentStatus: { state: - pty.lastAgentStatus === 'working' + pty!.lastAgentStatus === 'working' ? 'working' - : pty.lastAgentStatus === 'permission' + : pty!.lastAgentStatus === 'permission' ? 'blocked' : 'done', prompt: '', updatedAt: now, stateStartedAt: now, - paneKey: this.getMobileTerminalPaneKey(tab), + paneKey, ...(terminalHandle ? { terminalHandle } : {}), ...(agentType ? { agentType } : {}), - worktreeId: pty.worktreeId, + worktreeId: pty!.worktreeId, tabId: tab.parentTabId, - terminalTitle: normalizeCompatibleAgentTitleForOwner( - getLatestPtyTitle(pty) ?? tab.title, - ownerAgent - ), + terminalTitle, stateHistory: [] } } } + /** The retained OSC 9999 hook row for this mobile tab, when fresh enough to + * trust. Looked up by pane identity first, then by PTY ownership because + * legacy `pane:N` leaf ids can drift from the hook-side pane key. */ + private getFreshRetainedAgentStatusForMobileTab( + paneKey: string, + pty: RuntimePtyWorktreeRecord | null, + tab: RuntimeMobileSessionTerminalTab + ): RuntimeAgentRowSnapshot | null { + let retained = this.latestAgentStatusByPaneKey.get(paneKey) ?? null + if (!retained) { + const ptyId = pty?.ptyId ?? tab.ptyId ?? null + if (ptyId) { + for (const snapshot of this.latestAgentStatusByPaneKey.values()) { + if (snapshot.ptyId !== ptyId) { + continue + } + if (!retained || snapshot.updatedAt > retained.updatedAt) { + retained = snapshot + } + } + } + } + if (!retained || Date.now() - retained.updatedAt > AGENT_STATUS_STALE_AFTER_MS) { + return null + } + return retained + } + private findPtyForMobileTerminalTab( worktreeId: string, tab: RuntimeMobileSessionTerminalTab, diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index a10c4ea2091..8593fd81622 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -40,8 +40,8 @@ function createMockRuntime(): CoordinatorRuntime & { setProbeDrift(result: DriftResult): void { mock.probeDriftResult = result }, - async sendTerminal(handle: string, action: { text?: string }) { - mock.sentMessages.push({ handle, text: action.text ?? '' }) + async sendTerminalAgentPrompt(handle: string, prompt: string) { + mock.sentMessages.push({ handle, text: prompt }) return { handle, accepted: true, bytesWritten: 0 } }, async listTerminals() { @@ -275,7 +275,7 @@ describe('Coordinator', () => { db = new OrchestrationDb(':memory:') const runtime = createMockRuntime() runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }] - runtime.sendTerminal = async () => { + runtime.sendTerminalAgentPrompt = async () => { throw new Error('terminal_not_writable') } @@ -690,8 +690,8 @@ describe('Coordinator', () => { const result = await runPromise // Why: silent-skip must NOT burn the circuit-breaker budget. Task must - // stay in `ready`; failDispatch must NOT be called; sendTerminal must - // NOT be called; no dispatch context should exist. + // stay in `ready`; failDispatch must NOT be called; no prompt injection + // should happen; no dispatch context should exist. expect(runtime.sentMessages).toHaveLength(0) expect(db.getTask(task.id)?.status).toBe('ready') expect(db.getDispatchContext(task.id)).toBeUndefined() diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index 897290c329e..2313dfdf320 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -5,7 +5,7 @@ import { buildDispatchPreamble } from './preamble' import { reconcileLifecycleMessage } from './lifecycle-reconciliation' export type CoordinatorRuntime = { - sendTerminal(handle: string, action: { text?: string; enter?: boolean }): Promise + sendTerminalAgentPrompt(handle: string, prompt: string): Promise listTerminals( worktreeSelector?: string, limit?: number @@ -495,10 +495,7 @@ export class Coordinator { } try { - await this.runtime.sendTerminal(targetHandle, { - text: preamble + gateContext, - enter: true - }) + await this.runtime.sendTerminalAgentPrompt(targetHandle, preamble + gateContext) } catch (err) { const updated = this.db.failDispatch( dispatch.id, diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts new file mode 100644 index 00000000000..c7cc56a6490 --- /dev/null +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AI_VAULT_METHODS } from './ai-vault' + +const mocks = vi.hoisted(() => ({ + scanAiVaultSessions: vi.fn() +})) + +vi.mock('../../../ai-vault/session-scanner', () => ({ + scanAiVaultSessions: mocks.scanAiVaultSessions +})) + +beforeEach(() => { + vi.clearAllMocks() + mocks.scanAiVaultSessions.mockResolvedValue({ + sessions: [], + issues: [], + scannedAt: '2026-07-04T00:00:00.000Z' + }) +}) + +describe('aiVault.listSessions runtime RPC', () => { + it('scans server-local sessions under the caller supplied runtime host id', async () => { + const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions') + expect(method).toBeDefined() + + const params = method!.params!.parse({ + limit: 25, + scopePaths: ['/srv/app'], + executionHostId: 'runtime:remote-server' + }) + + await method!.handler(params, {} as never) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith({ + limit: 25, + scopePaths: ['/srv/app'], + executionHostId: 'runtime:remote-server' + }) + }) + + it('rejects non-runtime execution host ids before dispatch', () => { + const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions') + expect(method).toBeDefined() + + expect(() => + method!.params!.parse({ + executionHostId: 'not-a-runtime-host' + }) + ).toThrow('Invalid runtime execution host id') + expect(() => + method!.params!.parse({ + executionHostId: 'local' + }) + ).toThrow('Invalid runtime execution host id') + expect(() => + method!.params!.parse({ + executionHostId: 'ssh:dev-box' + }) + ).toThrow('Invalid runtime execution host id') + }) +}) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts new file mode 100644 index 00000000000..49591ea1e40 --- /dev/null +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import { scanAiVaultSessions } from '../../../ai-vault/session-scanner' +import { defineMethod, type RpcMethod } from '../core' +import { parseExecutionHostId } from '../../../../shared/execution-host' + +const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => { + const parsed = parseExecutionHostId(value) + if (parsed?.kind === 'runtime') { + return parsed.id + } + ctx.addIssue({ + code: 'custom', + message: 'Invalid runtime execution host id' + }) + return z.NEVER +}) + +const listSessionsParamsSchema = z.object({ + limit: z.number().int().positive().optional(), + force: z.boolean().optional(), + scopePaths: z.array(z.string()).optional(), + executionHostId: executionHostIdSchema +}) + +export const AI_VAULT_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'aiVault.listSessions', + params: listSessionsParamsSchema, + handler: async (params) => + scanAiVaultSessions({ + limit: params.limit, + scopePaths: params.scopePaths, + executionHostId: params.executionHostId + }) + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 8018375a8a0..bc0a73985f9 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -1,5 +1,6 @@ import type { RpcAnyMethod } from '../core' import { STATUS_METHODS } from './status' +import { AI_VAULT_METHODS } from './ai-vault' import { AUTOMATION_METHODS } from './automations' import { REPO_METHODS } from './repo' import { WORKTREE_METHODS } from './worktree' @@ -39,6 +40,7 @@ import { EMULATOR_METHODS } from './emulator' // auditing the security boundary or wiring new CLI commands. export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...STATUS_METHODS, + ...AI_VAULT_METHODS, ...AUTOMATION_METHODS, ...REPO_METHODS, ...WORKTREE_METHODS, diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 8fd0da2bfa6..e5255595a6a 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -1067,7 +1067,9 @@ describe('orchestration RPC methods', () => { setup() const task = db.createTask({ spec: 'work' }) vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) - vi.spyOn(runtime, 'sendTerminal').mockRejectedValue(new Error('terminal_not_writable')) + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockRejectedValue( + new Error('terminal_not_writable') + ) await expect( call('orchestration.dispatch', { @@ -1085,7 +1087,7 @@ describe('orchestration RPC methods', () => { setup() const task = db.createTask({ spec: 'work' }) vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) - const send = vi.spyOn(runtime, 'sendTerminal').mockResolvedValue({ + const send = vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ handle: 'term_a', accepted: true, bytesWritten: 1 @@ -1098,7 +1100,35 @@ describe('orchestration RPC methods', () => { devMode: true }) - expect(send.mock.calls[0]?.[1].text).toContain('orca-dev orchestration send') + expect(send).toHaveBeenCalledWith( + 'term_a', + expect.stringContaining('orca-dev orchestration send') + ) + }) + + it('injects preamble through the agent prompt path instead of raw terminal send', async () => { + setup() + const task = db.createTask({ spec: 'line one\nline two' }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + const agentPrompt = vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_a', + accepted: true, + bytesWritten: 1 + }) + const rawSend = vi.spyOn(runtime, 'sendTerminal') + + await call('orchestration.dispatch', { + task: task.id, + to: 'term_a', + inject: true, + from: 'term_coord' + }) + + expect(agentPrompt).toHaveBeenCalledWith( + 'term_a', + expect.stringContaining('line one\nline two') + ) + expect(rawSend).not.toHaveBeenCalled() }) it('rejects inject to terminal without recognized agent', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 705791047cf..f22ad62d8f4 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -492,7 +492,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ let injected = false if (params.inject) { try { - await runtime.sendTerminal(to, { text: preamble, enter: true }) + await runtime.sendTerminalAgentPrompt(to, preamble) injected = true } catch (err) { db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index f2bf1641bda..d27d771f321 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -41,7 +41,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.createTerminal', params: CreateTerminalTab, - handler: async (params, { runtime }) => + handler: async (params, { runtime, signal }) => runtime.createMobileSessionTerminal(params.worktree, { afterTabId: params.afterTabId, targetGroupId: params.targetGroupId, @@ -54,7 +54,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), activate: params.activate, - clientMutationId: params.clientMutationId + clientMutationId: params.clientMutationId, + // Why: a dead client connection must cancel the surface wait instead + // of running down the timeout and rolling back a live tab (#7718). + signal }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts index 94791a7fdd3..8efab508345 100644 --- a/src/main/runtime/rpc/methods/ssh.test.ts +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -4,14 +4,23 @@ import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' import { SSH_METHODS } from './ssh' -const { connectRegisteredSshTargetMock, getRegisteredSshStateMock } = vi.hoisted(() => ({ +const { + connectRegisteredSshTargetMock, + getRegisteredSshStateMock, + listRegisteredSshTargetsMock, + listRegisteredRemovedSshTargetLabelsMock +} = vi.hoisted(() => ({ connectRegisteredSshTargetMock: vi.fn(), - getRegisteredSshStateMock: vi.fn() + getRegisteredSshStateMock: vi.fn(), + listRegisteredSshTargetsMock: vi.fn(), + listRegisteredRemovedSshTargetLabelsMock: vi.fn() })) vi.mock('../../../ipc/ssh', () => ({ connectRegisteredSshTarget: connectRegisteredSshTargetMock, - getRegisteredSshState: getRegisteredSshStateMock + getRegisteredSshState: getRegisteredSshStateMock, + listRegisteredSshTargets: listRegisteredSshTargetsMock, + listRegisteredRemovedSshTargetLabels: listRegisteredRemovedSshTargetLabelsMock })) function makeRequest(method: string, params?: unknown): RpcRequest { @@ -62,4 +71,26 @@ describe('ssh RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { state: null } }) }) + + it('lists the registered SSH targets for paired clients', async () => { + const targets = [{ id: 'ssh-1', label: 'Dev box', host: 'dev', port: 22, username: 'me' }] + listRegisteredSshTargetsMock.mockReturnValueOnce(targets) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.listTargets')) + + expect(response).toMatchObject({ ok: true, result: { targets } }) + }) + + it('lists removed-target labels for ghost-host display on paired clients', async () => { + const labels = { 'ssh-old': 'Dev box' } + listRegisteredRemovedSshTargetLabelsMock.mockReturnValueOnce(labels) + const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ssh.listRemovedTargetLabels')) + + expect(response).toMatchObject({ ok: true, result: { labels } }) + }) }) diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index e4d836cedff..6de8634a881 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -1,5 +1,10 @@ import { z } from 'zod' -import { connectRegisteredSshTarget, getRegisteredSshState } from '../../../ipc/ssh' +import { + connectRegisteredSshTarget, + getRegisteredSshState, + listRegisteredRemovedSshTargetLabels, + listRegisteredSshTargets +} from '../../../ipc/ssh' import { defineMethod, type RpcMethod } from '../core' const SshTarget = z.object({ @@ -16,5 +21,15 @@ export const SSH_METHODS: RpcMethod[] = [ name: 'ssh.connect', params: SshTarget, handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) }) + }), + defineMethod({ + name: 'ssh.listTargets', + params: null, + handler: () => ({ targets: listRegisteredSshTargets() }) + }), + defineMethod({ + name: 'ssh.listRemovedTargetLabels', + params: null, + handler: () => ({ labels: listRegisteredRemovedSshTargetLabels() }) }) ] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 48b1bc793b2..a750438e03e 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -55,6 +55,7 @@ type SnapshotFrameOptions = { truncatedByByteBudget?: boolean source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] + pendingEscapeTailAnsi?: string } type SerializedSnapshot = { @@ -67,6 +68,7 @@ type SerializedSnapshot = { oscLinks?: TerminalOscLinkRange[] scrollbackRows: number truncatedByByteBudget: boolean + pendingEscapeTailAnsi?: string } | null type TerminalViewportClient = { @@ -466,6 +468,7 @@ function sendSnapshotFrames( cwd: options.cwd, source: options.source, oscLinks: options.oscLinks, + pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, truncated: options.truncated === true, truncatedByByteBudget: options.truncatedByByteBudget === true }) @@ -901,7 +904,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ params: TerminalSend, handler: async (params, { runtime }) => { await assertTerminalSendTextWithinLimit(params.text) - const leaf = runtime.resolveLeafForHandle(params.terminal) + // Why: guarded resolution — a stale handle must fail with + // terminal_handle_stale (clients recover by re-deriving the handle) + // instead of evaluating driver/lock state against the wrong PTY (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { return { @@ -1398,6 +1404,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cwd: serialized?.cwd, source: serialized?.source, oscLinks: serialized?.oscLinks, + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, truncated: false, truncatedByByteBudget: serialized?.truncatedByByteBudget, data: serialized?.data ?? '' @@ -1432,8 +1439,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const request = parsed.data detachStream(request.streamId, false) - let leaf = runtime.resolveLeafForHandle(request.terminal) const isMobile = request.client?.type === 'mobile' + let leaf: { ptyId: string | null } | null + try { + // Why: guarded resolution — binding the output stream to whatever + // PTY now occupies a stale handle's pane silently mirrors the wrong + // terminal after a reconnect (#7718). terminal_handle_stale lets the + // client re-derive the handle from the current session snapshot. + leaf = runtime.resolveLiveLeafForHandle(request.terminal) + } catch { + sendStreamError(request.streamId, 'terminal_handle_stale') + emit({ type: 'end', streamId: request.streamId }) + return + } if (!leaf?.ptyId && isMobile) { try { const ptyId = await runtime.waitForLeafPtyId(request.terminal, 10_000, signal) @@ -1602,6 +1620,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ truncatedByByteBudget: serialized?.truncatedByByteBudget, source: serialized?.source, oscLinks: serialized?.oscLinks, + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') }) // Why: baseline for resize re-stream gating; the client already diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 60836205ef8..ee4e011023a 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -38,7 +38,32 @@ describe('worktree RPC methods', () => { expect(response).toMatchObject({ ok: true }) expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { - notifyClients: false + notifyClients: false, + clientKind: undefined + }) + }) + + it('forwards the mobile clientKind to the runtime on session-only activation', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + activateManagedWorktree: vi + .fn() + .mockResolvedValue({ repoId: 'repo-1', worktreeId: 'wt-1', activated: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + // The mobile WebSocket path always uses dispatchStreaming, which threads the + // authenticated device scope as clientKind even for non-streaming methods. + const replies: string[] = [] + await dispatcher.dispatchStreaming( + makeRequest('worktree.activate', { worktree: 'id:wt-1', notifyClients: false }), + (response) => replies.push(response), + { clientKind: 'mobile' } + ) + + expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { + notifyClients: false, + clientKind: 'mobile' }) }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index a95d6ebf00a..f84a26d1d87 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -59,9 +59,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.activate', params: WorktreeActivate, - handler: async (params, { runtime }) => + handler: async (params, { runtime, clientKind }) => + // Why: clientKind ('mobile'|'runtime') scopes the host-renderer slept-agent + // wake to phones so web/desktop activation behavior is unchanged. runtime.activateManagedWorktree(params.worktree, { - notifyClients: params.notifyClients !== false + notifyClients: params.notifyClients !== false, + clientKind }) }), defineMethod({ diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts new file mode 100644 index 00000000000..59e4d2d1b29 --- /dev/null +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RuntimeTerminalWait } from '../../../shared/runtime-types' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson +} from '../../../shared/terminal-stream-protocol' + +// Transport-level regression for #7329: the daemon's mid-escape tail +// (pendingEscapeTailAnsi) must survive the terminal.multiplex wire so the +// renderer can replay it after the reset. Without threading it through the +// SnapshotStart JSON frame, the tail is lost and the next live chunk renders +// literally ("colors/garbage around what I type"). + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { + it('carries the daemon pendingEscapeTailAnsi through the SnapshotStart frame', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'user@host:~$ ', + cols: 80, + rows: 24, + // The dangling partial the emulator could not serialize. + pendingEscapeTailAnsi: '\x1b[3' + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-1', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.runOnlyPendingTimersAsync() + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 5, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' } + }) + }) + )! + ) + + // Drive the subscribe promise chain (readTerminal/serialize awaits + the + // output-batcher flush timer) to completion. + for (let i = 0; i < 5; i += 1) { + await vi.runOnlyPendingTimersAsync() + } + + const snapshotStart = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart)! + expect(decodeTerminalStreamJson(snapshotStart.payload)).toMatchObject({ + pendingEscapeTailAnsi: '\x1b[3' + }) + + runtime.cleanupSubscription('terminal-multiplex:conn-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 5ceee818dbb..69e956aa155 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -41,7 +41,7 @@ describe('terminal multiplex RPC', () => { current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void } = {} const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snapshot', @@ -258,7 +258,7 @@ describe('terminal multiplex RPC', () => { | undefined const restreamResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi .fn() @@ -371,7 +371,7 @@ describe('terminal multiplex RPC', () => { current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void } = {} const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snapshot', @@ -477,7 +477,7 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi .fn() .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }), @@ -573,7 +573,7 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi .fn() @@ -681,7 +681,7 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn().mockResolvedValue(null), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), @@ -781,7 +781,7 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn().mockResolvedValue(null), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), @@ -1305,7 +1305,7 @@ describe('terminal multiplex RPC', () => { >() const cleanups = new Map void>() const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), waitForLeafPtyId: vi.fn( (_handle: string, _timeoutMs?: number, signal?: AbortSignal) => new Promise((_resolve, reject) => { @@ -1372,6 +1372,78 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('rejects a stale terminal handle with terminal_handle_stale instead of binding the wrong PTY', async () => { + // Why: after a reconnect a client can resubscribe with a handle whose + // pane now hosts a different PTY. Binding the stream anyway would mirror + // (and type into) the wrong terminal (#7718); the client recovers from + // terminal_handle_stale by re-deriving the handle from the next snapshot. + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => { + throw new Error('terminal_handle_stale') + }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-stale-handle', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'stale-terminal', + client: { id: 'desktop-1', type: 'desktop' } + }) + }) + )! + ) + + await vi.waitFor(() => + expect( + messages + .map((msg) => JSON.parse(msg).result) + .some((result) => result?.type === 'end' && result.streamId === 9) + ).toBe(true) + ) + const errorFrame = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .find((frame) => frame?.opcode === TerminalStreamOpcode.Error) + expect(errorFrame && decodeTerminalStreamText(errorFrame.payload)).toBe('terminal_handle_stale') + // The stream must never have bound to any PTY. + expect(runtime.subscribeToTerminalData).not.toHaveBeenCalled() + + cleanups.get('terminal-multiplex:conn-stale-handle')?.() + await dispatchPromise + }) + it('bounds live output queued while a multiplex snapshot is loading', async () => { vi.useFakeTimers() try { @@ -1385,7 +1457,7 @@ describe('terminal multiplex RPC', () => { const dataListenerRef: { current?: (data: string) => void } = {} const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => @@ -1494,7 +1566,7 @@ describe('terminal multiplex RPC', () => { const dataListenerRef: { current?: (data: string) => void } = {} const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => @@ -1607,7 +1679,7 @@ describe('terminal multiplex RPC', () => { rows: number }) => void = () => {} const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi .fn() diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 82e47100088..b778f814977 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -75,9 +75,34 @@ describe('terminal send RPC', () => { expect(runtime.getTerminalAgentStatus).toHaveBeenCalledWith('terminal-1') }) + it('fails terminal.send with terminal_handle_stale for a stale handle instead of probing the wrong PTY', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => { + throw new Error('terminal_handle_stale') + }), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'stale-terminal', + text: 'x', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response.ok).toBe(false) + if (response.ok) { + throw new Error('expected stale handle error') + } + expect(response.error.message).toContain('terminal_handle_stale') + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + it('drops desktop input while a mobile client owns the terminal floor', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), sendTerminal: vi.fn(), mobileTookFloor: vi.fn() @@ -109,7 +134,7 @@ describe('terminal send RPC', () => { it('accepts legacy clientless mobile input when the current driver is mobile', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), sendTerminal: vi.fn().mockResolvedValue({ handle: 'terminal-1', @@ -198,7 +223,7 @@ describe('terminal send RPC', () => { vi.useFakeTimers() const text = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1) const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), sendTerminal: vi.fn().mockResolvedValue({ handle: 'terminal-1', @@ -236,7 +261,7 @@ describe('terminal send RPC', () => { it('refuses guarded terminal sends when the agent needs permission', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), getTerminalAgentStatus: vi.fn().mockResolvedValue({ handle: 'terminal-1', @@ -274,7 +299,7 @@ describe('terminal send RPC', () => { it('allows guarded terminal sends when the agent is sendable', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), getTerminalAgentStatus: vi.fn().mockResolvedValue({ handle: 'terminal-1', @@ -316,7 +341,7 @@ describe('terminal send RPC', () => { it('refuses guarded combined text and submit sends before any PTY write', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), getTerminalAgentStatus: vi.fn(), sendTerminal: vi.fn() @@ -350,7 +375,7 @@ describe('terminal send RPC', () => { it('rechecks the mobile floor lock immediately before guarded PTY writes', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi .fn() .mockReturnValueOnce({ kind: 'desktop' }) diff --git a/src/main/runtime/rpc/ws-fallback-port-store.test.ts b/src/main/runtime/rpc/ws-fallback-port-store.test.ts new file mode 100644 index 00000000000..2edd314c212 --- /dev/null +++ b/src/main/runtime/rpc/ws-fallback-port-store.test.ts @@ -0,0 +1,35 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { readWsFallbackPort, writeWsFallbackPort } from './ws-fallback-port-store' + +function makeUserDataPath(): string { + return mkdtempSync(join(tmpdir(), 'ws-fallback-port-test-')) +} + +describe('ws-fallback-port-store', () => { + it('round-trips a persisted fallback port', () => { + const userDataPath = makeUserDataPath() + expect(readWsFallbackPort(userDataPath)).toBeUndefined() + writeWsFallbackPort(userDataPath, 54321) + expect(readWsFallbackPort(userDataPath)).toBe(54321) + }) + + it('ignores corrupt or invalid contents', () => { + const userDataPath = makeUserDataPath() + writeFileSync(join(userDataPath, 'mobile-ws-fallback-port.json'), 'not json', 'utf8') + expect(readWsFallbackPort(userDataPath)).toBeUndefined() + writeFileSync(join(userDataPath, 'mobile-ws-fallback-port.json'), '{"port":-4}', 'utf8') + expect(readWsFallbackPort(userDataPath)).toBeUndefined() + writeFileSync(join(userDataPath, 'mobile-ws-fallback-port.json'), '{"port":"80"}', 'utf8') + expect(readWsFallbackPort(userDataPath)).toBeUndefined() + }) + + it('refuses to persist an invalid port', () => { + const userDataPath = makeUserDataPath() + writeWsFallbackPort(userDataPath, 0) + writeWsFallbackPort(userDataPath, 70000) + expect(readWsFallbackPort(userDataPath)).toBeUndefined() + }) +}) diff --git a/src/main/runtime/rpc/ws-fallback-port-store.ts b/src/main/runtime/rpc/ws-fallback-port-store.ts new file mode 100644 index 00000000000..beedcea8cf2 --- /dev/null +++ b/src/main/runtime/rpc/ws-fallback-port-store.ts @@ -0,0 +1,45 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +// Why: when the preferred WS port is taken (second Orca instance), the OS +// assigns a random port. Paired mobile devices store ws://ip:port endpoints, +// so a port that changes on every restart permanently orphans those pairings +// (STA-1511). Persist the assigned fallback so the same instance re-binds the +// same port next launch — the transport binds a persisted fallback BEFORE the +// preferred port, so pairings survive even when the preferred port is free +// again. + +const FALLBACK_PORT_FILE = 'mobile-ws-fallback-port.json' + +function isValidPort(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535 +} + +export function readWsFallbackPort(userDataPath: string): number | undefined { + try { + const raw = readFileSync(join(userDataPath, FALLBACK_PORT_FILE), 'utf8') + const parsed: unknown = JSON.parse(raw) + if ( + typeof parsed === 'object' && + parsed !== null && + isValidPort((parsed as { port?: unknown }).port) + ) { + return (parsed as { port: number }).port + } + } catch { + // Missing or corrupt file — treated as "no previous fallback". + } + return undefined +} + +export function writeWsFallbackPort(userDataPath: string, port: number): void { + if (!isValidPort(port)) { + return + } + try { + writeFileSync(join(userDataPath, FALLBACK_PORT_FILE), JSON.stringify({ port }), 'utf8') + } catch { + // Why: persistence is best-effort — failing to record the port must not + // break transport startup; the cost is a re-pair after the next restart. + } +} diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index b9f4c94ddcd..391e5e18284 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -441,4 +441,126 @@ describe('WebSocketTransport', () => { expect(serverClosed).toBe(true) expect(wss.clients.size).toBe(0) }, 5_000) + + // Why: paired mobile devices store ws://ip:port endpoints, so the fallback + // port must stay stable across restarts or pairings go permanently dead + // when the preferred port is held by another instance (STA-1511). + describe('fallback port stability', () => { + async function reserveFreePort(): Promise { + const scratch = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + await scratch.start() + const port = scratch.resolvedPort + await scratch.stop() + return port + } + + it('binds the persisted fallback port even when the preferred port is free', async () => { + // Why: regression for the STA-1511 follow-up — devices paired while the + // fallback port was active store ws://ip:. A later launch that + // finds the preferred port free must still bind the fallback, or those + // pairings go permanently dead until the user re-pairs. + const preferredPort = await reserveFreePort() + const fallbackPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(fallbackPort) + }) + + it('binds the preferred port when the persisted fallback is taken', async () => { + const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + transports.push(fallbackHolder) + await fallbackHolder.start() + const takenFallbackPort = fallbackHolder.resolvedPort + const preferredPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort: takenFallbackPort + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(preferredPort) + }) + + it('falls through to the preferred port when the fallback bind fails with a non-EADDRINUSE error', async () => { + // Why: a persisted fallback can land in an OS-reserved range on a later + // launch (Windows Hyper-V excluded ports → EACCES). That must degrade to + // the preferred port instead of disabling the transport for the session. + const preferredPort = await reserveFreePort() + const fallbackPort = await reserveFreePort() + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort + }) + transports.push(transport) + const withListen = transport as unknown as { tryListen(port: number): Promise } + const realTryListen = withListen.tryListen.bind(transport) + withListen.tryListen = (port: number) => + port === fallbackPort + ? Promise.reject(Object.assign(new Error('listen EACCES'), { code: 'EACCES' })) + : realTryListen(port) + + await transport.start() + expect(transport.resolvedPort).toBe(preferredPort) + }) + + it('still throws when the preferred port fails with a non-EADDRINUSE error', async () => { + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: await reserveFreePort() + }) + transports.push(transport) + const withListen = transport as unknown as { tryListen(port: number): Promise } + withListen.tryListen = () => + Promise.reject(Object.assign(new Error('listen EACCES'), { code: 'EACCES' })) + + await expect(transport.start()).rejects.toThrow('listen EACCES') + }) + + it('retries the persisted fallback port before an OS-assigned one', async () => { + const holder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + transports.push(holder) + await holder.start() + const takenPort = holder.resolvedPort + const fallbackPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: takenPort, + fallbackPort + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(fallbackPort) + }) + + it('falls back to an OS-assigned port when the persisted port is also taken', async () => { + const holder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + transports.push(holder, fallbackHolder) + await holder.start() + await fallbackHolder.start() + const takenPort = holder.resolvedPort + const takenFallbackPort = fallbackHolder.resolvedPort + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: takenPort, + fallbackPort: takenFallbackPort + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).not.toBe(takenPort) + expect(transport.resolvedPort).not.toBe(takenFallbackPort) + expect(transport.resolvedPort).toBeGreaterThan(0) + }) + }) }) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 37f6094b589..8b88c7a820e 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -48,6 +48,12 @@ export type WebSocketTransportOptions = { // Why: the pairing server can also serve the browser client, so users do // not need a second dev/static server once the web bundle is built. staticRoot?: string + // Why: paired mobile devices store the full ws://ip:port endpoint. Once a + // fallback port has been assigned and persisted, devices paired while it was + // active point at it, so it must be bound FIRST on later launches — binding + // the (now free) preferred port instead would strand those pairings + // (STA-1511). Callers pass the previously assigned fallback port here. + fallbackPort?: number } export class WebSocketTransport implements RpcTransport { @@ -58,6 +64,7 @@ export class WebSocketTransport implements RpcTransport { private readonly heartbeatIntervalMs: number private readonly preAuthTimeoutMs: number private readonly staticRoot: string | undefined + private readonly fallbackPort: number | undefined private httpServer: HttpsServer | HttpServer | null = null private wss: WebSocketServer | null = null private heartbeatTimer: ReturnType | null = null @@ -81,7 +88,8 @@ export class WebSocketTransport implements RpcTransport { tlsKey, heartbeatIntervalMs, preAuthTimeoutMs, - staticRoot + staticRoot, + fallbackPort }: WebSocketTransportOptions) { this.host = host this.port = port @@ -90,6 +98,7 @@ export class WebSocketTransport implements RpcTransport { this.heartbeatIntervalMs = heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS this.staticRoot = staticRoot + this.fallbackPort = fallbackPort } onMessage(handler: WebSocketMessageHandler): void { @@ -141,22 +150,41 @@ export class WebSocketTransport implements RpcTransport { return } - // Why: when the preferred port is occupied (e.g. another Orca instance is - // already running), fall back to an OS-assigned port so mobile pairing - // still works. The QR code reads resolvedPort after start, so it will - // advertise the correct port regardless. - let port = this.port - try { - await this.tryListen(port) - } catch (error: unknown) { - if (isEAddressInUse(error) && port !== 0) { - console.warn(`[ws-transport] Port ${port} is in use, falling back to OS-assigned port`) - port = 0 + // Why: a persisted fallback port is bound FIRST — devices paired while it + // was active store ws://ip: and would be permanently stranded if + // a later launch grabbed the (now free) preferred port instead (STA-1511). + // Without a persisted fallback the preferred port is tried first. On + // EADDRINUSE each candidate falls through to the next, ending at port 0 + // (OS-assigned) so mobile pairing still works when everything is taken. + // The QR code reads resolvedPort after start, so it always advertises the + // port that actually bound. + const persistedFallbackPort = + this.fallbackPort !== undefined && this.fallbackPort !== 0 && this.fallbackPort !== this.port + ? this.fallbackPort + : undefined + const candidatePorts = + persistedFallbackPort !== undefined ? [persistedFallbackPort, this.port] : [this.port] + for (const port of candidatePorts) { + try { await this.tryListen(port) - } else { - throw error + return + } catch (error: unknown) { + // Why: a persisted fallback can become unbindable for reasons beyond + // EADDRINUSE (e.g. Windows reserves dynamic-range ports for Hyper-V + // after a reboot → EACCES). Any fallback failure must degrade to the + // next candidate — aborting would disable the transport every launch + // while the store still names that port. Only preferred-port failures + // other than EADDRINUSE are fatal. + if (port !== persistedFallbackPort && (!isEAddressInUse(error) || port === 0)) { + throw error + } + console.warn( + `[ws-transport] Failed to bind port ${port} (${error instanceof Error ? error.message : String(error)}), trying next candidate` + ) } } + console.warn('[ws-transport] All configured ports failed to bind, using an OS-assigned port') + await this.tryListen(0) } private createHttpServer(): HttpServer | HttpsServer { diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index a5418500c1d..f8b3c75c7cf 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -16,6 +16,7 @@ import { errorResponse } from './rpc/errors' import type { RpcMessageContext, RpcTransport } from './rpc/transport' import { UnixSocketTransport } from './rpc/unix-socket-transport' import { WebSocketTransport } from './rpc/ws-transport' +import { readWsFallbackPort, writeWsFallbackPort } from './rpc/ws-fallback-port-store' import type { WebSocket } from 'ws' import { DeviceRegistry, type DeviceScope } from './device-registry' import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' @@ -308,6 +309,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'settings.update', 'ssh.connect', 'ssh.getState', + 'ssh.listRemovedTargetLabels', + 'ssh.listTargets', 'speech.dictation.cancel', 'speech.dictation.chunk', 'speech.dictation.finish', @@ -700,7 +703,13 @@ export class OrcaRuntimeRpcServer { const wsTransport = new WebSocketTransport({ host: '0.0.0.0', port: this.wsPort, - staticRoot: this.webClientRoot + staticRoot: this.webClientRoot, + // Why: keep the fallback port stable across restarts so paired + // devices' stored endpoints stay valid (STA-1511) — the transport + // binds a persisted fallback before the preferred port. wsPort 0 + // means the caller explicitly wants a random port (E2E) — don't + // pin it. + ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) }) this.wsTransport = wsTransport @@ -783,6 +792,9 @@ export class OrcaRuntimeRpcServer { }) await wsTransport.start() + if (this.wsPort !== 0 && wsTransport.resolvedPort !== this.wsPort) { + writeWsFallbackPort(this.userDataPath, wsTransport.resolvedPort) + } activeTransports.push(wsTransport) transportsMeta.push({ kind: 'websocket', diff --git a/src/main/speech/stt-offline-audio-chunker.test.ts b/src/main/speech/stt-offline-audio-chunker.test.ts new file mode 100644 index 00000000000..4b90976b2c2 --- /dev/null +++ b/src/main/speech/stt-offline-audio-chunker.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { OFFLINE_DECODE_CHUNK_SECONDS, OfflineAudioChunker } from './stt-offline-audio-chunker' + +// Why: a small rate keeps test arrays tiny while exercising the same +// seconds-based limits used with real 16 kHz audio. +const SAMPLE_RATE = 1000 +const CHUNK_LIMIT = OFFLINE_DECODE_CHUNK_SECONDS * SAMPLE_RATE + +function loudSignal(length: number): Float32Array { + const samples = new Float32Array(length) + for (let i = 0; i < length; i += 1) { + samples[i] = Math.sin(i * 0.3) * 0.8 + } + return samples +} + +describe('OfflineAudioChunker', () => { + it('buffers audio below the chunk limit without emitting chunks', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + + expect(chunker.push(loudSignal(CHUNK_LIMIT - 1))).toEqual([]) + }) + + it('emits a bounded chunk once the limit is reached and keeps the remainder', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + const total = CHUNK_LIMIT + 500 + + const ready = chunker.push(loudSignal(total)) + const remainder = chunker.flush() + + expect(ready).toHaveLength(1) + expect(ready[0].length).toBeLessThanOrEqual(CHUNK_LIMIT) + expect(ready[0].length).toBeGreaterThan(0) + expect(ready[0].length + (remainder?.length ?? 0)).toBe(total) + }) + + it('never emits a chunk larger than the limit across many small pushes', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + const emitted: Float32Array[] = [] + const pushSize = 160 + const pushes = Math.ceil((CHUNK_LIMIT * 3.5) / pushSize) + for (let i = 0; i < pushes; i += 1) { + emitted.push(...chunker.push(loudSignal(pushSize))) + } + const remainder = chunker.flush() + + expect(emitted.length).toBeGreaterThanOrEqual(3) + for (const chunk of emitted) { + expect(chunk.length).toBeLessThanOrEqual(CHUNK_LIMIT) + } + const totalOut = emitted.reduce((sum, c) => sum + c.length, 0) + (remainder?.length ?? 0) + expect(totalOut).toBe(pushes * pushSize) + }) + + it('splits multiple chunks out of one oversized push', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + + const ready = chunker.push(loudSignal(CHUNK_LIMIT * 2 + 100)) + + expect(ready.length).toBeGreaterThanOrEqual(2) + for (const chunk of ready) { + expect(chunk.length).toBeLessThanOrEqual(CHUNK_LIMIT) + } + }) + + it('splits at a silent pause near the chunk boundary instead of mid-speech', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + const samples = loudSignal(CHUNK_LIMIT + 200) + // Quiet gap 2 s before the limit, inside the 5 s split-search window. + const gapStart = CHUNK_LIMIT - 2 * SAMPLE_RATE + const gapEnd = gapStart + Math.round(0.2 * SAMPLE_RATE) + samples.fill(0, gapStart, gapEnd) + + const [chunk] = chunker.push(samples) + + expect(chunk.length).toBeGreaterThanOrEqual(gapStart) + expect(chunk.length).toBeLessThanOrEqual(gapEnd) + }) + + it('conserves sample values across the split', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + const samples = loudSignal(CHUNK_LIMIT + 50) + + const [chunk] = chunker.push(samples) + const remainder = chunker.flush() + + const rejoined = new Float32Array(samples.length) + rejoined.set(chunk, 0) + rejoined.set(remainder!, chunk.length) + expect(rejoined).toEqual(samples) + }) + + it('flush returns null when nothing is buffered', () => { + const chunker = new OfflineAudioChunker(SAMPLE_RATE) + + expect(chunker.flush()).toBeNull() + expect(chunker.push(new Float32Array(0))).toEqual([]) + expect(chunker.flush()).toBeNull() + }) +}) diff --git a/src/main/speech/stt-offline-audio-chunker.ts b/src/main/speech/stt-offline-audio-chunker.ts new file mode 100644 index 00000000000..291454bf083 --- /dev/null +++ b/src/main/speech/stt-offline-audio-chunker.ts @@ -0,0 +1,92 @@ +// Why: offline recognizers decode a whole buffer per call, and ONNX Runtime's +// arena allocations scale with buffer length. Chromium's allocator shim kills +// the entire app on any single allocation >= 2 GiB (#7925), so audio must be +// decoded in bounded chunks regardless of how long dictation runs. +export const OFFLINE_DECODE_CHUNK_SECONDS = 30 + +// Why: cutting audio mid-word degrades transcription at chunk boundaries. +// Search the tail of each chunk for its quietest window and split at its +// center, so cuts land on real inter-word pauses whenever one exists. The +// window must be pause-sized (~100ms): shorter windows match momentary +// quiet inside a word (e.g. plosive closures) and cut mid-word. +const SPLIT_SEARCH_SECONDS = 5 +const SPLIT_ENERGY_WINDOW_SECONDS = 0.1 + +export class OfflineAudioChunker { + private buffered: Float32Array[] = [] + private bufferedSamples = 0 + private readonly chunkSampleLimit: number + private readonly splitSearchSamples: number + private readonly energyWindowSamples: number + + constructor(sampleRate: number) { + this.chunkSampleLimit = Math.max(1, Math.round(OFFLINE_DECODE_CHUNK_SECONDS * sampleRate)) + this.splitSearchSamples = Math.round(SPLIT_SEARCH_SECONDS * sampleRate) + this.energyWindowSamples = Math.max(1, Math.round(SPLIT_ENERGY_WINDOW_SECONDS * sampleRate)) + } + + /** Buffers samples and returns any full chunks now ready to decode. */ + push(samples: Float32Array): Float32Array[] { + if (samples.length === 0) { + return [] + } + this.buffered.push(samples) + this.bufferedSamples += samples.length + + const ready: Float32Array[] = [] + while (this.bufferedSamples >= this.chunkSampleLimit) { + const combined = this.combineBuffered() + const splitIndex = this.findQuietSplitIndex(combined) + ready.push(combined.slice(0, splitIndex)) + const tail = combined.slice(splitIndex) + this.buffered = tail.length > 0 ? [tail] : [] + this.bufferedSamples = tail.length + } + return ready + } + + /** Returns all remaining buffered audio (any length below the chunk limit). */ + flush(): Float32Array | null { + if (this.bufferedSamples === 0) { + return null + } + const combined = this.combineBuffered() + this.buffered = [] + this.bufferedSamples = 0 + return combined + } + + private combineBuffered(): Float32Array { + if (this.buffered.length === 1) { + return this.buffered[0] + } + const combined = new Float32Array(this.bufferedSamples) + let offset = 0 + for (const chunk of this.buffered) { + combined.set(chunk, offset) + offset += chunk.length + } + return combined + } + + private findQuietSplitIndex(samples: Float32Array): number { + const limit = Math.min(this.chunkSampleLimit, samples.length) + const window = this.energyWindowSamples + const searchStart = Math.max(0, limit - this.splitSearchSamples) + const hop = Math.max(1, Math.floor(window / 2)) + let bestIndex = limit + let bestEnergy = Infinity + for (let start = searchStart; start + window <= limit; start += hop) { + let energy = 0 + for (let i = start; i < start + window; i += 1) { + energy += samples[i] * samples[i] + } + if (energy < bestEnergy) { + bestEnergy = energy + bestIndex = start + Math.floor(window / 2) + } + } + // Why: the split must consume at least one sample or push() would loop forever. + return Math.max(1, bestIndex) + } +} diff --git a/src/main/speech/stt-worker.ts b/src/main/speech/stt-worker.ts index 9456f95c8b5..2f4f9b025d4 100644 --- a/src/main/speech/stt-worker.ts +++ b/src/main/speech/stt-worker.ts @@ -2,6 +2,7 @@ import { parentPort, workerData } from 'node:worker_threads' import { readdirSync } from 'node:fs' import { resampleToRate } from './stt-audio-resample' +import { OfflineAudioChunker } from './stt-offline-audio-chunker' type WorkerMessage = | { @@ -27,7 +28,7 @@ let sherpa: any = null let recognizer: any = null let stream: any = null let isStreaming = false -let offlineBuffer: Float32Array[] = [] +let offlineChunker: OfflineAudioChunker | null = null let offlineSampleRate = 16000 function loadSherpa(): any { @@ -111,7 +112,7 @@ function handleInit(msg: Extract): void { const { modelDir, modelType, streaming, sampleRate, files } = msg isStreaming = streaming - offlineBuffer = [] + offlineChunker = streaming ? null : new OfflineAudioChunker(sampleRate) offlineSampleRate = sampleRate const tokens = resolveTokens(files, modelDir) @@ -203,6 +204,17 @@ function handleInit(msg: Extract): void { } } +// Why: an offline stream is single-use — decode one bounded chunk, then +// recreate the stream so the recognizer is ready for the next chunk. +function decodeOfflineChunk(samples: Float32Array): string { + sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples }) + sherpa.decodeOfflineStream(recognizer, stream) + const resultJson = sherpa.getOfflineStreamResultAsJson(stream) + stream = sherpa.createOfflineStream(recognizer) + const result = JSON.parse(resultJson) + return result?.text?.trim() ?? '' +} + function handleFeed(msg: Extract): void { if (!recognizer || !stream) { return @@ -236,9 +248,17 @@ function handleFeed(msg: Extract): void { sherpa.reset(recognizer, stream) } } else { - // Why: offline recognizers cannot decode incrementally — they need all - // audio buffered first, then decoded in one shot when dictation stops. - offlineBuffer.push(new Float32Array(samples)) + // Why: decoding one unbounded capture in a single call makes ONNX tensor + // sizes scale with dictation length until a >=2 GiB allocation SIGTRAPs + // the whole app (#7925). Decode bounded chunks as they fill instead; + // each consumer already appends multiple 'final' segments per session. + const readyChunks = offlineChunker?.push(new Float32Array(samples)) ?? [] + for (const chunk of readyChunks) { + const text = decodeOfflineChunk(chunk) + if (text) { + parentPort?.postMessage({ type: 'final', text }) + } + } } } catch (err) { parentPort?.postMessage({ type: 'error', error: String(err) }) @@ -265,27 +285,15 @@ function handleStop(): void { } stream = sherpa.createOnlineStream(recognizer) } else { - // Why: offline recognizer decodes all audio at once — concatenate - // buffered chunks into a single Float32Array and feed it to the stream. - const totalLength = offlineBuffer.reduce((sum, chunk) => sum + chunk.length, 0) - if (totalLength > 0) { - const combined = new Float32Array(totalLength) - let offset = 0 - for (const chunk of offlineBuffer) { - combined.set(chunk, offset) - offset += chunk.length - } - sherpa.acceptWaveformOffline(stream, { sampleRate: offlineSampleRate, samples: combined }) - sherpa.decodeOfflineStream(recognizer, stream) - const resultJson = sherpa.getOfflineStreamResultAsJson(stream) - const result = JSON.parse(resultJson) - const text = result?.text?.trim() + // Why: the remainder is below the chunk limit by construction, so this + // last decode is bounded too. + const remaining = offlineChunker?.flush() + if (remaining && remaining.length > 0) { + const text = decodeOfflineChunk(remaining) if (text) { parentPort?.postMessage({ type: 'final', text }) } } - offlineBuffer = [] - stream = sherpa.createOfflineStream(recognizer) } } catch (err) { parentPort?.postMessage({ type: 'error', error: String(err) }) @@ -298,7 +306,7 @@ function handleTeardown(): void { stream = null recognizer = null sherpa = null - offlineBuffer = [] + offlineChunker = null process.exit(0) } diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 8c56ae145b0..10123d56a25 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -307,6 +307,81 @@ describe('SshChannelMultiplexer', () => { }) }) + describe('wake guard (timer pause across system sleep, #7773)', () => { + it('does not kill a healthy link on the first tick after a long timer pause', () => { + // Reach steady state with pending unacked keepalives (<5s old at pause). + vi.advanceTimersByTime(5_000) + expect(mux.isDisposed()).toBe(false) + + // Simulate sleep/App Nap: wall clock jumps far ahead with no ticks. + // Without the guard, the first post-wake tick sees lastReceivedAt and + // the pre-pause keepalive both >20s stale and disposes the mux. + vi.setSystemTime(Date.now() + 60 * 60 * 1000) + const writesBefore = transport.written.length + vi.advanceTimersByTime(5_000) + + expect(mux.isDisposed()).toBe(false) + // The guard probes immediately with a fresh keepalive. + expect(transport.written.length).toBeGreaterThan(writesBefore) + expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive) + }) + + it('keeps the link alive after wake when frames resume', () => { + vi.advanceTimersByTime(5_000) + vi.setSystemTime(Date.now() + 60 * 60 * 1000) + vi.advanceTimersByTime(5_000) // guard tick + + // The relay answers the post-wake probe; the link must stay up. + let seq = 1 + for (let i = 0; i < 8; i++) { + vi.advanceTimersByTime(5_000) + transport.dataCallbacks[0](encodeKeepAliveFrame(seq++, 0)) + } + expect(mux.isDisposed()).toBe(false) + }) + + it('still detects a genuinely dead link within the next window after wake', () => { + vi.advanceTimersByTime(5_000) + vi.setSystemTime(Date.now() + 60 * 60 * 1000) + vi.advanceTimersByTime(5_000) // guard tick: reset + probe, no kill + + expect(mux.isDisposed()).toBe(false) + // No frames arrive after the guard reset; the honest window expires. + vi.advanceTimersByTime(25_000) + expect(mux.isDisposed()).toBe(true) + }) + }) + + describe('probeLiveness', () => { + it('sends a keepalive and resolves true when any frame arrives', async () => { + const writesBefore = transport.written.length + const probe = mux.probeLiveness(5_000) + + expect(transport.written.length).toBe(writesBefore + 1) + expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive) + + transport.dataCallbacks[0](encodeKeepAliveFrame(1, 0)) + await expect(probe).resolves.toBe(true) + }) + + it('resolves false when no frame arrives before the timeout', async () => { + const probe = mux.probeLiveness(5_000) + vi.advanceTimersByTime(5_000) + await expect(probe).resolves.toBe(false) + }) + + it('resolves false when the mux is disposed while probing', async () => { + const probe = mux.probeLiveness(5_000) + mux.dispose() + await expect(probe).resolves.toBe(false) + }) + + it('resolves false immediately on a disposed mux', async () => { + mux.dispose() + await expect(mux.probeLiveness(5_000)).resolves.toBe(false) + }) + }) + describe('dispose', () => { it('rejects all pending requests on dispose', async () => { const promise = mux.request('pty.spawn') diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 09642387ee8..8cb0a24f302 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -34,6 +34,9 @@ export type MethodNotificationHandler = (params: Record) => voi export type RequestHandler = (params: Record) => Promise | unknown const REQUEST_TIMEOUT_MS = 30_000 +// Why: a tick gap far beyond the interval means the process was paused +// (system sleep, App Nap timer throttling) — not that the link is dead (#7773). +const WAKE_GAP_MS = KEEPALIVE_SEND_MS * 3 export class SshChannelMultiplexer { private decoder: FrameDecoder @@ -58,6 +61,10 @@ export class SshChannelMultiplexer { // Track the oldest unacked outgoing message timestamp private unackedTimestamps = new Map() + // Why: liveness probes (#7773) resolve on the first frame of any kind — + // a keepalive ack proves the relay round-trip without a full RPC. + private livenessProbeWaiters: { succeed: () => void; fail: () => void }[] = [] + constructor(transport: MultiplexerTransport) { this.transport = transport @@ -232,6 +239,31 @@ export class SshChannelMultiplexer { this.sendMessage(msg) } + /** + * Send a fresh keepalive and resolve true when any frame arrives before the + * timeout. Used on system resume to distinguish a link that survived sleep + * from a dead one before tearing the session down (#7773). + */ + probeLiveness(timeoutMs: number): Promise { + if (this.disposed) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + const settle = (alive: boolean): void => { + clearTimeout(timer) + const idx = this.livenessProbeWaiters.indexOf(waiter) + if (idx !== -1) { + this.livenessProbeWaiters.splice(idx, 1) + } + resolve(alive) + } + const waiter = { succeed: () => settle(true), fail: () => settle(false) } + const timer = setTimeout(() => settle(false), timeoutMs) + this.livenessProbeWaiters.push(waiter) + this.sendKeepAlive() + }) + } + dispose(reason: 'shutdown' | 'connection_lost' = 'shutdown'): void { if (this.disposed) { return @@ -259,6 +291,10 @@ export class SshChannelMultiplexer { reason === 'connection_lost' ? 'SSH connection lost, reconnecting...' : 'Multiplexer disposed' const errorCode = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' + for (const waiter of this.livenessProbeWaiters.splice(0)) { + waiter.fail() + } + for (const [id, pending] of this.pendingRequests) { pending.cleanup() const err = new Error(errorMessage) as Error & { code: string } @@ -322,6 +358,12 @@ export class SshChannelMultiplexer { } private handleFrame(frame: DecodedFrame): void { + // Why: any decoded frame proves the relay round-trip is alive; resolve + // pending resume probes before ordinary dispatch (#7773). + for (const waiter of this.livenessProbeWaiters.splice(0)) { + waiter.succeed() + } + // Update ack tracking if (frame.id > this.highestReceivedSeq) { this.highestReceivedSeq = frame.id @@ -453,12 +495,28 @@ export class SshChannelMultiplexer { } private startTimeoutCheck(): void { + let lastTickAt = Date.now() this.timeoutTimer = setInterval(() => { if (this.disposed) { return } const now = Date.now() + const sinceLastTick = now - lastTickAt + lastTickAt = now + // Why: after sleep/App Nap the pre-pause keepalive looks stale on the + // first post-wake tick, killing a healthy link (#7773). Reset staleness + // tracking, probe with a fresh keepalive, and let the NEXT full window + // make an honest liveness determination. + if (sinceLastTick > WAKE_GAP_MS) { + this.lastReceivedAt = now + for (const seq of this.unackedTimestamps.keys()) { + this.unackedTimestamps.set(seq, now) + } + this.sendKeepAlive() + return + } + const noDataReceived = now - this.lastReceivedAt > TIMEOUT_MS // Check oldest unacked message diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 8e3e347d709..02e69c6dc97 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshConnectionStore } from './ssh-connection-store' -import type { SshTarget } from '../../shared/ssh-types' +import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ loadUserSshConfigMock: vi.fn(), @@ -14,6 +14,9 @@ vi.mock('./ssh-config-parser', () => ({ function createMockStore() { const targets: SshTarget[] = [] + let deletedAliases: string[] = [] + const removedTombstones: RemovedSshTargetTombstone[] = [] + const reassignments: { oldTargetId: string; newTargetId: string }[] = [] return { getSshTargets: vi.fn(() => [...targets]), @@ -32,6 +35,36 @@ function createMockStore() { if (idx !== -1) { targets.splice(idx, 1) } + }), + getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]), + addDeletedSshConfigAlias: vi.fn((alias: string) => { + if (!deletedAliases.includes(alias)) { + deletedAliases.push(alias) + } + }), + removeDeletedSshConfigAlias: vi.fn((alias: string) => { + deletedAliases = deletedAliases.filter((entry) => entry !== alias) + }), + clearDeletedSshConfigAliases: vi.fn(() => { + deletedAliases = [] + }), + removedTombstones, + reassignments, + getRemovedSshTargetTombstones: vi.fn(() => [...removedTombstones]), + addRemovedSshTargetTombstone: vi.fn((tombstone: RemovedSshTargetTombstone) => { + const filtered = removedTombstones.filter((t) => t.oldTargetId !== tombstone.oldTargetId) + removedTombstones.length = 0 + removedTombstones.push(...filtered, tombstone) + }), + removeRemovedSshTargetTombstone: vi.fn((oldTargetId: string) => { + const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId) + removedTombstones.length = 0 + removedTombstones.push(...kept) + }), + reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => { + reassignments.push({ oldTargetId, newTargetId }) + // Pretend one repo referenced the old id. + return 1 }) } } @@ -324,4 +357,242 @@ describe('SshConnectionStore', () => { expect(result).toEqual([]) }) }) + + describe('deleted config host tombstones', () => { + function candidate(overrides: Partial & { configHost: string }): SshTarget { + return { + id: `tmp-${overrides.configHost}`, + label: overrides.configHost, + host: `${overrides.configHost}.example.com`, + port: 22, + username: '', + ...overrides + } + } + + // PRIMARY regression: deleting a config-sourced host must not let the next + // ~/.ssh/config sync resurrect it. + it('tombstones a deleted config-sourced host so re-import skips it', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'ssh-config' + }) + + sshStore.removeTarget('ssh-1') + expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + + const result = sshStore.importFromSshConfig() + + expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) // only the seed insert + expect(result).toEqual([]) + }) + + it('does not tombstone a manual target on delete', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'manual' + }) + + sshStore.removeTarget('ssh-1') + expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled() + }) + + it('re-adding a deleted host reclaims its alias so sync stops suppressing it', () => { + mockStore.addDeletedSshConfigAlias('mini') + + sshStore.addTarget({ + label: 'mini', + configHost: 'mini', + host: '10.0.0.2', + port: 22, + username: 'ping' + }) + expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + // Alias reclaimed, but it is now a manual target — still not re-inserted. + const result = sshStore.importFromSshConfig() + expect(result).toEqual([]) + }) + + it('editing a target reclaims its alias from the deleted set', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'mini', + configHost: 'mini', + host: 'mini.example.com', + port: 22, + username: 'ping', + source: 'ssh-config' + }) + mockStore.addDeletedSshConfigAlias('mini') + + sshStore.updateTarget('ssh-1', { port: 2222, source: 'manual' }) + expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini') + }) + + it('reAdopt clears all tombstones and re-imports the deleted host', () => { + mockStore.addDeletedSshConfigAlias('mini') + loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }]) + sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })]) + + const result = sshStore.importFromSshConfig({ reAdopt: true }) + + expect(mockStore.clearDeletedSshConfigAliases).toHaveBeenCalled() + expect(mockStore.addSshTarget).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'mini', source: 'ssh-config' }) + ) + expect(result).toHaveLength(1) + }) + }) + + describe('re-adoption of orphaned workspaces', () => { + it('records a tombstone when removing any user-facing target', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'Dev', + host: 'dev.example.com', + port: 22, + username: 'tim', + source: 'manual' + }) + + sshStore.removeTarget('ssh-1') + + expect(mockStore.addRemovedSshTargetTombstone).toHaveBeenCalledWith( + expect.objectContaining({ + oldTargetId: 'ssh-1', + host: 'dev.example.com', + port: 22, + username: 'tim' + }) + ) + }) + + it('does not tombstone runtime-owned targets', () => { + mockStore.addSshTarget({ + id: 'runtime-ssh-abc', + label: 'VM', + host: 'vm.example.com', + port: 22, + username: 'tim', + owner: { type: 'on-demand-runtime', runtimeId: 'abc' } + }) + + sshStore.removeTarget('runtime-ssh-abc') + + expect(mockStore.addRemovedSshTargetTombstone).not.toHaveBeenCalled() + }) + + it('re-adopts orphaned repos when the same host is re-added', () => { + // Simulate a prior removal by seeding a matching tombstone. + mockStore.addRemovedSshTargetTombstone({ + oldTargetId: 'ssh-old', + host: 'dev.example.com', + port: 22, + username: 'tim', + label: 'Dev', + removedAt: 1 + }) + + sshStore.addTarget({ + label: 'Dev', + host: 'dev.example.com', + port: 22, + username: 'tim' + }) + + expect(mockStore.reassignSshTargetId).toHaveBeenCalledTimes(1) + const [oldId, newId] = mockStore.reassignSshTargetId.mock.calls[0] + expect(oldId).toBe('ssh-old') + expect(newId).toMatch(/^ssh-/) + // Re-adoption count surfaces so the IPC layer can refresh the repo list. + expect(sshStore.lastReadoptedRepoCount).toBe(1) + }) + + // Why: drive the real remove→re-add path so the tombstone carries the + // defaulted configHost (host) that buildRemovedSshTargetTombstone produces, + // rather than a hand-seeded tombstone without one. + it('re-adopts through an actual removeTarget then re-add of the same host', () => { + const added = sshStore.addTarget({ + label: 'Dev', + host: 'dev.example.com', + port: 22, + username: 'tim' + }) + sshStore.removeTarget(added.id) + // The tombstone was built from the real target (configHost defaulted to host). + const tombstones = mockStore.getRemovedSshTargetTombstones() + expect(tombstones).toHaveLength(1) + expect(tombstones[0]).toMatchObject({ oldTargetId: added.id, configHost: 'dev.example.com' }) + + mockStore.reassignSshTargetId.mockClear() + const readded = sshStore.addTarget({ + label: 'Dev', + host: 'dev.example.com', + port: 22, + username: 'tim' + }) + + expect(mockStore.reassignSshTargetId).toHaveBeenCalledWith(added.id, readded.id) + expect(sshStore.lastReadoptedRepoCount).toBe(1) + }) + + // A different account on the SAME host must NOT re-adopt, even though both + // manual adds default configHost to the shared hostname. + it('does not re-adopt a different account on the same host', () => { + const alice = sshStore.addTarget({ + label: 'alice', + host: 'dev.example.com', + port: 22, + username: 'alice' + }) + sshStore.removeTarget(alice.id) + mockStore.reassignSshTargetId.mockClear() + + sshStore.addTarget({ + label: 'bob', + host: 'dev.example.com', + port: 2222, + username: 'bob' + }) + + expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled() + }) + + it('does not re-adopt when the re-added host identity differs', () => { + mockStore.addRemovedSshTargetTombstone({ + oldTargetId: 'ssh-old', + host: 'other.example.com', + port: 22, + username: 'root', + label: 'Other', + removedAt: 1 + }) + + sshStore.addTarget({ + label: 'Dev', + host: 'dev.example.com', + port: 22, + username: 'tim' + }) + + expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled() + expect(sshStore.lastReadoptedRepoCount).toBe(0) + }) + }) }) diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index d65bd42b9f0..df3e6cd8fee 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -2,6 +2,10 @@ import type { Store } from '../persistence' import type { SshTarget } from '../../shared/ssh-types' import { RUNTIME_OWNED_SSH_TARGET_ID_PREFIX } from '../../shared/execution-host' import { loadUserSshConfig, sshConfigHostsToTargets } from './ssh-config-parser' +import { + buildRemovedSshTargetTombstone, + readoptOrphanedWorkspacesForTarget +} from './ssh-target-readoption' export class SshConnectionStore { constructor(private store: Store) {} @@ -10,6 +14,17 @@ export class SshConnectionStore { return this.store.getSshTargets().filter((target) => !isRuntimeOwnedSshTarget(target)) } + /** Map of removed-target id → its last known label, from the re-adoption + * tombstones. Lets the renderer show a friendly host name for a workspace + * still pinned to a target that no longer exists. */ + listRemovedTargetLabels(): Record { + const labels: Record = {} + for (const tombstone of this.store.getRemovedSshTargetTombstones()) { + labels[tombstone.oldTargetId] = tombstone.label + } + return labels + } + getTarget(id: string): SshTarget | undefined { return this.store.getSshTarget(id) } @@ -23,10 +38,21 @@ export class SshConnectionStore { source: target.source ?? 'manual', id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } + // Why: re-adding a host the user previously deleted is an explicit intent to + // keep it — lift any tombstone so config sync stops suppressing this alias. + this.reclaimAlias(full.configHost ?? full.label) this.store.addSshTarget(full) + // Why: re-adopt workspaces that were orphaned when the same host was removed + // (repos/worktrees still point at the old, now-dead target id). Track the + // count so the IPC layer knows whether to refresh the repo list. + this.lastReadoptedRepoCount = readoptOrphanedWorkspacesForTarget(this.store, full) return full } + /** Repos re-adopted by the most recent addTarget/importFromSshConfig call. + * Lets the IPC layer broadcast repos:changed only when workspaces reattached. */ + lastReadoptedRepoCount = 0 + upsertRuntimeOwnedTarget( runtimeId: string, target: Omit @@ -51,19 +77,57 @@ export class SshConnectionStore { } updateTarget(id: string, updates: Partial>): SshTarget | null { - return this.store.updateSshTarget(id, updates) + const updated = this.store.updateSshTarget(id, updates) + if (updated) { + // Why: actively editing a target reclaims its alias from the deleted set, + // so an edit can never leave the host tombstoned. + this.reclaimAlias(updated.configHost ?? updated.label) + } + return updated } removeTarget(id: string): void { + const target = this.store.getSshTarget(id) + // Why: deleting a config-managed target must record a tombstone; otherwise + // the next ~/.ssh/config sync re-inserts it verbatim (the config entry still + // exists on disk) and the host reappears. Manual targets need no tombstone — + // sync never re-adds them. + if (target && isConfigManagedTarget(target)) { + const alias = target.configHost ?? target.label + if (alias) { + this.store.addDeletedSshConfigAlias(alias) + } + } + // Why: record the removed target's host identity (for ALL user-facing + // targets, config-managed or manual) so a later re-add of the same host can + // re-adopt any workspaces orphaned on this id. Runtime-owned targets manage + // their own lifecycle and are never re-adopted. + if (target && !isRuntimeOwnedSshTarget(target)) { + this.store.addRemovedSshTargetTombstone(buildRemovedSshTargetTombstone(target, Date.now())) + } this.store.removeSshTarget(id) } + private reclaimAlias(alias: string | undefined): void { + if (alias) { + this.store.removeDeletedSshConfigAlias(alias) + } + } + /** * Sync targets from ~/.ssh/config: insert new hosts, update existing * config-sourced ones in place (so a rotated port takes effect), never touch * manual targets. Returns the inserted and updated targets. */ - importFromSshConfig(): SshTarget[] { + importFromSshConfig(options?: { reAdopt?: boolean }): SshTarget[] { + let readoptedThisImport = 0 + // Why: the explicit Import action re-adopts every config host, so it clears + // all tombstones first. The passive on-open sync passes no flag and keeps + // deleted hosts suppressed. + if (options?.reAdopt) { + this.store.clearDeletedSshConfigAliases() + } + const deletedAliases = new Set(this.store.getDeletedSshConfigAliases()) const configHosts = loadUserSshConfig() const existingTargets = this.store.getSshTargets() // Map config-managed targets (and legacy targets that strongly look like @@ -102,6 +166,11 @@ export class SshConnectionStore { // A manual target owns this alias — never clobber it. continue } + if (deletedAliases.has(alias)) { + // The user deleted this config host — stay deleted until they re-add it + // or re-adopt config explicitly. + continue + } if (processedAliases.has(alias)) { continue } @@ -140,10 +209,15 @@ export class SshConnectionStore { } else { const inserted: SshTarget = { ...candidate, source: 'ssh-config' } this.store.addSshTarget(inserted) + // Why: a freshly-inserted config host may be one the user removed and is + // now re-importing — re-adopt its orphaned workspaces. Updated-in-place + // targets keep their id, so their repos were never orphaned. + readoptedThisImport += readoptOrphanedWorkspacesForTarget(this.store, inserted) changed.push(inserted) } } + this.lastReadoptedRepoCount = readoptedThisImport return changed } } @@ -156,6 +230,16 @@ export function isRuntimeOwnedSshTarget(target: SshTarget): boolean { return target.owner?.type === 'on-demand-runtime' } +function isConfigManagedTarget(target: SshTarget): boolean { + // Why: a target is subject to config sync (and therefore needs a tombstone on + // delete) when it is explicitly config-sourced, or a legacy import that sync + // still adopts. Manual targets are excluded — sync never re-adds them. + return ( + target.source === 'ssh-config' || + (target.source === undefined && isLegacyConfigImportTarget(target)) + ) +} + function isLegacyConfigImportTarget(target: SshTarget): boolean { const alias = target.configHost ?? target.label // Why: legacy manual and imported targets both lack `source`. Only adopt the diff --git a/src/main/ssh/ssh-port-forward.test.ts b/src/main/ssh/ssh-port-forward.test.ts index 31e26a5bc40..b49c627b8b3 100644 --- a/src/main/ssh/ssh-port-forward.test.ts +++ b/src/main/ssh/ssh-port-forward.test.ts @@ -267,6 +267,27 @@ describe('SshPortForwardManager', () => { ) }) + it('keeps only a bounded tail of a chatty forward stderr (memory-leak regression)', async () => { + const onForwardClosed = vi.fn() + manager.setCallbacks({ onForwardClosed }) + const forward = createFakeSystemSshForward() + startSystemSshPortForwardProcessMock.mockReturnValue(forward) + const conn = createSystemSshConn() + + await manager.addForward('conn-1', conn as never, 3000, '127.0.0.1', 8080) + // A long-lived forward against a chatty remote sshd: emit >64 KB of stderr. + forward.process.stderr.emit('data', Buffer.from(`HEAD_MARKER${'x'.repeat(70 * 1024)}`)) + forward.process.stderr.emit('data', Buffer.from('TAIL_MARKER')) + forward.process.emit('exit', 255) + + const detail = onForwardClosed.mock.calls[0]?.[1]?.detail as string + // The oldest bytes are trimmed; the most-recent tail is retained. + expect(detail).toContain('TAIL_MARKER') + expect(detail).not.toContain('HEAD_MARKER') + // Bounded well below the ~70 KB produced. + expect(detail.length).toBeLessThan(66 * 1024) + }) + it('lists forwards filtered by connectionId', async () => { const conn = createMockConn() await manager.addForward('conn-1', conn as never, 3000, 'localhost', 8080) diff --git a/src/main/ssh/ssh-port-scanner.test.ts b/src/main/ssh/ssh-port-scanner.test.ts new file mode 100644 index 00000000000..09be7c1f686 --- /dev/null +++ b/src/main/ssh/ssh-port-scanner.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PortScanner, + SSH_PORT_SCAN_BASE_INTERVAL_MS, + SSH_PORT_SCAN_MAX_INTERVAL_MS, + type PortScannerWindowVisibility +} from './ssh-port-scanner' +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import type { DetectedPort } from '../../shared/ssh-types' + +type VisibilityHarness = { + visibility: PortScannerWindowVisibility + setVisible: (visible: boolean) => void + listenerCount: () => number +} + +function createVisibilityHarness(initiallyVisible: boolean): VisibilityHarness { + let visible = initiallyVisible + const listeners = new Set<() => void>() + return { + visibility: { + isWindowVisible: () => visible, + onWindowBecameVisible: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + } + }, + setVisible: (next) => { + const wasVisible = visible + visible = next + if (!wasVisible && next) { + for (const listener of Array.from(listeners)) { + listener() + } + } + }, + listenerCount: () => listeners.size + } +} + +function port(portNumber: number): DetectedPort { + return { port: portNumber, host: '127.0.0.1', pid: 100 + portNumber, processName: 'node' } +} + +function createMux(ports: () => DetectedPort[]): { + mux: SshChannelMultiplexer + request: ReturnType +} { + const request = vi.fn(async () => ({ ports: ports(), platform: 'linux' })) + return { mux: { request } as unknown as SshChannelMultiplexer, request } +} + +const BASE = SSH_PORT_SCAN_BASE_INTERVAL_MS +const MAX = SSH_PORT_SCAN_MAX_INTERVAL_MS + +describe('PortScanner', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('sends zero ports.detect requests while the window is hidden, then scans immediately on show', async () => { + const harness = createVisibilityHarness(false) + const { mux, request } = createMux(() => [port(3000)]) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', mux, vi.fn()) + + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(request).not.toHaveBeenCalled() + + harness.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(1) + + scanner.dispose() + }) + + it('polls at the base cadence while visible when results keep changing', async () => { + const harness = createVisibilityHarness(true) + let next = 3000 + const { mux, request } = createMux(() => [port(next++)]) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', mux, vi.fn()) + + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(1) + + // 5 simulated base intervals -> 5 more scans (no backoff while changing). + await vi.advanceTimersByTimeAsync(5 * BASE) + expect(request).toHaveBeenCalledTimes(6) + + scanner.dispose() + }) + + it('doubles the interval up to the cap while unchanged and resets to base on a change', async () => { + const harness = createVisibilityHarness(true) + let ports = [port(3000)] + const { mux, request } = createMux(() => ports) + const onChanged = vi.fn() + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', mux, onChanged) + + // t=0: first scan is a change (empty -> {3000}) and stays at base. + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(1) + expect(onChanged).toHaveBeenCalledTimes(1) + + // t=12s: unchanged -> next wait doubles to 24s. + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(2) + + // t=24s: nothing (waiting until t=36s). + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(2) + + // t=36s: unchanged -> next wait caps at 30s (not 48s). + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(3) + + // t=65.999s: still waiting. + await vi.advanceTimersByTimeAsync(MAX - 1) + expect(request).toHaveBeenCalledTimes(3) + + // t=66s: unchanged -> next wait remains capped at 30s. + await vi.advanceTimersByTimeAsync(1) + expect(request).toHaveBeenCalledTimes(4) + + await vi.advanceTimersByTimeAsync(MAX - 1) + expect(request).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(1) + expect(request).toHaveBeenCalledTimes(5) + expect(onChanged).toHaveBeenCalledTimes(1) + + // A changed result resets the cadence back to base. + ports = [port(3000), port(4000)] + await vi.advanceTimersByTimeAsync(MAX) + expect(request).toHaveBeenCalledTimes(6) + expect(onChanged).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(7) + expect(scanner.getDetectedPorts('t1').map((p) => p.port)).toEqual([3000, 4000]) + + scanner.dispose() + }) + + it('resets backoff and scans immediately when scanning restarts (reconnect/session-ready)', async () => { + const harness = createVisibilityHarness(true) + const { mux, request } = createMux(() => [port(3000)]) + const scanner = new PortScanner(harness.visibility) + const onChanged = vi.fn() + scanner.startScanning('t1', mux, onChanged) + + // Back off: scans at t=0, t=12s, t=36s -> interval now 48s. + await vi.advanceTimersByTimeAsync(3 * BASE) + expect(request).toHaveBeenCalledTimes(3) + + // Reconnect paths call startScanning again: immediate scan, base cadence. + scanner.startScanning('t1', mux, onChanged) + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(4) + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(5) + + scanner.dispose() + }) + + it('parks when the window hides mid-run and resumes with an immediate scan on show', async () => { + const harness = createVisibilityHarness(true) + let next = 3000 + const { mux, request } = createMux(() => [port(next++)]) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', mux, vi.fn()) + + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(2) + + harness.setVisible(false) + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(request).toHaveBeenCalledTimes(2) + + harness.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(4) + + scanner.dispose() + }) + + it('never overlaps a slow in-flight request and resumes the chain after it settles', async () => { + const harness = createVisibilityHarness(true) + let resolveFirst: ((value: { ports: DetectedPort[]; platform: string }) => void) | null = null + const request = vi.fn( + () => + new Promise<{ ports: DetectedPort[]; platform: string }>((resolve) => { + resolveFirst = resolve + }) + ) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', { request } as unknown as SshChannelMultiplexer, vi.fn()) + expect(request).toHaveBeenCalledTimes(1) + + // The chain waits for the in-flight request instead of stacking more. + await vi.advanceTimersByTimeAsync(10 * BASE) + expect(request).toHaveBeenCalledTimes(1) + + resolveFirst!({ ports: [port(3000)], platform: 'linux' }) + await vi.advanceTimersByTimeAsync(BASE) + expect(request).toHaveBeenCalledTimes(2) + + scanner.dispose() + }) + + it('stopScanning halts polling and detaches the visibility listener', async () => { + const harness = createVisibilityHarness(true) + const { mux, request } = createMux(() => [port(3000)]) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('t1', mux, vi.fn()) + await vi.advanceTimersByTimeAsync(0) + expect(request).toHaveBeenCalledTimes(1) + expect(harness.listenerCount()).toBe(1) + + scanner.stopScanning('t1') + expect(harness.listenerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(10 * 60_000) + harness.setVisible(false) + harness.setVisible(true) + await vi.advanceTimersByTimeAsync(10 * 60_000) + expect(request).toHaveBeenCalledTimes(1) + expect(scanner.getDetectedPorts('t1')).toEqual([]) + }) + + it('keeps targets independent: stopping one host leaves the other scanning', async () => { + const harness = createVisibilityHarness(true) + let nextA = 3000 + let nextB = 4000 + const a = createMux(() => [port(nextA++)]) + const b = createMux(() => [port(nextB++)]) + const scanner = new PortScanner(harness.visibility) + scanner.startScanning('host-a', a.mux, vi.fn()) + scanner.startScanning('host-b', b.mux, vi.fn()) + await vi.advanceTimersByTimeAsync(0) + expect(a.request).toHaveBeenCalledTimes(1) + expect(b.request).toHaveBeenCalledTimes(1) + + scanner.stopScanning('host-a') + await vi.advanceTimersByTimeAsync(BASE) + expect(a.request).toHaveBeenCalledTimes(1) + expect(b.request).toHaveBeenCalledTimes(2) + + scanner.dispose() + await vi.advanceTimersByTimeAsync(10 * BASE) + expect(b.request).toHaveBeenCalledTimes(2) + expect(harness.listenerCount()).toBe(0) + }) +}) diff --git a/src/main/ssh/ssh-port-scanner.ts b/src/main/ssh/ssh-port-scanner.ts index 3746308db36..6404d035f22 100644 --- a/src/main/ssh/ssh-port-scanner.ts +++ b/src/main/ssh/ssh-port-scanner.ts @@ -1,10 +1,30 @@ import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' import type { DetectedPort } from '../../shared/ssh-types' -const POLL_INTERVAL_MS = 3_000 +// Why: every tick walks /proc/*/fd on the remote relay, so cadence is remote +// CPU, not just a local timer. 12s cuts steady-state request volume 4x vs the +// old 3s while keeping new-port detection well under the 30s workspace-scanner +// cadence users already accept. +export const SSH_PORT_SCAN_BASE_INTERVAL_MS = 12_000 +// Why: idle backoff cap — an unchanged port set doubles the interval up to +// this bound, so a quiet remote costs two scans per minute instead of twenty +// without exceeding the existing workspace-port scanner's visible cadence. +export const SSH_PORT_SCAN_MAX_INTERVAL_MS = 30_000 + +export type PortScannerWindowVisibility = { + isWindowVisible: () => boolean + // Fires when a hidden/minimized window is shown again; returns unsubscribe. + onWindowBecameVisible: (listener: () => void) => () => void +} type ScanHandle = { - timer: ReturnType + timer: ReturnType | null + intervalMs: number + // Why: while the window is hidden the scan chain is parked outright — no + // timer wakeups, no remote requests — and the visibility listener resumes + // it with an immediate scan so ports opened while hidden surface at once. + parkedWhileHidden: boolean + unsubscribeVisibility: () => void // Why: keyed by "host:port" (not just port) so that host-distinct listeners // on the same port (e.g. 127.0.0.1:3000 + 0.0.0.0:3000) are tracked separately. previousPorts: Map @@ -17,6 +37,8 @@ type ScanHandle = { export class PortScanner { private handles = new Map() + constructor(private visibility: PortScannerWindowVisibility) {} + startScanning( targetId: string, mux: SshChannelMultiplexer, @@ -25,14 +47,18 @@ export class PortScanner { this.stopScanning(targetId) const handle: ScanHandle = { - timer: null!, + timer: null, + intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS, + parkedWhileHidden: false, + unsubscribeVisibility: () => {}, previousPorts: new Map(), initialPorts: null } + const isCurrent = (): boolean => this.handles.get(targetId) === handle - // Why: guard against overlapping scans. On slow remotes, /proc/*/fd walks - // can take longer than POLL_INTERVAL_MS. Without this guard, setInterval - // would stack up concurrent requests on the shared SSH multiplexer. + // Why: guard against overlapping scans. The timer chain only reschedules + // after a poll completes, but the visibility-resume path can race a poll + // still in flight on a slow remote. let polling = false const poll = async (): Promise => { if (polling) { @@ -45,7 +71,7 @@ export class PortScanner { platform: string } - if (!this.handles.has(targetId)) { + if (!isCurrent()) { return } @@ -60,7 +86,10 @@ export class PortScanner { if (!portsEqual(handle.previousPorts, currentPorts)) { handle.previousPorts = currentPorts + handle.intervalMs = SSH_PORT_SCAN_BASE_INTERVAL_MS onChanged(targetId, result.ports, result.platform) + } else { + handle.intervalMs = Math.min(handle.intervalMs * 2, SSH_PORT_SCAN_MAX_INTERVAL_MS) } } catch { // Relay disconnected or request timed out — retry on next interval @@ -69,10 +98,32 @@ export class PortScanner { } } - handle.timer = setInterval(() => void poll(), POLL_INTERVAL_MS) - this.handles.set(targetId, handle) + const tick = async (): Promise => { + handle.timer = null + if (!isCurrent()) { + return + } + if (!this.visibility.isWindowVisible()) { + handle.parkedWhileHidden = true + return + } + await poll() + if (!isCurrent()) { + return + } + handle.timer = setTimeout(() => void tick(), handle.intervalMs) + } - void poll() + handle.unsubscribeVisibility = this.visibility.onWindowBecameVisible(() => { + if (!isCurrent() || !handle.parkedWhileHidden) { + return + } + handle.parkedWhileHidden = false + void tick() + }) + + this.handles.set(targetId, handle) + void tick() } getDetectedPorts(targetId: string): DetectedPort[] { @@ -88,7 +139,10 @@ export class PortScanner { if (!handle) { return } - clearInterval(handle.timer) + if (handle.timer) { + clearTimeout(handle.timer) + } + handle.unsubscribeVisibility() this.handles.delete(targetId) } diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 0dfa2edc8d8..614c121d56a 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -118,6 +118,10 @@ function createMockDeps() { } as unknown as SshPortForwardManager const mockWindow = { isDestroyed: () => false, + // Why: the port scanner visibility-gates its ticks; a visible mock window + // keeps establish-path tests exercising the scan-on-ready behavior. + isVisible: () => true, + isMinimized: () => false, webContents: { send: vi.fn() } } const getMainWindow = vi.fn().mockReturnValue(mockWindow) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 2462bbd94c3..4b29d7f7ea3 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -52,6 +52,7 @@ import { import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events' import { PortScanner } from './ssh-port-scanner' +import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility' import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' @@ -982,7 +983,13 @@ export class SshRelaySession { if (!this.mux || this.isDisposed()) { return } - const scanner = new PortScanner() + // Why: each scan walks /proc/*/fd on the remote host, so the scanner skips + // ticks entirely while no window can show the results (hidden to tray or + // minimized overnight) and rescans immediately when the window returns. + const scanner = new PortScanner({ + isWindowVisible: () => isMainWindowVisible(this.getMainWindow()), + onWindowBecameVisible: onMainWindowBecameVisible + }) this.portScanner = scanner // Why: capture the scanner instance so that a late ports.detect callback // from a previous relay session (before reconnect replaced it) is silently diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts new file mode 100644 index 00000000000..22920a66636 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -0,0 +1,250 @@ +import { EventEmitter } from 'node:events' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/host/app' + } +})) +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/host/user-data' +})) + +import { + HostCliUnavailableError, + buildHostCliEnv, + resolveHostCliEntryPath, + resolveHostCliKillTimeoutMs, + runHostOrcaCliPassthrough +} from './ssh-remote-cli-host-passthrough' + +type FakeChild = EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: { end: ReturnType; on: ReturnType } + kill: ReturnType +} + +function createFakeChild(): FakeChild { + const child = new EventEmitter() as FakeChild + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.stdin = { end: vi.fn(), on: vi.fn() } + child.kill = vi.fn() + return child +} + +const BASE_OPTIONS = { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => true +} + +describe('resolveHostCliEntryPath', () => { + it('uses the in-repo entry for dev builds and the unpacked asar entry when packaged', () => { + expect( + resolveHostCliEntryPath({ isPackaged: false, resourcesPath: '/r', appPath: '/host/app' }) + ).toBe(join('/host/app', 'out', 'cli', 'index.js')) + expect( + resolveHostCliEntryPath({ isPackaged: true, resourcesPath: '/r', appPath: '/host/app' }) + ).toBe(join('/r', 'app.asar.unpacked', 'out', 'cli', 'index.js')) + }) +}) + +describe('buildHostCliEnv', () => { + it('forwards only Orca terminal-context vars from the remote env', () => { + const env = buildHostCliEnv({ + hostEnv: { PATH: '/host/bin', NODE_OPTIONS: '--inspect' }, + remoteEnv: { + ORCA_TERMINAL_HANDLE: 'term_remote', + ORCA_WORKTREE_ID: 'repo::/home/alice/wt', + ORCA_PANE_KEY: 'pane-9', + ORCA_WORKSPACE_ID: 'ws-1', + // Why: these are remote-machine paths and must not leak into the host + // subprocess (PATH would break host binary lookup; user-data would + // retarget the CLI at a different local instance). + PATH: '/remote/bin', + ORCA_USER_DATA_PATH: '/remote/user-data' + }, + userDataPath: '/host/user-data', + remoteCwd: '/home/alice/wt/sub' + }) + + expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote') + expect(env.ORCA_WORKTREE_ID).toBe('repo::/home/alice/wt') + expect(env.ORCA_PANE_KEY).toBe('pane-9') + expect(env.ORCA_WORKSPACE_ID).toBe('ws-1') + expect(env.PATH).toBe('/host/bin') + expect(env.ORCA_USER_DATA_PATH).toBe('/host/user-data') + expect(env.ORCA_CLI_CWD).toBe('/home/alice/wt/sub') + expect(env.ELECTRON_RUN_AS_NODE).toBe('1') + expect(env.NODE_OPTIONS).toBeUndefined() + expect(env.ORCA_NODE_OPTIONS).toBe('--inspect') + }) +}) + +describe('resolveHostCliKillTimeoutMs', () => { + it('extends the kill timer past an explicit --timeout-ms budget', () => { + expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', '1800000'])).toBe( + 1_920_000 + ) + expect(resolveHostCliKillTimeoutMs(['orchestration', 'check', '--timeout-ms=5000'])).toBe( + 600_000 + ) + expect(resolveHostCliKillTimeoutMs(['worktree', 'list'])).toBe(600_000) + }) +}) + +describe('runHostOrcaCliPassthrough', () => { + it('spawns the bundled CLI entry with the remote argv and returns captured output', async () => { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { + argv: ['orchestration', 'task-create', '--spec', 'do the thing', '--json'], + cwd: '/home/alice/wt', + env: { ORCA_TERMINAL_HANDLE: 'term_remote' } + }, + { ...BASE_OPTIONS, spawn: spawn as never } + ) + + await Promise.resolve() + child.stdout.emit('data', Buffer.from('{"ok":true}\n')) + child.stderr.emit('data', Buffer.from('warn\n')) + child.emit('close', 0) + + const result = await resultPromise + expect(result).toEqual({ stdout: '{"ok":true}\n', stderr: 'warn\n', exitCode: 0 }) + + expect(spawn).toHaveBeenCalledTimes(1) + const [execPath, args, options] = spawn.mock.calls[0] as unknown as [ + string, + string[], + { env: NodeJS.ProcessEnv } + ] + expect(execPath).toBe('/host/electron') + expect(args).toEqual([ + '/host/app/out/cli/index.js', + 'orchestration', + 'task-create', + '--spec', + 'do the thing', + '--json' + ]) + expect(options.env.ELECTRON_RUN_AS_NODE).toBe('1') + expect(options.env.ORCA_CLI_CWD).toBe('/home/alice/wt') + expect(options.env.ORCA_TERMINAL_HANDLE).toBe('term_remote') + // Why: stdin must be closed even without a payload so CLI handlers that + // stream stdin see EOF instead of hanging forever. + expect(child.stdin.end).toHaveBeenCalledWith() + }) + + it('pipes a stdin payload to the CLI subprocess', async () => { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { + argv: ['linear', 'comment', 'add', 'ENG-1', '--body-file', '-'], + cwd: '/home/alice/wt', + env: {}, + stdin: 'comment body' + }, + { ...BASE_OPTIONS, spawn: spawn as never } + ) + + await Promise.resolve() + child.emit('close', 0) + await resultPromise + + expect(child.stdin.end).toHaveBeenCalledWith('comment body') + }) + + it('propagates non-zero exit codes', async () => { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { argv: ['worktree', 'show'], cwd: '/', env: {} }, + { ...BASE_OPTIONS, spawn: spawn as never } + ) + + await Promise.resolve() + child.stderr.emit('data', Buffer.from('boom\n')) + child.emit('close', 3) + + await expect(resultPromise).resolves.toEqual({ stdout: '', stderr: 'boom\n', exitCode: 3 }) + }) + + it('throws HostCliUnavailableError when the CLI entry is missing', async () => { + const spawn = vi.fn() + await expect( + runHostOrcaCliPassthrough( + { argv: ['status'], cwd: '/', env: {} }, + { ...BASE_OPTIONS, entryExists: () => false, spawn: spawn as never } + ) + ).rejects.toBeInstanceOf(HostCliUnavailableError) + expect(spawn).not.toHaveBeenCalled() + }) + + it('throws HostCliUnavailableError when the subprocess fails to launch', async () => { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { argv: ['status'], cwd: '/', env: {} }, + { ...BASE_OPTIONS, spawn: spawn as never } + ) + + await Promise.resolve() + child.emit('error', new Error('spawn ENOENT')) + + await expect(resultPromise).rejects.toBeInstanceOf(HostCliUnavailableError) + }) + + it('kills the subprocess and reports an error when the kill timeout elapses', async () => { + vi.useFakeTimers() + try { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { argv: ['terminal', 'wait', '--for', 'exit'], cwd: '/', env: {} }, + { ...BASE_OPTIONS, spawn: spawn as never, killTimeoutMs: 1000 } + ) + + await vi.advanceTimersByTimeAsync(1001) + const result = await resultPromise + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('timed out') + } finally { + vi.useRealTimers() + } + }) + + it('caps runaway output instead of buffering it unbounded', async () => { + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runHostOrcaCliPassthrough( + { argv: ['terminal', 'read'], cwd: '/', env: {} }, + { ...BASE_OPTIONS, spawn: spawn as never } + ) + + await Promise.resolve() + const chunk = Buffer.alloc(3 * 1024 * 1024, 97) + for (let i = 0; i < 4; i += 1) { + child.stdout.emit('data', chunk) + } + child.emit('close', 0) + + const result = await resultPromise + expect(result.stdout.length).toBeLessThanOrEqual(8 * 1024 * 1024 + 64) + expect(result.stdout).toContain('output truncated') + }) +}) diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts new file mode 100644 index 00000000000..3ca55edfd69 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -0,0 +1,270 @@ +// Why: the SSH relay shim (`~/.orca-relay/bin/orca`) forwards CLI invocations +// to the host app. Instead of re-implementing every command in a hand-rolled +// switch (the cause of "Unsupported SSH Orca CLI command", #7716), the host +// runs the real bundled `orca` CLI entry in Electron node mode — the same +// entry the local shell command uses — so remote invocations get the full +// command surface (orchestration, worktree, terminal, ...) by construction. +import { app } from 'electron' +import { spawn as nodeSpawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { getCanonicalUserDataPath } from '../persistence' + +export type RemoteOrcaCliRequest = { + argv: string[] + cwd: string + env: Record + stdin?: string +} + +export type RemoteOrcaCliResult = { + stdout: string + stderr: string + exitCode: number +} + +export type HostCliPassthroughOptions = { + execPath?: string + cliEntryPath?: string + userDataPath?: string + hostEnv?: NodeJS.ProcessEnv + spawn?: typeof nodeSpawn + entryExists?: (path: string) => boolean + killTimeoutMs?: number +} + +/** Thrown when the host CLI entry cannot be launched at all; callers fall back + * to the legacy in-process command switch so previously-working commands keep + * working even on broken installs. */ +export class HostCliUnavailableError extends Error {} + +// Why: only Orca terminal-context vars may cross from the remote shell into +// the host CLI process. Remote PATH / ORCA_USER_DATA_PATH are paths on the +// remote machine (meaningless or instance-hijacking on the host), and +// NODE_OPTIONS-style vars could alter host execution. +const REMOTE_CONTEXT_ENV_VARS = [ + 'ORCA_TERMINAL_HANDLE', + 'ORCA_WORKTREE_ID', + 'ORCA_PANE_KEY', + 'ORCA_WORKSPACE_ID' +] as const + +// Why: bound captured output so a runaway command cannot balloon the relay +// JSON-RPC response or main-process memory. +const MAX_CAPTURED_OUTPUT_BYTES = 8 * 1024 * 1024 +const DEFAULT_KILL_TIMEOUT_MS = 10 * 60_000 +const KILL_TIMEOUT_GRACE_MS = 2 * 60_000 + +export function resolveHostCliEntryPath(app: { + isPackaged: boolean + resourcesPath: string + appPath: string +}): string { + // Why: mirrors the packaged launcher scripts (resources/*/bin) and the dev + // launcher in cli-installer.ts — packaged builds ship the CLI entry outside + // app.asar so Electron node mode can execute it directly. + return app.isPackaged + ? join(app.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js') + : join(app.appPath, 'out', 'cli', 'index.js') +} + +/** Kill timer for the host CLI subprocess. Long-poll commands carry their wait + * budget in `--timeout-ms`; extend past it so the CLI's own timeout fires + * first and produces a proper error message. */ +export function resolveHostCliKillTimeoutMs(argv: string[]): number { + const explicit = parseTimeoutMsFlag(argv) + if (explicit !== null && Number.isFinite(explicit) && explicit > 0) { + return Math.max(DEFAULT_KILL_TIMEOUT_MS, explicit + KILL_TIMEOUT_GRACE_MS) + } + return DEFAULT_KILL_TIMEOUT_MS +} + +export function buildHostCliEnv(args: { + hostEnv: NodeJS.ProcessEnv + remoteEnv: Record + userDataPath: string + remoteCwd: string +}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...args.hostEnv } + for (const key of REMOTE_CONTEXT_ENV_VARS) { + const value = args.remoteEnv[key] + if (typeof value === 'string' && value.length > 0) { + env[key] = value + } + } + // Why: bind the subprocess to this app instance's runtime metadata (dev and + // parallel instances use non-default userData dirs). + env.ORCA_USER_DATA_PATH = args.userDataPath + // Why: the caller's working directory lives on the remote machine, so the + // subprocess cwd cannot be chdir'd there; ORCA_CLI_CWD carries it for + // cwd-based selectors like `--worktree active`. + env.ORCA_CLI_CWD = args.remoteCwd + // Why: same node-mode hygiene as the shipped CLI launchers — stash and clear + // NODE_OPTIONS so Electron's node bootstrap does not inherit them. + env.ORCA_NODE_OPTIONS = args.hostEnv.NODE_OPTIONS ?? '' + env.ORCA_NODE_REPL_EXTERNAL_MODULE = args.hostEnv.NODE_REPL_EXTERNAL_MODULE ?? '' + delete env.NODE_OPTIONS + delete env.NODE_REPL_EXTERNAL_MODULE + env.ELECTRON_RUN_AS_NODE = '1' + return env +} + +export async function runHostOrcaCliPassthrough( + request: RemoteOrcaCliRequest, + options: HostCliPassthroughOptions = {} +): Promise { + // Why: per-field lazy defaults keep the module testable — tests inject all + // three, so no Electron API is touched outside the production path. + const execPath = options.execPath ?? process.execPath + let cliEntryPath: string + let userDataPath: string + try { + cliEntryPath = + options.cliEntryPath ?? + resolveHostCliEntryPath({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath() + }) + // Why: must match the userData dir the runtime RPC server writes metadata + // to (see index.ts OrcaRuntimeRpcServer wiring), or the CLI subprocess + // reports "Orca is not running" against a healthy app. + userDataPath = options.userDataPath ?? getCanonicalUserDataPath() + } catch (err) { + // Why: no Electron app context (or broken install paths) — degrade to the + // caller's legacy in-process fallback instead of failing the command. + throw new HostCliUnavailableError( + `Host CLI environment unavailable: ${err instanceof Error ? err.message : String(err)}` + ) + } + const hostEnv = options.hostEnv ?? process.env + const spawn = options.spawn ?? nodeSpawn + const entryExists = options.entryExists ?? existsSync + const killTimeoutMs = options.killTimeoutMs ?? resolveHostCliKillTimeoutMs(request.argv) + + if (!entryExists(cliEntryPath)) { + throw new HostCliUnavailableError(`Orca CLI entry not found at ${cliEntryPath}`) + } + + const env = buildHostCliEnv({ + hostEnv, + remoteEnv: request.env, + userDataPath, + remoteCwd: request.cwd + }) + + return await new Promise((resolve, reject) => { + let settled = false + const child = spawn(execPath, [cliEntryPath, ...request.argv], { + env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + + const stdout = new CappedOutputCollector(MAX_CAPTURED_OUTPUT_BYTES) + const stderr = new CappedOutputCollector(MAX_CAPTURED_OUTPUT_BYTES) + + const killTimer = setTimeout(() => { + if (settled) { + return + } + settled = true + try { + child.kill('SIGKILL') + } catch { + // best effort — process may already be gone + } + resolve({ + stdout: stdout.toString(), + stderr: `${stderr.toString()}Orca CLI bridge timed out after ${killTimeoutMs}ms on the host.\n`, + exitCode: 1 + }) + }, killTimeoutMs) + killTimer.unref?.() + + child.on('error', (err) => { + if (settled) { + return + } + settled = true + clearTimeout(killTimer) + // Why: failure to launch (ENOENT, EACCES) means the host CLI is not + // runnable at all — signal the caller to use the legacy fallback rather + // than reporting a confusing per-command failure. + reject( + new HostCliUnavailableError(`Failed to launch the Orca CLI on the host: ${err.message}`) + ) + }) + + child.stdout?.on('data', (chunk: Buffer) => stdout.push(chunk)) + child.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk)) + + child.on('close', (code) => { + if (settled) { + return + } + settled = true + clearTimeout(killTimer) + resolve({ + stdout: stdout.toString(), + stderr: stderr.toString(), + exitCode: typeof code === 'number' ? code : 1 + }) + }) + + if (child.stdin) { + child.stdin.on('error', () => { + // Why: the CLI may exit without draining stdin; EPIPE here is routine. + }) + if (request.stdin !== undefined) { + child.stdin.end(request.stdin) + } else { + child.stdin.end() + } + } + }) +} + +class CappedOutputCollector { + private readonly chunks: Buffer[] = [] + private bytes = 0 + private truncated = false + + constructor(private readonly maxBytes: number) {} + + push(chunk: Buffer): void { + if (this.truncated) { + return + } + const remaining = this.maxBytes - this.bytes + if (chunk.length >= remaining) { + this.chunks.push(chunk.subarray(0, remaining)) + this.bytes = this.maxBytes + this.truncated = true + return + } + this.chunks.push(chunk) + this.bytes += chunk.length + } + + toString(): string { + const text = Buffer.concat(this.chunks).toString('utf8') + return this.truncated ? `${text}\n[orca ssh cli] output truncated\n` : text + } +} + +function parseTimeoutMsFlag(argv: string[]): number | null { + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i] + if (token === '--timeout-ms') { + const next = argv[i + 1] + const parsed = next === undefined ? Number.NaN : Number(next) + return Number.isFinite(parsed) ? parsed : null + } + if (token.startsWith('--timeout-ms=')) { + const parsed = Number(token.slice('--timeout-ms='.length)) + return Number.isFinite(parsed) ? parsed : null + } + } + return null +} diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index c64443876dd..f54c2157c55 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -1,7 +1,45 @@ +import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/host/app' + } +})) +vi.mock('../persistence', () => ({ + getCanonicalUserDataPath: () => '/host/user-data' +})) + import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough' import { runRemoteOrcaCli } from './ssh-remote-orca-cli' +// Why: pointing the passthrough at a missing CLI entry forces the legacy +// in-process fallback, which is what these dispatch tests exercise. +const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => false +} + +type FakeChild = EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: { end: ReturnType; on: ReturnType } + kill: ReturnType +} + +function createFakeChild(): FakeChild { + const child = new EventEmitter() as FakeChild + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.stdin = { end: vi.fn(), on: vi.fn() } + child.kill = vi.fn() + return child +} + describe('runRemoteOrcaCli', () => { function createRuntime() { const messages: { @@ -93,11 +131,15 @@ describe('runRemoteOrcaCli', () => { it('uses the remote ORCA_TERMINAL_HANDLE as orchestration sender identity', async () => { const { runtime, db } = createRuntime() - const result = await runRemoteOrcaCli(runtime, { - argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], - cwd: '/home/alice/repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }, + LEGACY_FALLBACK_OPTIONS + ) expect(result.exitCode).toBe(0) const payload = JSON.parse(result.stdout) as { ok: boolean } @@ -108,18 +150,22 @@ describe('runRemoteOrcaCli', () => { it('accepts equals-style orchestration flags in the remote shim', async () => { const { runtime, db } = createRuntime() - const result = await runRemoteOrcaCli(runtime, { - argv: [ - 'orchestration', - 'send', - '--to=term_windows', - '--subject=ping', - '--body=--literal-body', - '--json' - ], - cwd: '/home/alice/repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--to=term_windows', + '--subject=ping', + '--body=--literal-body', + '--json' + ], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }, + LEGACY_FALLBACK_OPTIONS + ) expect(result.exitCode).toBe(0) const payload = JSON.parse(result.stdout) as { ok: boolean } @@ -138,11 +184,15 @@ describe('runRemoteOrcaCli', () => { body: 'hello' }) - const result = await runRemoteOrcaCli(runtime, { - argv: ['orchestration', 'check', '--all', '--json'], - cwd: '/home/alice/repo', - env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } - }) + const result = await runRemoteOrcaCli( + runtime, + { + argv: ['orchestration', 'check', '--all', '--json'], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }, + LEGACY_FALLBACK_OPTIONS + ) expect(result.exitCode).toBe(0) const payload = JSON.parse(result.stdout) as { @@ -153,4 +203,92 @@ describe('runRemoteOrcaCli', () => { expect(payload.result.count).toBe(1) expect(payload.result.messages[0]?.subject).toBe('pong') }) + + it('routes previously-unsupported commands through the full host CLI', async () => { + const { runtime } = createRuntime() + const child = createFakeChild() + const spawn = vi.fn(() => child) + + const resultPromise = runRemoteOrcaCli( + runtime, + { + argv: ['worktree', 'create', '--repo', 'orca', '--branch', 'fix/x', '--json'], + cwd: '/home/alice/repo', + env: { ORCA_TERMINAL_HANDLE: 'term_ssh' } + }, + { + execPath: '/host/electron', + cliEntryPath: '/host/app/out/cli/index.js', + userDataPath: '/host/user-data', + entryExists: () => true, + spawn: spawn as never + } + ) + + await Promise.resolve() + child.stdout.emit('data', Buffer.from('{"ok":true}\n')) + child.emit('close', 0) + + const result = await resultPromise + expect(result).toEqual({ stdout: '{"ok":true}\n', stderr: '', exitCode: 0 }) + const [, args] = spawn.mock.calls[0] as unknown as [string, string[]] + expect(args).toEqual([ + '/host/app/out/cli/index.js', + 'worktree', + 'create', + '--repo', + 'orca', + '--branch', + 'fix/x', + '--json' + ]) + }) + + it('rejects host-interactive commands with a targeted error instead of bridging them', async () => { + const { runtime } = createRuntime() + const spawn = vi.fn() + + const result = await runRemoteOrcaCli( + runtime, + { argv: ['serve'], cwd: '/home/alice', env: {} }, + { ...LEGACY_FALLBACK_OPTIONS, spawn: spawn as never } + ) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('orca serve') + expect(result.stderr).toContain('SSH relay bridge') + expect(spawn).not.toHaveBeenCalled() + }) + + it('reports host-interactive command errors as JSON envelopes with --json', async () => { + const { runtime } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { argv: ['serve', '--json'], cwd: '/home/alice', env: {} }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + const payload = JSON.parse(result.stdout) as { + ok: boolean + error: { code: string } + } + expect(payload.ok).toBe(false) + expect(payload.error.code).toBe('unsupported_over_ssh') + }) + + it('explains the root cause when falling back and the command is not in the legacy switch', async () => { + const { runtime } = createRuntime() + + const result = await runRemoteOrcaCli( + runtime, + { argv: ['worktree', 'list'], cwd: '/home/alice', env: {} }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('Unsupported SSH Orca CLI command: worktree list') + expect(result.stderr).toContain('full Orca CLI bridge unavailable') + }) }) diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index d61fe4f25cf..d555b5c2707 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -3,30 +3,38 @@ import { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { formatRemoteCli } from './ssh-remote-cli-format' +import { + HostCliUnavailableError, + runHostOrcaCliPassthrough, + type HostCliPassthroughOptions, + type RemoteOrcaCliRequest, + type RemoteOrcaCliResult +} from './ssh-remote-cli-host-passthrough' import { RemoteCliArgumentError, getRemoteLinearHelp, tryDispatchRemoteLinearCli } from './ssh-remote-linear-cli' -export type RemoteOrcaCliRequest = { - argv: string[] - cwd: string - env: Record - stdin?: string -} - -export type RemoteOrcaCliResult = { - stdout: string - stderr: string - exitCode: number -} +export type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough' type ParsedRemoteCli = { commandPath: string[] flags: Map } +// Why: these commands run a foreground/interactive process attached to the +// caller's TTY (or a local tmux pane), which a buffered one-shot relay bridge +// cannot host. Everything else routes through the full host CLI. +const HOST_INTERACTIVE_COMMANDS: Record = { + serve: + 'orca serve starts a foreground headless Orca server and cannot run through the SSH relay bridge. Run it directly on the machine that should host Orca.', + 'claude-teams': + 'orca claude-teams starts an interactive Claude Code session and cannot run through the SSH relay bridge. Run it in a terminal on the Orca host machine.', + 'agent-teams-tmux': + 'orca agent-teams-tmux is a tmux pane shim for the Orca host machine and cannot run through the SSH relay bridge.' +} + const REMOTE_BOOLEAN_FLAGS = new Set([ 'all', 'attachments', @@ -48,18 +56,60 @@ const REPEATABLE_REMOTE_STRING_FLAGS = new Set(['label']) export async function runRemoteOrcaCli( runtime: OrcaRuntimeService, - request: RemoteOrcaCliRequest + request: RemoteOrcaCliRequest, + passthroughOptions?: HostCliPassthroughOptions ): Promise { - const dispatcher = new RpcDispatcher({ runtime }) const parsed = parseRemoteCliArgs(request.argv) const json = parsed.flags.has('json') + + const interactiveMessage = HOST_INTERACTIVE_COMMANDS[parsed.commandPath[0] ?? ''] + if (interactiveMessage) { + if (json) { + return { + stdout: `${JSON.stringify(buildLocalError(interactiveMessage, 'unsupported_over_ssh'), null, 2)}\n`, + stderr: '', + exitCode: 1 + } + } + return { stdout: '', stderr: `${interactiveMessage}\n`, exitCode: 1 } + } + + let passthroughFailure: HostCliUnavailableError | null = null + try { + return await runHostOrcaCliPassthrough(request, passthroughOptions) + } catch (err) { + if (!(err instanceof HostCliUnavailableError)) { + throw err + } + // Why: fall back to the legacy in-process command switch below so the + // historical read-only/orchestration surface keeps working even when the + // bundled CLI entry cannot be launched on this install. + passthroughFailure = err + } + return await runLegacyRemoteOrcaCli(runtime, request, parsed, json, passthroughFailure) +} + +async function runLegacyRemoteOrcaCli( + runtime: OrcaRuntimeService, + request: RemoteOrcaCliRequest, + parsed: ParsedRemoteCli, + json: boolean, + passthroughFailure: HostCliUnavailableError +): Promise { + const dispatcher = new RpcDispatcher({ runtime }) const help = getRemoteLinearHelp(parsed) if (help) { return { stdout: `${help}\n`, stderr: '', exitCode: 0 } } try { - const response = await dispatchRemoteCli(dispatcher, parsed, request.env, request.stdin) + const response = await dispatchRemoteCli( + dispatcher, + parsed, + request.env, + request.stdin, + passthroughFailure.message + ) const formatted = json ? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' } : formatRemoteCli(response) @@ -93,7 +143,8 @@ async function dispatchRemoteCli( dispatcher: RpcDispatcher, parsed: ParsedRemoteCli, env: Record, - stdin?: string + stdin: string | undefined, + passthroughFailureReason: string ): Promise { const command = parsed.commandPath.join(' ') const linearResponse = await tryDispatchRemoteLinearCli(dispatcher, parsed, env, stdin) @@ -156,7 +207,12 @@ async function dispatchRemoteCli( terminal: optionalString(parsed.flags, 'terminal') }) default: - throw new Error(`Unsupported SSH Orca CLI command: ${command}`) + // Why: only reachable when the full host CLI could not be launched; + // include that root cause so users can fix the install instead of + // assuming the command family is unsupported over SSH. + throw new Error( + `Unsupported SSH Orca CLI command: ${command} (full Orca CLI bridge unavailable: ${passthroughFailureReason})` + ) } } diff --git a/src/main/ssh/ssh-target-id-migration.test.ts b/src/main/ssh/ssh-target-id-migration.test.ts new file mode 100644 index 00000000000..fa6507d6260 --- /dev/null +++ b/src/main/ssh/ssh-target-id-migration.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest' +import type { PersistedUIState, TerminalTab, WorkspaceSessionState } from '../../shared/types' +import { + migrateUiHostScopeSshTargetId, + migrateWorkspaceSessionSshTargetId +} from './ssh-target-id-migration' + +const OLD_ID = 'ssh-1783337351840-ohabf0' +const NEW_ID = 'ssh-1783400000000-fresh1' + +const makeTab = (overrides: Partial = {}): TerminalTab => ({ + id: 'tab1', + ptyId: null, + worktreeId: 'r1::/wt', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides +}) + +const makeSession = (overrides: Partial = {}): WorkspaceSessionState => ({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...overrides +}) + +describe('migrateWorkspaceSessionSshTargetId', () => { + it('re-encodes SSH pty ids embedded in tabs, layouts, and remote session ids', () => { + const session = makeSession({ + tabsByWorktree: { + 'r1::/wt': [ + makeTab({ id: 'tab1', ptyId: `ssh:${OLD_ID}@@pty-3` }), + makeTab({ id: 'tab2', ptyId: 'local-pty-7' }), + makeTab({ id: 'tab3', ptyId: `ssh:other-target@@pty-1` }) + ] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: 'leaf-1' }, + activeLeafId: 'leaf-1', + expandedLeafId: null, + ptyIdsByLeafId: { 'leaf-1': `ssh:${OLD_ID}@@pty-3` } + } + }, + remoteSessionIdsByTabId: { + tab1: `ssh:${OLD_ID}@@pty-3`, + tab3: `ssh:other-target@@pty-1` + } + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true) + + const tabs = session.tabsByWorktree['r1::/wt'] + expect(tabs[0].ptyId).toBe(`ssh:${NEW_ID}@@pty-3`) + // Local and other-target pty ids must be untouched. + expect(tabs[1].ptyId).toBe('local-pty-7') + expect(tabs[2].ptyId).toBe(`ssh:other-target@@pty-1`) + expect(session.terminalLayoutsByTabId.tab1.ptyIdsByLeafId).toEqual({ + 'leaf-1': `ssh:${NEW_ID}@@pty-3` + }) + expect(session.remoteSessionIdsByTabId).toEqual({ + tab1: `ssh:${NEW_ID}@@pty-3`, + tab3: `ssh:other-target@@pty-1` + }) + }) + + it('replaces the old id in activeConnectionIdsAtShutdown and dedupes', () => { + const session = makeSession({ + activeConnectionIdsAtShutdown: [OLD_ID, NEW_ID, 'ssh-unrelated'] + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true) + expect(session.activeConnectionIdsAtShutdown).toEqual([NEW_ID, 'ssh-unrelated']) + }) + + it('re-points sleeping agent records pinned to the old connection', () => { + const session = makeSession({ + sleepingAgentSessionsByPaneKey: { + 'tab1:leaf-1': { + paneKey: 'tab1:leaf-1', + worktreeId: 'r1::/wt', + agent: 'claude', + providerSession: { key: 'session_id', id: 's-1' }, + prompt: 'p', + state: 'done', + capturedAt: 1, + updatedAt: 1, + connectionId: OLD_ID + } + } + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(true) + expect(session.sleepingAgentSessionsByPaneKey?.['tab1:leaf-1'].connectionId).toBe(NEW_ID) + }) + + it('returns false when nothing references the old id', () => { + const session = makeSession({ + tabsByWorktree: { 'r1::/wt': [makeTab({ ptyId: 'local-pty' })] }, + activeConnectionIdsAtShutdown: ['ssh-unrelated'] + }) + + expect(migrateWorkspaceSessionSshTargetId(session, OLD_ID, NEW_ID)).toBe(false) + expect(session.activeConnectionIdsAtShutdown).toEqual(['ssh-unrelated']) + }) +}) + +describe('migrateUiHostScopeSshTargetId', () => { + const makeUi = (overrides: Partial): PersistedUIState => + ({ ...overrides }) as PersistedUIState + + it('re-points scope, visible hosts, and host order, deduping collisions', () => { + const ui = makeUi({ + workspaceHostScope: `ssh:${OLD_ID}`, + visibleWorkspaceHostIds: ['local', `ssh:${OLD_ID}`, `ssh:${NEW_ID}`], + workspaceHostOrder: [`ssh:${OLD_ID}`, 'local'] + }) + + expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(true) + expect(ui.workspaceHostScope).toBe(`ssh:${NEW_ID}`) + expect(ui.visibleWorkspaceHostIds).toEqual(['local', `ssh:${NEW_ID}`]) + expect(ui.workspaceHostOrder).toEqual([`ssh:${NEW_ID}`, 'local']) + }) + + it('returns false when the old host id appears nowhere', () => { + const ui = makeUi({ + workspaceHostScope: 'all', + visibleWorkspaceHostIds: ['local'], + workspaceHostOrder: ['local'] + }) + + expect(migrateUiHostScopeSshTargetId(ui, OLD_ID, NEW_ID)).toBe(false) + }) +}) diff --git a/src/main/ssh/ssh-target-id-migration.ts b/src/main/ssh/ssh-target-id-migration.ts new file mode 100644 index 00000000000..a917c4393e9 --- /dev/null +++ b/src/main/ssh/ssh-target-id-migration.ts @@ -0,0 +1,116 @@ +import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types' +import { parseAppSshPtyId, toAppSshPtyId } from '../../shared/ssh-pty-id' +import { toSshExecutionHostId } from '../../shared/execution-host' + +/** + * Carrier sweep for SSH target re-adoption (see ssh-target-readoption.ts). + * + * reassignSshTargetId re-points repos/worktree metas, but the removed target's + * id is also embedded in other persisted state: app-scoped SSH pty ids + * ("ssh:@@pty-N") inside the workspace session, the startup + * reconnect list, sleeping-agent resume records, and the sidebar host-scope + * arrays. Any survivor resurfaces later as `SSH target "" not found` at + * connect/reattach time (STA-1468), so every carrier must migrate together. + * + * All helpers mutate in place (matching how the Store edits this.state) and + * return whether anything changed so callers can gate scheduleSave. + */ + +function rewriteSshPtyId(ptyId: string, oldTargetId: string, newTargetId: string): string | null { + const parsed = parseAppSshPtyId(ptyId) + if (!parsed || parsed.connectionId !== oldTargetId) { + return null + } + return toAppSshPtyId(newTargetId, parsed.relayPtyId) +} + +function rewriteSshPtyIdRecordValues( + record: Record | undefined, + oldTargetId: string, + newTargetId: string +): boolean { + if (!record) { + return false + } + let changed = false + for (const [key, ptyId] of Object.entries(record)) { + const next = rewriteSshPtyId(ptyId, oldTargetId, newTargetId) + if (next) { + record[key] = next + changed = true + } + } + return changed +} + +/** Re-point every old-target-id carrier inside one workspace session partition. */ +export function migrateWorkspaceSessionSshTargetId( + session: WorkspaceSessionState, + oldTargetId: string, + newTargetId: string +): boolean { + let changed = false + for (const tabs of Object.values(session.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if (!tab.ptyId) { + continue + } + const next = rewriteSshPtyId(tab.ptyId, oldTargetId, newTargetId) + if (next) { + tab.ptyId = next + changed = true + } + } + } + for (const layout of Object.values(session.terminalLayoutsByTabId ?? {})) { + if (rewriteSshPtyIdRecordValues(layout.ptyIdsByLeafId, oldTargetId, newTargetId)) { + changed = true + } + } + if (rewriteSshPtyIdRecordValues(session.remoteSessionIdsByTabId, oldTargetId, newTargetId)) { + changed = true + } + if (session.activeConnectionIdsAtShutdown?.includes(oldTargetId)) { + session.activeConnectionIdsAtShutdown = [ + ...new Set( + session.activeConnectionIdsAtShutdown.map((id) => (id === oldTargetId ? newTargetId : id)) + ) + ] + changed = true + } + for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) { + if (record.connectionId === oldTargetId) { + record.connectionId = newTargetId + changed = true + } + } + return changed +} + +/** Re-point the sidebar host-scope arrays pinned to the old SSH host id. */ +export function migrateUiHostScopeSshTargetId( + ui: PersistedUIState, + oldTargetId: string, + newTargetId: string +): boolean { + const oldHostId = toSshExecutionHostId(oldTargetId) + const newHostId = toSshExecutionHostId(newTargetId) + let changed = false + if (ui.workspaceHostScope === oldHostId) { + ui.workspaceHostScope = newHostId + changed = true + } + if (ui.visibleWorkspaceHostIds?.includes(oldHostId)) { + ui.visibleWorkspaceHostIds = [ + ...new Set(ui.visibleWorkspaceHostIds.map((id) => (id === oldHostId ? newHostId : id))) + ] + changed = true + } + if (ui.workspaceHostOrder?.includes(oldHostId)) { + ui.workspaceHostOrder = [ + ...new Set(ui.workspaceHostOrder.map((id) => (id === oldHostId ? newHostId : id))) + ] + changed = true + } + return changed +} diff --git a/src/main/ssh/ssh-target-readoption.test.ts b/src/main/ssh/ssh-target-readoption.test.ts new file mode 100644 index 00000000000..f263fe01d88 --- /dev/null +++ b/src/main/ssh/ssh-target-readoption.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest' +import { + buildRemovedSshTargetTombstone, + readoptOrphanedWorkspacesForTarget +} from './ssh-target-readoption' +import type { Store } from '../persistence' +import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' + +function makeTarget(overrides: Partial = {}): SshTarget { + return { + id: 'ssh-new', + label: 'Dev box', + host: 'dev.example.com', + port: 22, + username: 'tim', + ...overrides + } +} + +/** Minimal in-memory store exposing only what re-adoption touches. */ +function makeFakeStore(tombstones: RemovedSshTargetTombstone[]) { + const reassigned: { oldId: string; newId: string }[] = [] + let current = [...tombstones] + const store = { + getRemovedSshTargetTombstones: () => [...current], + removeRemovedSshTargetTombstone: (oldTargetId: string) => { + current = current.filter((t) => t.oldTargetId !== oldTargetId) + }, + reassignSshTargetId: (oldTargetId: string, newTargetId: string) => { + reassigned.push({ oldId: oldTargetId, newId: newTargetId }) + return 1 + } + } as unknown as Store + return { store, reassigned, remaining: () => current } +} + +const tombstone = ( + overrides: Partial = {} +): RemovedSshTargetTombstone => ({ + oldTargetId: 'ssh-old', + host: 'dev.example.com', + port: 22, + username: 'tim', + label: 'Dev box', + removedAt: 1, + ...overrides +}) + +describe('readoptOrphanedWorkspacesForTarget', () => { + it('re-adopts on matching configHost alias', () => { + const fake = makeFakeStore([tombstone({ configHost: 'devbox', host: 'changed.example.com' })]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ configHost: 'devbox', host: 'now.example.com' }) + ) + expect(count).toBe(1) + expect(fake.reassigned).toEqual([{ oldId: 'ssh-old', newId: 'ssh-new' }]) + expect(fake.remaining()).toHaveLength(0) // tombstone consumed + }) + + it('re-adopts on matching host+user+port when no alias', () => { + const fake = makeFakeStore([tombstone()]) + const count = readoptOrphanedWorkspacesForTarget(fake.store, makeTarget()) + expect(count).toBe(1) + expect(fake.reassigned).toEqual([{ oldId: 'ssh-old', newId: 'ssh-new' }]) + }) + + it('does not re-adopt when identity differs', () => { + const fake = makeFakeStore([tombstone({ host: 'other.example.com', username: 'root' })]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ host: 'dev.example.com', username: 'tim' }) + ) + expect(count).toBe(0) + expect(fake.reassigned).toEqual([]) + expect(fake.remaining()).toHaveLength(1) // tombstone left for a future match + }) + + it('matches host/user/port case-insensitively', () => { + const fake = makeFakeStore([tombstone({ host: 'Dev.Example.COM', username: 'Tim' })]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ host: 'dev.example.com', username: 'tim' }) + ) + expect(count).toBe(1) + }) + + it('does not match alias against a different host tuple', () => { + // Different alias AND different tuple => no match. + const fake = makeFakeStore([ + tombstone({ configHost: 'prod', host: 'prod.example.com', username: 'root' }) + ]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ configHost: 'devbox', host: 'dev.example.com', username: 'tim' }) + ) + expect(count).toBe(0) + }) + + it('is a no-op when there are no tombstones', () => { + const fake = makeFakeStore([]) + expect(readoptOrphanedWorkspacesForTarget(fake.store, makeTarget())).toBe(0) + }) + + it('does NOT re-adopt across two different aliases that share host+user+port', () => { + // prod-deploy removed; prod-admin re-added — same box, different alias. + // These are deliberately distinct targets and must not steal workspaces. + const fake = makeFakeStore([ + tombstone({ configHost: 'prod-deploy', host: 'prod.example.com', username: 'deploy' }) + ]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ configHost: 'prod-admin', host: 'prod.example.com', username: 'deploy' }) + ) + expect(count).toBe(0) + expect(fake.reassigned).toEqual([]) + expect(fake.remaining()).toHaveLength(1) // tombstone preserved + }) + + it('does NOT re-adopt a different account/port on the same host (implicit alias)', () => { + // Manual adds default configHost to host, so both carry configHost === host. + // That is NOT a real alias — matching on it alone would ignore port/username + // and reattach workspaces to the wrong SSH account on the same machine. + const fake = makeFakeStore([ + tombstone({ + configHost: 'dev.example.com', + host: 'dev.example.com', + port: 22, + username: 'alice' + }) + ]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ + configHost: 'dev.example.com', + host: 'dev.example.com', + port: 2222, + username: 'bob' + }) + ) + expect(count).toBe(0) + expect(fake.reassigned).toEqual([]) + }) + + it('re-adopts the same account/host/port even with implicit configHost === host', () => { + const fake = makeFakeStore([ + tombstone({ + configHost: 'dev.example.com', + host: 'dev.example.com', + port: 22, + username: 'alice' + }) + ]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ + configHost: 'dev.example.com', + host: 'dev.example.com', + port: 22, + username: 'alice' + }) + ) + expect(count).toBe(1) + }) + + it('still re-adopts via tuple when the re-added target has no alias', () => { + // Manual re-add (no configHost) of a host that was config-managed: fall back + // to the tuple since one side lacks an alias to distinguish it. + const fake = makeFakeStore([ + tombstone({ configHost: 'devbox', host: 'dev.example.com', username: 'tim' }) + ]) + const count = readoptOrphanedWorkspacesForTarget( + fake.store, + makeTarget({ configHost: undefined, host: 'dev.example.com', username: 'tim' }) + ) + expect(count).toBe(1) + }) +}) + +describe('buildRemovedSshTargetTombstone', () => { + it('captures identity fields and omits configHost when absent', () => { + const t = buildRemovedSshTargetTombstone(makeTarget(), 123) + expect(t).toEqual({ + oldTargetId: 'ssh-new', + host: 'dev.example.com', + port: 22, + username: 'tim', + label: 'Dev box', + removedAt: 123 + }) + expect('configHost' in t).toBe(false) + }) + + it('includes configHost when present', () => { + const t = buildRemovedSshTargetTombstone(makeTarget({ configHost: 'devbox' }), 1) + expect(t.configHost).toBe('devbox') + }) +}) diff --git a/src/main/ssh/ssh-target-readoption.ts b/src/main/ssh/ssh-target-readoption.ts new file mode 100644 index 00000000000..06b0cefd8ec --- /dev/null +++ b/src/main/ssh/ssh-target-readoption.ts @@ -0,0 +1,99 @@ +import type { Store } from '../persistence' +import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types' + +/** + * Re-adoption of workspaces orphaned when an SSH target was removed. + * + * Repos/worktrees store only the (random) target id, so removing a target + * strands them on a dead id. When the user re-adds the same host, a fresh id is + * minted and nothing links the old workspaces to it. We bridge that gap using + * removal-time tombstones ({ oldTargetId, configHost, host, username, port }): + * on add/import we match the new target's identity to a tombstone and re-point + * every repo/worktree from the old id to the new one. + * + * Matching is intentionally strict — configHost (alias) first, then the + * host+username+port tuple — so we only auto-reattach on a confident identity + * match and never mislink two genuinely different hosts. Anything that doesn't + * match cleanly stays a ghost, handled by the forget flow instead. + */ + +type IdentityFields = Pick + +function normalize(value: string | undefined): string { + return value?.trim().toLowerCase() ?? '' +} + +function tupleKey(fields: IdentityFields): string { + return `${normalize(fields.host)}|${fields.port}|${normalize(fields.username)}` +} + +// An alias only counts as a distinguishing identity when it differs from the +// host. addTarget defaults configHost to host, so a manual add with no real +// ssh-config alias has configHost === host — that's not an alias, it's just the +// hostname, and matching on it alone would ignore port/username. +function meaningfulAlias(fields: IdentityFields): string { + const alias = normalize(fields.configHost) + return alias && alias !== normalize(fields.host) ? alias : '' +} + +function tombstoneMatches(tombstone: RemovedSshTargetTombstone, target: IdentityFields): boolean { + const targetAlias = meaningfulAlias(target) + const tombstoneAlias = meaningfulAlias(tombstone) + // Primary: matching ssh-config alias. Stable across remove/re-import. + if (targetAlias && tombstoneAlias) { + // Both carry a real alias — the alias is the identity. Different aliases + // mean deliberately distinct targets (e.g. prod-deploy vs prod-admin on the + // same box with different identity files); do NOT fall through to the tuple, + // or a second alias for the same endpoint would steal the first's workspaces. + return targetAlias === tombstoneAlias + } + // Fallback: identical host+user+port. Used when either side has no real alias + // (manual adds default configHost to host), so a different account or port on + // the same host is correctly treated as a different target. + return tupleKey(tombstone) === tupleKey(target) +} + +/** + * Re-point orphaned repos/worktrees onto `newTarget` if a removed target with + * the same host identity is tombstoned. Consumes the matching tombstone(s). + * Returns the number of repos re-adopted (0 when there was nothing to adopt). + */ +export function readoptOrphanedWorkspacesForTarget(store: Store, newTarget: SshTarget): number { + const tombstones = store.getRemovedSshTargetTombstones() + if (tombstones.length === 0) { + return 0 + } + let readopted = 0 + for (const tombstone of tombstones) { + // Why: a re-added target can't share the id of one that still exists, but + // guard anyway so we never re-point a live target onto itself. + if (tombstone.oldTargetId === newTarget.id) { + store.removeRemovedSshTargetTombstone(tombstone.oldTargetId) + continue + } + if (!tombstoneMatches(tombstone, newTarget)) { + continue + } + readopted += store.reassignSshTargetId(tombstone.oldTargetId, newTarget.id) + // Consume the tombstone whether or not it re-pointed anything: the host has + // returned, so the record has served its purpose. + store.removeRemovedSshTargetTombstone(tombstone.oldTargetId) + } + return readopted +} + +/** Build a tombstone from a target about to be removed. */ +export function buildRemovedSshTargetTombstone( + target: SshTarget, + removedAt: number +): RemovedSshTargetTombstone { + return { + oldTargetId: target.id, + ...(target.configHost ? { configHost: target.configHost } : {}), + host: target.host, + port: target.port, + username: target.username, + label: target.label, + removedAt + } +} diff --git a/src/main/ssh/system-ssh-port-forward-provider.ts b/src/main/ssh/system-ssh-port-forward-provider.ts index 74227f67cfe..e2b53abab7b 100644 --- a/src/main/ssh/system-ssh-port-forward-provider.ts +++ b/src/main/ssh/system-ssh-port-forward-provider.ts @@ -33,9 +33,17 @@ export class SystemSshPortForwardProvider implements SshPortForwardProvider { ) await forward.waitForStartup() + // Why: this stderr stays attached for the forward's whole lifetime but is + // only used to build the exit-error detail, so keep a bounded tail — a chatty + // remote sshd could otherwise grow it unbounded on a long-lived forward + // (mirrors MAX_RELAY_STARTUP_BUFFER_BYTES in ssh-relay-deploy-helpers). + const MAX_STDERR_TAIL_BYTES = 64 * 1024 let stderr = '' const onStderr = (chunk: Buffer): void => { stderr += chunk.toString('utf-8') + if (stderr.length > MAX_STDERR_TAIL_BYTES) { + stderr = stderr.slice(-MAX_STDERR_TAIL_BYTES) + } } forward.process.stderr?.on('data', onStderr) diff --git a/src/main/system-fonts.test.ts b/src/main/system-fonts.test.ts index 3f63d8930f0..c6c5e0e42c4 100644 --- a/src/main/system-fonts.test.ts +++ b/src/main/system-fonts.test.ts @@ -9,16 +9,56 @@ vi.mock('child_process', () => ({ execFile: execFileMock })) -function expectedFallbackFont(): string { - if (process.platform === 'darwin') { +function expectedFallbackFont(platform = process.platform): string { + if (platform === 'darwin') { return 'SF Mono' } - if (process.platform === 'win32') { + if (platform === 'win32') { return 'Cascadia Mono' } return 'JetBrains Mono' } +async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await fn() + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } +} + +async function expectFontCommandTimeout( + platform: NodeJS.Platform, + timeoutMs: number +): Promise { + await withPlatform(platform, async () => { + vi.useFakeTimers() + execFileMock.mockReturnValue({ kill: killMock }) + + const { listSystemFontFamilies } = await import('./system-fonts') + const fontsPromise = listSystemFontFamilies() + let resolvedFonts: string[] | null = null + fontsPromise.then((fonts) => { + resolvedFonts = fonts + }) + + await vi.advanceTimersByTimeAsync(timeoutMs - 1) + + expect(resolvedFonts).toBeNull() + expect(killMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + + await expect(fontsPromise).resolves.toContain(expectedFallbackFont(platform)) + expect(killMock).toHaveBeenCalledOnce() + }) +} + describe('listSystemFontFamilies', () => { afterEach(() => { vi.useRealTimers() @@ -44,4 +84,15 @@ describe('listSystemFontFamilies', () => { expect(resolvedFonts).toContain(expectedFallbackFont()) expect(killMock).toHaveBeenCalledOnce() }) + + it('uses the longer timeout for macOS profiler scans', async () => { + await expectFontCommandTimeout('darwin', 45_000) + }) + + it.each([ + ['linux' as NodeJS.Platform, 15_000], + ['win32' as NodeJS.Platform, 15_000] + ])('keeps the %s font command timeout short', async (platform, timeoutMs) => { + await expectFontCommandTimeout(platform, timeoutMs) + }) }) diff --git a/src/main/system-fonts.ts b/src/main/system-fonts.ts index afe142562d9..15ff9c28bde 100644 --- a/src/main/system-fonts.ts +++ b/src/main/system-fonts.ts @@ -3,6 +3,9 @@ import { execFile } from 'node:child_process' let cachedFonts: string[] | null = null let fontsPromise: Promise | null = null const SYSTEM_FONT_LIST_TIMEOUT_MS = 15_000 +// Why: large macOS font catalogs can make system_profiler exceed 15s even +// when it is healthy; keep the longer wait scoped to that slow command. +const MAC_SYSTEM_FONT_LIST_TIMEOUT_MS = 45_000 export async function listSystemFontFamilies(): Promise { if (cachedFonts) { @@ -43,23 +46,26 @@ function loadSystemFontFamilies(): Promise { } function listMacFonts(): Promise { - return execFileText('system_profiler', ['SPFontsDataType', '-json'], 32 * 1024 * 1024).then( - (output) => { - const parsed = JSON.parse(output) as { - SPFontsDataType?: { - typefaces?: { - family?: string - }[] + return execFileText( + 'system_profiler', + ['SPFontsDataType', '-json'], + 32 * 1024 * 1024, + MAC_SYSTEM_FONT_LIST_TIMEOUT_MS + ).then((output) => { + const parsed = JSON.parse(output) as { + SPFontsDataType?: { + typefaces?: { + family?: string }[] - } - - return uniqueSorted( - (parsed.SPFontsDataType ?? []).flatMap((font) => - (font.typefaces ?? []).map((typeface) => typeface.family) - ) - ) + }[] } - ) + + return uniqueSorted( + (parsed.SPFontsDataType ?? []).flatMap((font) => + (font.typefaces ?? []).map((typeface) => typeface.family) + ) + ) + }) } function listLinuxFonts(): Promise { @@ -95,7 +101,12 @@ $fonts.Families | ForEach-Object { $_.Name } ) } -function execFileText(command: string, args: string[], maxBuffer: number): Promise { +function execFileText( + command: string, + args: string[], + maxBuffer: number, + timeoutMs = SYSTEM_FONT_LIST_TIMEOUT_MS +): Promise { return new Promise((resolve, reject) => { let settled = false let timer: ReturnType | undefined @@ -123,7 +134,7 @@ function execFileText(command: string, args: string[], maxBuffer: number): Promi // should fall back instead of keeping settings IPC pending forever. child.kill() reject(new Error(`Timed out listing system fonts with ${command}`)) - }, SYSTEM_FONT_LIST_TIMEOUT_MS) + }, timeoutMs) if (typeof timer === 'object' && 'unref' in timer) { timer.unref() } diff --git a/src/main/system-resume-broadcast.test.ts b/src/main/system-resume-broadcast.test.ts new file mode 100644 index 00000000000..dc67ee27a8a --- /dev/null +++ b/src/main/system-resume-broadcast.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { registerSystemResumeBroadcast, SYSTEM_RESUMED_CHANNEL } from './system-resume-broadcast' + +vi.mock('electron', () => ({ + BrowserWindow: { getAllWindows: vi.fn(() => []) }, + powerMonitor: { on: vi.fn(), off: vi.fn() } +})) + +type ResumeListener = () => void + +function createResumeSource() { + const state: { listener: ResumeListener | null } = { listener: null } + const source = { + on: vi.fn((_event: 'resume', callback: ResumeListener) => { + state.listener = callback + }), + off: vi.fn((_event: 'resume', _callback: ResumeListener) => { + state.listener = null + }) + } + return { source, fireResume: () => state.listener?.() } +} + +function createWindow(destroyed = false): { + isDestroyed: () => boolean + webContents: { send: ReturnType void>> } +} { + return { + isDestroyed: () => destroyed, + webContents: { send: vi.fn<(channel: string) => void>() } + } +} + +describe('registerSystemResumeBroadcast', () => { + it('broadcasts the resume channel to every live window', () => { + const { source, fireResume } = createResumeSource() + const liveWindow = createWindow() + const destroyedWindow = createWindow(true) + registerSystemResumeBroadcast({ + resumeSource: source, + getWindows: () => [liveWindow, destroyedWindow] + }) + + fireResume() + + expect(liveWindow.webContents.send).toHaveBeenCalledWith(SYSTEM_RESUMED_CHANNEL) + expect(destroyedWindow.webContents.send).not.toHaveBeenCalled() + }) + + it('stops broadcasting after unsubscribe', () => { + const { source, fireResume } = createResumeSource() + const window = createWindow() + const unsubscribe = registerSystemResumeBroadcast({ + resumeSource: source, + getWindows: () => [window] + }) + + unsubscribe() + fireResume() + + expect(source.off).toHaveBeenCalledTimes(1) + expect(window.webContents.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/system-resume-broadcast.ts b/src/main/system-resume-broadcast.ts new file mode 100644 index 00000000000..990b8d2a66c --- /dev/null +++ b/src/main/system-resume-broadcast.ts @@ -0,0 +1,39 @@ +import { BrowserWindow, powerMonitor } from 'electron' + +export const SYSTEM_RESUMED_CHANNEL = 'system:resumed' + +type ResumeEventSource = { + on(event: 'resume', listener: () => void): unknown + off(event: 'resume', listener: () => void): unknown +} + +type ResumeBroadcastWindow = { + isDestroyed(): boolean + webContents: { send(channel: string): void } +} + +type SystemResumeBroadcastOptions = { + resumeSource?: ResumeEventSource + getWindows?: () => ResumeBroadcastWindow[] +} + +// Why: renderers cannot observe OS sleep/wake directly, and Linux has no +// window-occlusion tracking so visibilitychange never fires around suspend. +// Wake-sensitive renderer recovery needs this explicit resume signal. +export function registerSystemResumeBroadcast( + options: SystemResumeBroadcastOptions = {} +): () => void { + const resumeSource = options.resumeSource ?? powerMonitor + const getWindows = options.getWindows ?? (() => BrowserWindow.getAllWindows()) + const onResume = (): void => { + for (const window of getWindows()) { + if (!window.isDestroyed()) { + window.webContents.send(SYSTEM_RESUMED_CHANNEL) + } + } + } + resumeSource.on('resume', onResume) + return () => { + resumeSource.off('resume', onResume) + } +} diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index 2dd41fad7d1..3929288d588 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -9,7 +9,7 @@ import { statSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { app } from 'electron' import type { WorkspaceSessionState } from '../shared/types' import { @@ -20,20 +20,47 @@ import { const SNAPSHOT_DIR_NAME = 'terminal-scrollback' const REF_PREFIX = 'v1' -function getSnapshotRoot(): string { +export type TerminalScrollbackSnapshotStorage = { + snapshotRoot?: string + fallbackSnapshotRoot?: string | null +} + +function getLegacySnapshotRoot(): string { return join(app.getPath('userData'), SNAPSHOT_DIR_NAME) } +export function getProfileTerminalScrollbackSnapshotRoot(dataFile: string): string { + return join(dirname(dataFile), SNAPSHOT_DIR_NAME) +} + +function getSnapshotRoot(storage?: TerminalScrollbackSnapshotStorage): string { + return storage?.snapshotRoot ?? getLegacySnapshotRoot() +} + export function makeTerminalScrollbackSnapshotRef(tabId: string, leafId: string): string { const hash = createHash('sha256').update(`${tabId}\0${leafId}`).digest('hex').slice(0, 32) return `${REF_PREFIX}-${hash}` } -function snapshotPath(ref: string): string | null { +function snapshotPath(ref: string, snapshotRoot: string): string | null { if (!/^v1-[0-9a-f]{32}$/.test(ref)) { return null } - return join(getSnapshotRoot(), `${ref}.bin`) + return join(snapshotRoot, `${ref}.bin`) +} + +function snapshotReadPaths(ref: string, storage?: TerminalScrollbackSnapshotStorage): string[] { + const primaryRoot = getSnapshotRoot(storage) + const primaryPath = snapshotPath(ref, primaryRoot) + if (!primaryPath) { + return [] + } + const fallbackRoot = storage?.fallbackSnapshotRoot ?? null + if (!fallbackRoot || fallbackRoot === primaryRoot) { + return [primaryPath] + } + const fallbackPath = snapshotPath(ref, fallbackRoot) + return fallbackPath ? [primaryPath, fallbackPath] : [primaryPath] } function trailingUtf8Bytes(value: string, maxBytes: number): Buffer { @@ -72,17 +99,19 @@ export function writeTerminalScrollbackSnapshotSync(args: { tabId: string leafId: string buffer: string + storage?: TerminalScrollbackSnapshotStorage }): string | null { if (!args.buffer) { return null } const ref = makeTerminalScrollbackSnapshotRef(args.tabId, args.leafId) - const path = snapshotPath(ref) + const snapshotRoot = getSnapshotRoot(args.storage) + const path = snapshotPath(ref, snapshotRoot) if (!path) { return null } try { - mkdirSync(getSnapshotRoot(), { recursive: true, mode: 0o700 }) + mkdirSync(snapshotRoot, { recursive: true, mode: 0o700 }) const tmpPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` const bytes = trailingUtf8Bytes(args.buffer, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) let renamed = false @@ -104,27 +133,30 @@ export function writeTerminalScrollbackSnapshotSync(args: { } } -export function readTerminalScrollbackSnapshotSync(ref: string): string | null { - const path = snapshotPath(ref) - if (!path) { - return null - } - try { - return readTrailingUtf8(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) - } catch { - return null +export function readTerminalScrollbackSnapshotSync( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): string | null { + for (const path of snapshotReadPaths(ref, storage)) { + try { + return readTrailingUtf8(path, TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT) + } catch { + // Try the legacy/global fallback when a profile-local snapshot is absent. + } } + return null } -export function deleteTerminalScrollbackSnapshotSync(ref: string): void { - const path = snapshotPath(ref) - if (!path) { - return - } - try { - rmSync(path, { force: true }) - } catch { - // Best-effort cleanup; stale refs are harmless and bounded by per-file caps. +export function deleteTerminalScrollbackSnapshotSync( + ref: string, + storage?: TerminalScrollbackSnapshotStorage +): void { + for (const path of snapshotReadPaths(ref, storage)) { + try { + rmSync(path, { force: true }) + } catch { + // Best-effort cleanup; stale refs are harmless and bounded by per-file caps. + } } } @@ -139,7 +171,8 @@ export function collectTerminalScrollbackSnapshotRefs(session: WorkspaceSessionS } export function migrateWorkspaceSessionTerminalScrollbackSnapshots( - session: WorkspaceSessionState + session: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage ): { session: WorkspaceSessionState; changed: boolean } { let terminalLayoutsByTabId: WorkspaceSessionState['terminalLayoutsByTabId'] | null = null for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { @@ -151,7 +184,7 @@ export function migrateWorkspaceSessionTerminalScrollbackSnapshots( const remainingBuffers: Record = {} let layoutChanged = false for (const [leafId, buffer] of Object.entries(buffers)) { - const ref = writeTerminalScrollbackSnapshotSync({ tabId, leafId, buffer }) + const ref = writeTerminalScrollbackSnapshotSync({ tabId, leafId, buffer, storage }) if (ref) { refs[leafId] = ref layoutChanged = true diff --git a/src/main/tray/system-tray.test.ts b/src/main/tray/system-tray.test.ts index e269d9307b2..9f5ad72caad 100644 --- a/src/main/tray/system-tray.test.ts +++ b/src/main/tray/system-tray.test.ts @@ -1,21 +1,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as SystemTrayModule from './system-tray' -const { trayInstances, menuFromTemplateMock, createAppIconImageMock, resizedImage } = vi.hoisted( - () => { - const resizedImage = { resized: true } - return { - trayInstances: [] as FakeTray[], - menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), - createAppIconImageMock: vi.fn(), - resizedImage - } +const { + trayInstances, + menuFromTemplateMock, + createAppIconImageMock, + composeAttentionMock, + resizedImage +} = vi.hoisted(() => { + const resizedImage = { resized: true } + return { + trayInstances: [] as FakeTray[], + menuFromTemplateMock: vi.fn((template: unknown) => ({ template })), + createAppIconImageMock: vi.fn(), + composeAttentionMock: vi.fn((image: unknown) => ({ dotted: image })), + resizedImage } -) +}) class FakeTray { setToolTip = vi.fn() setContextMenu = vi.fn() + setImage = vi.fn() on = vi.fn() destroy = vi.fn() isDestroyed = vi.fn(() => false) @@ -33,6 +39,10 @@ vi.mock('../app-icon', () => ({ createAppIconImage: createAppIconImageMock })) +vi.mock('./tray-attention-icon', () => ({ + composeTrayAttentionIcon: composeAttentionMock +})) + type TrayModule = typeof SystemTrayModule const originalPlatform = process.platform @@ -55,6 +65,7 @@ function builtMenuItems(): MenuItem[] { beforeEach(() => { trayInstances.length = 0 menuFromTemplateMock.mockClear() + composeAttentionMock.mockClear() createAppIconImageMock.mockReset() createAppIconImageMock.mockReturnValue({ resize: vi.fn(() => resizedImage) }) }) @@ -126,6 +137,57 @@ describe('createSystemTray', () => { }) }) +describe('setTrayAttention', () => { + it('swaps in the dotted icon when active and restores the base when cleared', async () => { + setPlatform('win32') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + const tray = trayInstances[0] + tray.setImage.mockClear() + + setTrayAttention(true) + expect(composeAttentionMock).toHaveBeenCalledWith(resizedImage) + expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) + + tray.setImage.mockClear() + setTrayAttention(false) + expect(tray.setImage).toHaveBeenCalledWith(resizedImage) + }) + + it('ignores repeated same-state calls', async () => { + setPlatform('win32') + const { createSystemTray, setTrayAttention } = await loadModule() + createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + const tray = trayInstances[0] + tray.setImage.mockClear() + + setTrayAttention(true) + setTrayAttention(true) + + expect(tray.setImage).toHaveBeenCalledTimes(1) + }) + + it('reflects attention that was requested before the tray was created', async () => { + setPlatform('win32') + const { createSystemTray, setTrayAttention } = await loadModule() + + // Fire the event before the (deferred) tray exists. + setTrayAttention(true) + createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }) + const tray = trayInstances[0] + + expect(tray.setImage).toHaveBeenCalledWith({ dotted: resizedImage }) + }) + + it('is a safe no-op on non-win32 platforms', async () => { + setPlatform('darwin') + const { setTrayAttention } = await loadModule() + + expect(() => setTrayAttention(true)).not.toThrow() + expect(composeAttentionMock).not.toHaveBeenCalled() + }) +}) + describe('destroySystemTray', () => { it('destroys an existing tray and is safe to call without one', async () => { setPlatform('win32') diff --git a/src/main/tray/system-tray.ts b/src/main/tray/system-tray.ts index d531d2c596b..ada6f58c4e9 100644 --- a/src/main/tray/system-tray.ts +++ b/src/main/tray/system-tray.ts @@ -1,6 +1,7 @@ -import { Menu, Tray } from 'electron' +import { Menu, Tray, type NativeImage } from 'electron' import { createAppIconImage } from '../app-icon' import { translateMain } from '../i18n/main-i18n' +import { composeTrayAttentionIcon } from './tray-attention-icon' type SystemTrayOptions = { /** App icon id from settings; the tray reuses the app icon image. */ @@ -15,10 +16,28 @@ type SystemTrayOptions = { // reference is kept, so hold it at module scope for the app's lifetime. let tray: Tray | null = null +// Why: hold the plain (dot-free) icon so we can toggle the attention dot on and +// off with tray.setImage without rebuilding the icon from the app-icon PNG. +let baseTrayImage: NativeImage | null = null + +// Why: an attention event can fire while the tray is still being created +// (creation is deferred ~ready-to-show); remember the desired state so a +// freshly created tray reflects it immediately. +let attentionActive = false + // Why: on Windows the notification area expects a 16px icon; the app icon PNG // is larger, so downscale to avoid a cropped/blurry tray glyph. const TRAY_ICON_SIZE = 16 +// Why: centralize which image the tray shows so both creation and attention +// toggling stay in sync. No-ops safely when the tray or base image is missing. +function applyTrayImage(): void { + if (!tray || tray.isDestroyed() || !baseTrayImage) { + return + } + tray.setImage(attentionActive ? composeTrayAttentionIcon(baseTrayImage) : baseTrayImage) +} + /** * Creates the Windows system tray icon. No-op on macOS/Linux. Idempotent: a * second call while a tray is alive returns the existing one instead of @@ -31,11 +50,13 @@ export function createSystemTray(opts: SystemTrayOptions): Tray | null { if (tray && !tray.isDestroyed()) { return tray } - const image = createAppIconImage(opts.appIcon).resize({ + baseTrayImage = createAppIconImage(opts.appIcon).resize({ width: TRAY_ICON_SIZE, height: TRAY_ICON_SIZE }) - tray = new Tray(image) + tray = new Tray(baseTrayImage) + // Why: reflect any attention event that fired before the tray existed. + applyTrayImage() tray.setToolTip('Orca') const menu = Menu.buildFromTemplate([ { label: translateMain('tray.openOrca', 'Open Orca'), click: () => opts.onOpen() }, @@ -49,10 +70,26 @@ export function createSystemTray(opts: SystemTrayOptions): Tray | null { return tray } +/** + * Shows or hides a red/amber attention dot on the tray icon. Call with `true` + * when a terminal bell or agent completion fires while the window is + * minimized/hidden, and `false` once the window is shown again. No-op on + * macOS/Linux (no tray) and safe to call before the tray is created. + */ +export function setTrayAttention(active: boolean): void { + if (attentionActive === active) { + return + } + attentionActive = active + applyTrayImage() +} + /** Destroys the tray icon if present. Safe to call repeatedly or with no tray. */ export function destroySystemTray(): void { if (tray && !tray.isDestroyed()) { tray.destroy() } tray = null + baseTrayImage = null + attentionActive = false } diff --git a/src/main/tray/tray-attention-icon.test.ts b/src/main/tray/tray-attention-icon.test.ts new file mode 100644 index 00000000000..42d554417e0 --- /dev/null +++ b/src/main/tray/tray-attention-icon.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' + +const createFromBitmapMock = vi.hoisted(() => + vi.fn((buffer: Buffer, options: { width: number; height: number }) => ({ + __image: true, + buffer, + ...options + })) +) + +vi.mock('electron', () => ({ + nativeImage: { createFromBitmap: createFromBitmapMock } +})) + +import { composeTrayAttentionIcon } from './tray-attention-icon' + +type FakeImage = { + getSize: () => { width: number; height: number } + toBitmap: () => Buffer +} + +function fakeBase(width: number, height: number): FakeImage { + // All-transparent base so any non-zero pixel in the result is the dot/ring. + return { + getSize: () => ({ width, height }), + toBitmap: () => Buffer.alloc(width * height * 4, 0) + } +} + +// The compositor receives BGRA. amber-500 = #f59e0b. +const AMBER = { b: 0x0b, g: 0x9e, r: 0xf5 } + +describe('composeTrayAttentionIcon', () => { + it('returns the base unchanged when it has no pixels', () => { + const base = { getSize: () => ({ width: 0, height: 0 }), toBitmap: () => Buffer.alloc(0) } + + expect(composeTrayAttentionIcon(base as never)).toBe(base) + expect(createFromBitmapMock).not.toHaveBeenCalled() + }) + + it('builds a new image of the same size', () => { + createFromBitmapMock.mockClear() + const result = composeTrayAttentionIcon(fakeBase(16, 16) as never) + + expect(createFromBitmapMock).toHaveBeenCalledTimes(1) + const [, options] = createFromBitmapMock.mock.calls[0] + expect(options).toEqual({ width: 16, height: 16 }) + expect(result).toMatchObject({ __image: true, width: 16, height: 16 }) + }) + + it('paints an amber dot in the top-right corner and leaves the rest untouched', () => { + createFromBitmapMock.mockClear() + const width = 16 + const height = 16 + composeTrayAttentionIcon(fakeBase(width, height) as never) + const bitmap = createFromBitmapMock.mock.calls[0][0] + + const pixel = (x: number, y: number): [number, number, number, number] => { + const o = (y * width + x) * 4 + return [bitmap[o], bitmap[o + 1], bitmap[o + 2], bitmap[o + 3]] + } + + // The dot must exist, be centered in the top-right, and never touch the + // opposite (bottom-left) corner where the app glyph is most visible. + let amberCount = 0 + let sumX = 0 + let sumY = 0 + let paintedInBottomLeft = 0 + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const [b, g, r, a] = pixel(x, y) + if (b === AMBER.b && g === AMBER.g && r === AMBER.r && a === 0xff) { + amberCount++ + sumX += x + sumY += y + } + if (a !== 0 && x < width / 2 && y >= height / 2) { + paintedInBottomLeft++ + } + } + } + + expect(amberCount).toBeGreaterThan(0) + expect(sumX / amberCount).toBeGreaterThan(width / 2) // centroid sits right of center + expect(sumY / amberCount).toBeLessThan(height / 2) // centroid sits above center + expect(paintedInBottomLeft).toBe(0) // the opposite corner is never touched + }) +}) diff --git a/src/main/tray/tray-attention-icon.ts b/src/main/tray/tray-attention-icon.ts new file mode 100644 index 00000000000..d9b042db37f --- /dev/null +++ b/src/main/tray/tray-attention-icon.ts @@ -0,0 +1,52 @@ +import { nativeImage, type NativeImage } from 'electron' + +// Why: amber-500 (#f59e0b) is Orca's "needs attention / unread" color, matching +// the renderer launcher dot and the tab-unread bell. Kept in sync with the +// bg-amber-500 used in FloatingTerminalToggleButton. +const DOT_RGB = { r: 0xf5, g: 0x9e, b: 0x0b } +// A near-white halo separates the dot from the icon glyph on any tray theme. +const RING_RGB = { r: 0xff, g: 0xff, b: 0xff } + +/** + * Returns a copy of `base` with a small amber attention dot composited into the + * top-right corner. Electron's NativeImage has no compositing API, so we merge + * the dot directly into the raw BGRA bitmap. Returns `base` unchanged if it has + * no pixels (e.g. a failed icon load) so the tray never shows a blank image. + */ +export function composeTrayAttentionIcon(base: NativeImage): NativeImage { + const { width, height } = base.getSize() + if (width <= 0 || height <= 0) { + return base + } + + // toBitmap()/createFromBitmap both use BGRA; the round-trip preserves format. + const bitmap = Buffer.from(base.toBitmap()) + const dotRadius = Math.max(2, Math.round(Math.min(width, height) * 0.2)) + const ringRadius = dotRadius + 1 + // Hug the top-right corner so the dot reads as a badge and leaves the app + // glyph (bottom-left) visible. The dot stays fully on-canvas; the outer ring + // may clip a pixel at the very corner, which is expected for a corner badge. + const centerX = width - 1 - dotRadius + const centerY = dotRadius + const dotRadiusSq = dotRadius * dotRadius + const ringRadiusSq = ringRadius * ringRadius + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const dx = x - centerX + const dy = y - centerY + const distSq = dx * dx + dy * dy + if (distSq > ringRadiusSq) { + continue + } + const offset = (y * width + x) * 4 + const color = distSq <= dotRadiusSq ? DOT_RGB : RING_RGB + bitmap[offset] = color.b + bitmap[offset + 1] = color.g + bitmap[offset + 2] = color.r + bitmap[offset + 3] = 0xff + } + } + + return nativeImage.createFromBitmap(bitmap, { width, height }) +} diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index b4c1a47c54b..defad8588e5 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -1766,11 +1766,14 @@ describe('updater', () => { expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null) }) - // Why: issue #631 — the Windows auto-updater fails because installed - // versions signed with the wrong certificate have a stale publisherName - // in app-update.yml. verifyUpdateCodeSignature must be overridden on - // Windows so electron-updater skips Authenticode verification. - it('overrides verifyUpdateCodeSignature on Windows to skip signing verification', async () => { + // Why: the Windows auto-updater must keep electron-updater's built-in + // Authenticode verification, which checks the downloaded installer against + // the SignPath Foundation publisherName that electron-builder embeds in + // app-update.yml. A no-op verifyUpdateCodeSignature override would silently + // accept every installer, so setup must NOT install one. (The issue #631 + // stale-publisherName problem that once justified an override is resolved now + // that SignPath builds embed the correct publisherName.) + it('does not disable Windows Authenticode verification on win32', async () => { vi.stubGlobal('process', { ...process, platform: 'win32' }) const { setupAutoUpdater } = await import('./updater') @@ -1780,11 +1783,7 @@ describe('updater', () => { setupAutoUpdater(mainWindow as never) - // The override should be set on the autoUpdater mock - const override = (autoUpdaterMock as Record).verifyUpdateCodeSignature - expect(override).toBeTypeOf('function') - // Calling it should resolve to null (meaning "signature valid, skip check") - await expect((override as () => Promise)()).resolves.toBeNull() + expect((autoUpdaterMock as Record).verifyUpdateCodeSignature).toBeUndefined() }) it('does not override verifyUpdateCodeSignature on non-Windows platforms', async () => { diff --git a/src/main/updater.ts b/src/main/updater.ts index bc9c9d9b6f6..42924bdf08c 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines */ import { app, BrowserWindow, powerMonitor } from 'electron' -import type { NsisUpdater } from 'electron-updater' import { is } from '@electron-toolkit/utils' import type { UpdateCheckOptions, UpdateStatus } from '../shared/types' import { killAllPty } from './ipc/pty' @@ -1269,15 +1268,11 @@ export function setupAutoUpdater( debug: (m: unknown) => console.debug('[autoUpdater]', m) } as never - // Why: older Windows installs either have no publisherName or have the - // stale macOS Apple Developer ID publisherName from issue #631. Keep the - // migration path open while SignPath-signed builds roll out. - // - // TODO: re-enable after SignPath-signed builds with the explicit Windows - // publisherName have been the minimum supported updater source for a while. - if (process.platform === 'win32') { - ;(autoUpdater as NsisUpdater).verifyUpdateCodeSignature = () => Promise.resolve(null) - } + // Why: Windows update integrity is enforced by electron-updater's built-in + // Authenticode check against the `publisherName` (SignPath Foundation) that + // electron-builder embeds in app-update.yml. Do NOT re-add a + // `verifyUpdateCodeSignature` override — a no-op override silently accepts + // every downloaded installer, disabling signature verification entirely. // Use the generic provider with GitHub's /releases/latest/download/ URL as // the startup fallback so electron-updater can fetch the manifest diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index d291198a0e0..0a6d381d3c0 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + getCmdExePath, getSpawnArgsForWindows, isPermissionError, isWindowsBatchScript, @@ -65,6 +66,26 @@ describe('getSpawnArgsForWindows', () => { } }) + it('preserves VS Code WSL remote arguments with spaces through .cmd launchers', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\code.cmd', [ + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + + expect(spawnCmd).toBe(getCmdExePath()) + expect(spawnArgs).toEqual([ + '/d', + '/c', + 'C:\\tools\\code.cmd', + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + }) + }) + it('passes .exe through unchanged on win32', () => { withPlatform('win32', () => { const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\codex.exe', ['login']) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 2d34a4c9fd0..cc03c40a904 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -389,6 +389,7 @@ function registerRuntimeWindowLifecycle( }) as Promise, closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), + resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }), terminalFitOverrideChanged: (ptyId, mode, cols, rows) => send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }), terminalDriverChanged: (ptyId, driver) => diff --git a/src/main/window/main-window-visibility.test.ts b/src/main/window/main-window-visibility.test.ts new file mode 100644 index 00000000000..335dc4a87d6 --- /dev/null +++ b/src/main/window/main-window-visibility.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { isMainWindowVisible } from './main-window-visibility' + +describe('main window visibility helpers', () => { + it('treats a minimal alive window double as visible', () => { + expect(isMainWindowVisible({ isDestroyed: () => false })).toBe(true) + }) + + it('parks work when the real window is hidden, minimized, destroyed, or missing', () => { + expect( + isMainWindowVisible({ + isDestroyed: () => false, + isVisible: () => false, + isMinimized: () => false + }) + ).toBe(false) + expect( + isMainWindowVisible({ + isDestroyed: () => false, + isVisible: () => true, + isMinimized: () => true + }) + ).toBe(false) + expect(isMainWindowVisible({ isDestroyed: () => true })).toBe(false) + expect(isMainWindowVisible(null)).toBe(false) + }) +}) diff --git a/src/main/window/main-window-visibility.ts b/src/main/window/main-window-visibility.ts new file mode 100644 index 00000000000..8b4ca4c4647 --- /dev/null +++ b/src/main/window/main-window-visibility.ts @@ -0,0 +1,38 @@ +// Why: BrowserWindow instances are recreated on macOS dock re-activation, so +// long-lived main-process services (e.g. the SSH port scanner) subscribe to +// this process-global signal instead of a specific window instance; index.ts +// re-wires each new window's show/restore events into notifyMainWindowBecameVisible. +type MainWindowBecameVisibleListener = () => void + +type MainWindowVisibilityState = { + isDestroyed: () => boolean + isVisible?: () => boolean + isMinimized?: () => boolean +} + +const listeners = new Set() + +export function notifyMainWindowBecameVisible(): void { + for (const listener of Array.from(listeners)) { + listener() + } +} + +export function onMainWindowBecameVisible(listener: MainWindowBecameVisibleListener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function isMainWindowVisible(window: MainWindowVisibilityState | null): boolean { + if (window === null || window.isDestroyed()) { + return false + } + + // Why: production BrowserWindow exposes both APIs, but older main-process + // tests use minimal window doubles that should stay visible by default. + const isVisible = window.isVisible?.() ?? true + const isMinimized = window.isMinimized?.() ?? false + return isVisible && !isMinimized +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a6cf04e7d75..4ec3557beeb 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -11,6 +11,32 @@ import type { import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' +import type { + CreateLocalOrcaProfileArgs, + CreateLocalOrcaProfileResult, + CreateCloudLinkedOrcaProfileArgs, + CreateCloudLinkedOrcaProfileResult, + ConnectCurrentOrcaProfileResult, + FindOrcaProfileProjectsByPathArgs, + FindOrcaProfileProjectsByPathResult, + OrcaProfileListResult, + OrcaProfileAuthStatus, + RefreshCurrentOrcaProfileAuthResult, + SelectOrcaProfileOrgArgs, + SelectOrcaProfileOrgResult, + SignOutCurrentOrcaProfileResult, + SwitchOrcaProfileArgs, + SwitchOrcaProfileResult, + TransferOrcaProfileProjectArgs, + TransferOrcaProfileProjectResult, + OrcaProfileOrgInviteRevokeArgs, + OrcaProfileOrgMemberChangeRoleArgs, + OrcaProfileOrgMemberInviteArgs, + OrcaProfileOrgMemberMutationResult, + OrcaProfileOrgMemberRemoveArgs, + OrcaProfileOrgMembersListArgs, + OrcaProfileOrgMembersListResult +} from '../shared/orca-profiles' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { TaskSourceContext } from '../shared/task-source-context' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' @@ -126,6 +152,7 @@ import type { GetRateLimitResult, NotificationDispatchRequest, NotificationDispatchResult, + NotificationDeliveryProbeResult, NotificationDismissResult, NotificationPermissionStatusResult, NotificationSoundResult, @@ -841,6 +868,40 @@ export type AppApi = { export type PreloadApi = { app: AppApi + orcaProfiles: { + list: () => Promise + authStatus: () => Promise + createLocal: (args?: CreateLocalOrcaProfileArgs) => Promise + createCloudLinked: ( + args?: CreateCloudLinkedOrcaProfileArgs + ) => Promise + switchProfile: (args: SwitchOrcaProfileArgs) => Promise + transferProject: ( + args: TransferOrcaProfileProjectArgs + ) => Promise + findProjectProfiles: ( + args: FindOrcaProfileProjectsByPathArgs + ) => Promise + connectCurrent: () => Promise + refreshAuth: () => Promise + signOutCurrent: () => Promise + selectOrg: (args: SelectOrcaProfileOrgArgs) => Promise + orgMembersList: ( + args: OrcaProfileOrgMembersListArgs + ) => Promise + orgMemberInvite: ( + args: OrcaProfileOrgMemberInviteArgs + ) => Promise + orgInviteRevoke: ( + args: OrcaProfileOrgInviteRevokeArgs + ) => Promise + orgMemberChangeRole: ( + args: OrcaProfileOrgMemberChangeRoleArgs + ) => Promise + orgMemberRemove: ( + args: OrcaProfileOrgMemberRemoveArgs + ) => Promise + } platform: { get: () => { platform: NodeJS.Platform @@ -859,6 +920,9 @@ export type PreloadApi = { kind?: 'git' | 'folder' }) => Promise<{ repo: Repo } | { error: string }> remove: (args: { repoId: string }) => Promise + // Forget a project on one execution host only, leaving the same repo id on + // other hosts (local or a re-added SSH target) intact. + removeForHost: (args: { repoId: string; hostId: string }) => Promise reorder: (args: { orderedIds: string[] }) => Promise<{ status: 'applied' | 'rejected' }> update: (args: { repoId: string @@ -1059,6 +1123,9 @@ export type PreloadApi = { force?: boolean skipArchive?: boolean }) => Promise + // Forget a workspace from Orca only — no remote Git/filesystem work. Used + // for workspaces pinned to a removed/disconnected SSH host. + forgetLocal: (args: { worktreeId: string }) => Promise forceDeletePreservedBranch: (args: { worktreeId: string branchName: string @@ -1109,6 +1176,7 @@ export type PreloadApi = { cols: number rows: number cwd?: string + cwdFallback?: 'worktree' env?: Record command?: string launchConfig?: SleepingAgentLaunchConfig @@ -1145,6 +1213,7 @@ export type PreloadApi = { replay?: string sessionExpired?: boolean coldRestore?: { scrollback: string; cwd: string } + startupCwdFallback?: { kind: 'worktree'; cwd: string } }> write: (id: string, data: string) => void writeAccepted: (id: string, data: string) => Promise @@ -1174,6 +1243,7 @@ export type PreloadApi = { seq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> getRendererDeliveryDebugSnapshot: () => Promise<{ pendingPtyCount: number @@ -1198,6 +1268,7 @@ export type PreloadApi = { seq?: number rawLength?: number background?: boolean + droppedBacklog?: boolean }) => void ) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void @@ -1985,7 +2056,7 @@ export type PreloadApi = { dismiss: (ids: string[]) => Promise openSystemSettings: () => Promise getPermissionStatus: () => Promise - requestPermission: () => Promise + probeDelivery: (args?: { force?: boolean }) => Promise playSound: (options?: { force?: boolean; volume?: number }) => Promise } onboarding: { @@ -2239,7 +2310,9 @@ export type PreloadApi = { rootPath: string connectionId?: string excludePaths?: string[] + requestToken?: string }) => Promise + cancelListFiles: (args: { requestToken: string }) => Promise search: (args: SearchOptions & { connectionId?: string }) => Promise importExternalPaths: (args: { sourcePaths: string[] @@ -2677,7 +2750,9 @@ export type PreloadApi = { callback: (data: { tabId: string; paneRuntimeId?: number }) => void ) => () => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void + onSystemResumed: (callback: () => void) => () => void readClipboardText: (options?: ReadClipboardTextOptions) => Promise readSelectionClipboardText: (options?: ReadClipboardTextOptions) => Promise saveClipboardImageAsTempFile: (args?: { @@ -2808,13 +2883,16 @@ export type PreloadApi = { } ssh: { listTargets: () => Promise + // Removed-target id → last known label, for showing a friendly host name on + // workspaces still pinned to a target that no longer exists. + listRemovedTargetLabels: () => Promise> addTarget: (args: { target: Omit }) => Promise updateTarget: (args: { id: string updates: Partial> }) => Promise removeTarget: (args: { id: string }) => Promise - importConfig: () => Promise + importConfig: (args?: { reAdopt?: boolean }) => Promise connect: (args: { targetId: string }) => Promise disconnect: (args: { targetId: string }) => Promise terminateSessions: (args: { targetId: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index ba7760a5bf9..d7385f18fc2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -36,6 +36,7 @@ import type { MemorySnapshot, NotificationDismissResult, NotificationDispatchResult, + NotificationDeliveryProbeResult, NotificationPermissionStatusResult, NotificationSoundDataResult, NotificationSoundPathResult, @@ -481,6 +482,25 @@ const api = { ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory') }, + orcaProfiles: { + list: () => ipcRenderer.invoke('orcaProfiles:list'), + authStatus: () => ipcRenderer.invoke('orcaProfiles:authStatus'), + createLocal: (args) => ipcRenderer.invoke('orcaProfiles:createLocal', args), + createCloudLinked: (args) => ipcRenderer.invoke('orcaProfiles:createCloudLinked', args), + switchProfile: (args) => ipcRenderer.invoke('orcaProfiles:switch', args), + transferProject: (args) => ipcRenderer.invoke('orcaProfiles:transferProject', args), + findProjectProfiles: (args) => ipcRenderer.invoke('orcaProfiles:findProjectProfiles', args), + connectCurrent: () => ipcRenderer.invoke('orcaProfiles:connectCurrent'), + refreshAuth: () => ipcRenderer.invoke('orcaProfiles:refreshAuth'), + signOutCurrent: () => ipcRenderer.invoke('orcaProfiles:signOutCurrent'), + selectOrg: (args) => ipcRenderer.invoke('orcaProfiles:selectOrg', args), + orgMembersList: (args) => ipcRenderer.invoke('orcaProfiles:orgMembersList', args), + orgMemberInvite: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberInvite', args), + orgInviteRevoke: (args) => ipcRenderer.invoke('orcaProfiles:orgInviteRevoke', args), + orgMemberChangeRole: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberChangeRole', args), + orgMemberRemove: (args) => ipcRenderer.invoke('orcaProfiles:orgMemberRemove', args) + } satisfies PreloadApi['orcaProfiles'], + platform: { get: () => ({ platform: process.platform, @@ -520,6 +540,8 @@ const api = { remove: (args) => ipcRenderer.invoke('repos:remove', args), + removeForHost: (args) => ipcRenderer.invoke('repos:removeForHost', args), + reorder: (args) => ipcRenderer.invoke('repos:reorder', args), update: (args) => ipcRenderer.invoke('repos:update', args), @@ -652,6 +674,8 @@ const api = { remove: (args) => ipcRenderer.invoke('worktrees:remove', args), + forgetLocal: (args) => ipcRenderer.invoke('worktrees:forgetLocal', args), + forceDeletePreservedBranch: (args) => ipcRenderer.invoke('worktrees:forceDeletePreservedBranch', args), @@ -752,6 +776,7 @@ const api = { cols: number rows: number cwd?: string + cwdFallback?: 'worktree' env?: Record command?: string launchConfig?: SleepingAgentLaunchConfig @@ -787,6 +812,7 @@ const api = { replay?: string sessionExpired?: boolean coldRestore?: { scrollback: string; cwd: string } + startupCwdFallback?: { kind: 'worktree'; cwd: string } }> => ipcRenderer.invoke('pty:spawn', opts), write: (id: string, data: string): void => { @@ -850,6 +876,7 @@ const api = { seq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), getRendererDeliveryDebugSnapshot: (): Promise<{ @@ -897,6 +924,7 @@ const api = { seq?: number rawLength?: number background?: boolean + droppedBacklog?: boolean }) => void ): (() => void) => { const listener = ( @@ -907,6 +935,7 @@ const api = { seq?: number rawLength?: number background?: boolean + droppedBacklog?: boolean } ) => callback(data) ipcRenderer.on('pty:data', listener) @@ -1870,8 +1899,8 @@ const api = { openSystemSettings: (): Promise => ipcRenderer.invoke('notifications:openSystemSettings'), getPermissionStatus: (): Promise => ipcRenderer.invoke('notifications:getPermissionStatus'), - requestPermission: (): Promise => - ipcRenderer.invoke('notifications:requestPermission'), + probeDelivery: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('notifications:probeDelivery', args), playSound: async (options?: { force?: boolean volume?: number @@ -2641,7 +2670,10 @@ const api = { rootPath: string connectionId?: string excludePaths?: string[] + requestToken?: string }): Promise => ipcRenderer.invoke('fs:listFiles', args), + cancelListFiles: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('fs:cancelListFiles', args), search: (args: { query: string rootPath: string @@ -3446,12 +3478,23 @@ const api = { ipcRenderer.on('ui:sleepWorktree', listener) return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) }, + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:resumeSleepingAgents', listener) + return () => ipcRenderer.removeListener('ui:resumeSleepingAgents', listener) + }, onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => callback(direction) ipcRenderer.on('terminal:zoom', listener) return () => ipcRenderer.removeListener('terminal:zoom', listener) }, + onSystemResumed: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('system:resumed', listener) + return () => ipcRenderer.removeListener('system:resumed', listener) + }, readClipboardText: (options?: ReadClipboardTextOptions): Promise => ipcRenderer.invoke('clipboard:readText', options), readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => @@ -3826,6 +3869,9 @@ const api = { ssh: { listTargets: (): Promise => ipcRenderer.invoke('ssh:listTargets'), + listRemovedTargetLabels: (): Promise> => + ipcRenderer.invoke('ssh:listRemovedTargetLabels'), + addTarget: (args: { target: Omit }): Promise => ipcRenderer.invoke('ssh:addTarget', args), @@ -3837,7 +3883,8 @@ const api = { removeTarget: (args: { id: string }): Promise => ipcRenderer.invoke('ssh:removeTarget', args), - importConfig: (): Promise => ipcRenderer.invoke('ssh:importConfig'), + importConfig: (args?: { reAdopt?: boolean }): Promise => + ipcRenderer.invoke('ssh:importConfig', args), connect: (args: { targetId: string }): Promise => ipcRenderer.invoke('ssh:connect', args), diff --git a/src/relay/fs-handler-git-fallback.ts b/src/relay/fs-handler-git-fallback.ts index da1f67c7ffa..8a98b9fa444 100644 --- a/src/relay/fs-handler-git-fallback.ts +++ b/src/relay/fs-handler-git-fallback.ts @@ -7,6 +7,7 @@ * a git-focused app. */ import { spawn } from 'node:child_process' +import { fileListingCancellationError } from '../shared/file-listing-cancellation' import type { SearchOptions, SearchResult } from './fs-handler-utils' import { buildGitLsFilesArgsForQuickOpen } from '../shared/quick-open-filter' import { expandQuickOpenGitFilesWithNestedRepos } from '../shared/quick-open-readdir-walk' @@ -31,8 +32,13 @@ import { buildRelayCommandEnv } from './relay-command-env' */ export function listFilesWithGit( rootPath: string, - excludePathPrefixes: readonly string[] = [] + excludePathPrefixes: readonly string[] = [], + options: { signal?: AbortSignal } = {} ): Promise { + const { signal } = options + if (signal?.aborted) { + return Promise.reject(fileListingCancellationError(signal)) + } const gitPaths = new Set() const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes) const children: { @@ -145,7 +151,7 @@ export function listFilesWithGit( }) } - const killSurvivors = (): void => { + const killSurvivors = (reason: string): void => { // Why: Promise.all returns after the first failed pass, but the sibling // git process can keep streaming on SSH unless we cancel it explicitly. for (const entry of children) { @@ -155,22 +161,35 @@ export function listFilesWithGit( if (entry.child.exitCode === null && entry.child.signalCode === null) { entry.child.kill() } - entry.reject(new Error('git ls-files canceled after sibling failure')) + entry.reject(new Error(reason)) } } + // Why: a cancelled scan (workspace switch, superseded request) must stop + // its git children right away instead of streaming a huge tree the caller + // has already abandoned over the shared SSH channel. + const onAbort = (): void => killSurvivors('git ls-files cancelled') + signal?.addEventListener('abort', onAbort, { once: true }) + return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]) .then(() => expandQuickOpenGitFilesWithNestedRepos({ rootPath, gitPaths, - excludePathPrefixes + excludePathPrefixes, + signal }) ) .catch((err) => { - killSurvivors() + killSurvivors('git ls-files canceled after sibling failure') + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } throw err }) + .finally(() => { + signal?.removeEventListener('abort', onAbort) + }) } /** diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts new file mode 100644 index 00000000000..02cd8da6afc --- /dev/null +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -0,0 +1,127 @@ +/** + * Cancellation behavior of the relay file-list scanners (#7721): an aborted + * scan must kill its child processes immediately and reject with a + * cancellation error instead of streaming an abandoned tree to completion. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + spawn: spawnMock +})) + +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { listFilesWithRg } from './fs-handler-list-files' +import { listFilesWithGit } from './fs-handler-git-fallback' +import { isFileListingCancellation } from '../shared/file-listing-cancellation' + +function createMockProcess(): ChildProcess { + const p = new EventEmitter() as unknown as ChildProcess + ;(p as unknown as Record).stdout = new EventEmitter() + ;( + (p as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(p as unknown as Record).stderr = new EventEmitter() + ;(p as unknown as Record).kill = vi.fn() + ;(p as unknown as Record).exitCode = null + ;(p as unknown as Record).signalCode = null + return p +} + +describe('relay list-files cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('listFilesWithRg kills both rg passes and rejects when aborted mid-flight', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + spawnMock.mockImplementation((_cmd: string, args: string[]) => + args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc + ) + + const controller = new AbortController() + const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) + + // Partial output before the abort — must be discarded, not resolved. + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + controller.abort() + + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + expect(primaryProc.kill).toHaveBeenCalled() + expect(ignoredProc.kill).toHaveBeenCalled() + + // Late close events after cancellation must not fire anything. + primaryProc.emit('close', null, 'SIGTERM') + ignoredProc.emit('close', null, 'SIGTERM') + }) + + it('listFilesWithRg rejects without spawning when the signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + listFilesWithRg('/remote/root', [], { signal: controller.signal }) + ).rejects.toSatisfy(isFileListingCancellation) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('listFilesWithRg still resolves normally when a signal is provided but never aborted', async () => { + const primaryProc = createMockProcess() + const ignoredProc = createMockProcess() + spawnMock.mockImplementation((_cmd: string, args: string[]) => + args.includes('--no-ignore-vcs') ? ignoredProc : primaryProc + ) + + const controller = new AbortController() + const promise = listFilesWithRg('/remote/root', [], { signal: controller.signal }) + + setTimeout(() => { + ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') + primaryProc.emit('close', 0, null) + ;(ignoredProc.stdout as unknown as EventEmitter).emit('data', 'dist/out.js\n') + ignoredProc.emit('close', 0, null) + }, 5) + + await expect(promise).resolves.toEqual(['src/index.ts', 'dist/out.js']) + }) + + it('listFilesWithGit kills both git passes and rejects when aborted mid-flight', async () => { + const procs: ChildProcess[] = [] + spawnMock.mockImplementation(() => { + const proc = createMockProcess() + procs.push(proc) + return proc + }) + + const controller = new AbortController() + const promise = listFilesWithGit('/remote/root', [], { signal: controller.signal }) + + expect(procs).toHaveLength(2) + controller.abort() + + await expect(promise).rejects.toSatisfy(isFileListingCancellation) + expect(procs[0].kill).toHaveBeenCalled() + expect(procs[1].kill).toHaveBeenCalled() + }) + + it('listFilesWithGit rejects without spawning when the signal is already aborted', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + listFilesWithGit('/remote/root', [], { signal: controller.signal }) + ).rejects.toSatisfy(isFileListingCancellation) + expect(spawnMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index 99e3eb97ff9..bbe7912ee61 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -16,6 +16,7 @@ * denied on a single subdir is expected on home-dir roots) */ import { spawn, type ChildProcess } from 'node:child_process' +import { fileListingCancellationError } from '../shared/file-listing-cancellation' import { buildRgArgsForQuickOpen, normalizeQuickOpenRgLine, @@ -27,8 +28,13 @@ export const LIST_FILES_TIMEOUT_MS = 25_000 export function listFilesWithRg( rootPath: string, - excludePathPrefixes: readonly string[] = [] + excludePathPrefixes: readonly string[] = [], + options: { signal?: AbortSignal } = {} ): Promise { + const { signal } = options + if (signal?.aborted) { + return Promise.reject(fileListingCancellationError(signal)) + } return new Promise((resolve, reject) => { const files = new Set() let done = false @@ -177,7 +183,7 @@ export function listFilesWithRg( child.once('close', handleClose) }) - const killSurvivors = (): void => { + const killSurvivors = (reason: string): void => { // Why: when one pass rejects, Promise.all surfaces the error immediately // but the sibling rg keeps running up to LIST_FILES_TIMEOUT_MS. Kill it // so repeated Quick Open opens don't pile up orphan rg processes on the @@ -189,16 +195,30 @@ export function listFilesWithRg( if (entry.child.exitCode === null && entry.child.signalCode === null) { entry.child.kill() } - entry.reject(new Error('rg list canceled after sibling failure')) + entry.reject(new Error(reason)) } } + // Why: a cancelled scan (workspace switch, superseded request) must stop + // its rg children immediately instead of letting them walk the tree to + // completion and flood the relay with stdout it will only discard. + const onAbort = (): void => { + if (done) { + return + } + done = true + killSurvivors('rg list cancelled') + reject(fileListingCancellationError(signal)) + } + signal?.addEventListener('abort', onAbort, { once: true }) + Promise.all([runPass(primary), runPass(ignoredPass)]) .then(() => { if (done) { return } done = true + signal?.removeEventListener('abort', onAbort) resolve(Array.from(files)) }) .catch((err) => { @@ -206,7 +226,8 @@ export function listFilesWithRg( return } done = true - killSurvivors() + signal?.removeEventListener('abort', onAbort) + killSurvivors('rg list canceled after sibling failure') reject(err instanceof Error ? err : new Error(String(err))) }) }) diff --git a/src/relay/fs-handler-readdir-fallback.ts b/src/relay/fs-handler-readdir-fallback.ts index e49609996d1..d7c9172183b 100644 --- a/src/relay/fs-handler-readdir-fallback.ts +++ b/src/relay/fs-handler-readdir-fallback.ts @@ -23,10 +23,12 @@ import { */ export async function listFilesWithReaddir( rootPath: string, - excludePathPrefixes: readonly string[] = [] + excludePathPrefixes: readonly string[] = [], + options: { signal?: AbortSignal } = {} ): Promise { return listQuickOpenFilesWithReaddir(rootPath, { excludePathPrefixes, - budget: createQuickOpenReaddirBudget() + budget: createQuickOpenReaddirBudget(), + signal: options.signal }) } diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index c6564c51fbe..f6417b38d3c 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -17,6 +17,11 @@ import { } from './fs-handler-utils' import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' import { listFilesWithReaddir } from './fs-handler-readdir-fallback' +import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator' +import { + isFileListingCancellation, + throwIfFileListingCancelled +} from '../shared/file-listing-cancellation' import { isQuickOpenReaddirBudgetError } from '../shared/quick-open-readdir-walk' import { buildExcludePathPrefixes } from '../shared/quick-open-filter' import { buildInstallRgMessage } from './fs-handler-install-rg' @@ -82,6 +87,7 @@ export class FsHandler { private dispatcher: RelayDispatcher private watches = new Map() private streamRegistry = new RelayStreamRegistry() + private listFilesScans = new ListFilesScanCoordinator() constructor(dispatcher: RelayDispatcher, _context: RelayContext) { this.dispatcher = dispatcher @@ -114,7 +120,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.copy', (p) => this.copy(p)) this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p)) this.dispatcher.onRequest('fs.search', (p) => this.search(p)) - this.dispatcher.onRequest('fs.listFiles', (p) => this.listFiles(p)) + this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c)) this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context)) this.dispatcher.onNotification('fs.unwatch', (p, context) => this.unwatch(p, context)) @@ -331,16 +337,33 @@ export class FsHandler { }) } - private async listFiles(params: Record): Promise { + private listFiles(params: Record, context?: RequestContext): Promise { const rootPath = expandTilde(params.rootPath as string) // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees // don't get double-scanned. The shared helper validates the shape and // normalizes into root-relative prefixes; malformed input yields [] so // the request still succeeds (older apps omit the field entirely). const excludePathPrefixes = buildExcludePathPrefixes(rootPath, params.excludePaths) + // Why #7721: full-tree scans are the relay's most expensive request; the + // coordinator caps them at one per client, coalescing duplicates and + // aborting a stale scan when the workspace changes or the host cancels. + return this.listFilesScans.run({ + clientId: context?.clientId ?? 0, + key: JSON.stringify([rootPath, excludePathPrefixes]), + signal: context?.signal, + start: (signal) => this.runListFilesScan(rootPath, excludePathPrefixes, signal) + }) + } + + private async runListFilesScan( + rootPath: string, + excludePathPrefixes: string[], + signal: AbortSignal + ): Promise { const rgAvailable = await checkRgAvailable() + throwIfFileListingCancelled(signal) if (rgAvailable) { - return listFilesWithRg(rootPath, excludePathPrefixes) + return listFilesWithRg(rootPath, excludePathPrefixes, { signal }) } // Why: git ls-files only works inside git repos. Use rev-parse to detect // git ancestry — unlike checking for a local .git entry, this works from @@ -361,7 +384,7 @@ export class FsHandler { // budget errors into install-rg guidance; genuine git failures keep // their own messages. try { - return await listFilesWithGit(rootPath, excludePathPrefixes) + return await listFilesWithGit(rootPath, excludePathPrefixes, { signal }) } catch (err) { if (isQuickOpenReaddirBudgetError(err)) { throw new Error(await buildInstallRgMessage(err)) @@ -375,8 +398,13 @@ export class FsHandler { // problem, so translate the opaque cap error into actionable guidance // the user can act on directly from the error toast. try { - return await listFilesWithReaddir(rootPath, excludePathPrefixes) + return await listFilesWithReaddir(rootPath, excludePathPrefixes, { signal }) } catch (err) { + // Why: a cancelled scan is not an rg-availability problem; wrapping it + // in install-rg guidance would surface bogus advice on the client. + if (isFileListingCancellation(err)) { + throw err + } throw new Error(await buildInstallRgMessage(err)) } } diff --git a/src/relay/fs-list-files-cancel.integration.test.ts b/src/relay/fs-list-files-cancel.integration.test.ts new file mode 100644 index 00000000000..588ddb5fe51 --- /dev/null +++ b/src/relay/fs-list-files-cancel.integration.test.ts @@ -0,0 +1,209 @@ +/** + * End-to-end in-process regression tests for #7721: cancellable fs.listFiles. + * + * Wires the client-side SshChannelMultiplexer to the relay-side + * RelayDispatcher + FsHandler through an in-memory pipe (no SSH). The scan + * body is a controllable fake so the tests are deterministic: + * - aborting the client request sends rpc.cancel and stops the relay scan, + * - interactive fs.readDir is served while a scan is in flight, + * - a scan for a different workspace supersedes the previous one, + * - identical concurrent requests coalesce into one scan. + */ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import * as path from 'node:path' +import { tmpdir } from 'node:os' + +const { fakeListFiles } = vi.hoisted(() => { + type ScanRecord = { + rootPath: string + signal: AbortSignal | undefined + resolve: (files: string[]) => void + } + const scans: ScanRecord[] = [] + const fakeListFiles = Object.assign( + vi.fn( + ( + rootPath: string, + _excludes: readonly string[] = [], + options: { signal?: AbortSignal } = {} + ) => + new Promise((resolve, reject) => { + scans.push({ rootPath, signal: options.signal, resolve }) + options.signal?.addEventListener( + 'abort', + () => + // Mirror the real scanners: surface the abort reason (e.g. the + // "superseded" error) so the dispatcher reports it to the host. + reject( + options.signal?.reason instanceof Error + ? options.signal.reason + : new Error('File listing cancelled') + ), + { once: true } + ) + }) + ), + { scans } + ) + return { fakeListFiles } +}) + +vi.mock('./fs-handler-utils', async (importOriginal) => { + const original = (await importOriginal()) as Record + return { + ...original, + checkRgAvailable: () => Promise.resolve(true), + listFilesWithRg: fakeListFiles + } +}) + +import { + SshChannelMultiplexer, + type MultiplexerTransport +} from '../main/ssh/ssh-channel-multiplexer' +import { RelayDispatcher } from './dispatcher' +import { RelayContext } from './context' +import { FsHandler } from './fs-handler' +import { LIST_FILES_SUPERSEDED_MESSAGE } from './fs-list-files-scan-coordinator' + +async function flushPipe(): Promise { + // The in-memory pipe defers each hop with setImmediate; a few macrotask + // turns guarantee request/notification frames have crossed both directions. + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + +describe('Integration: cancellable fs.listFiles (#7721)', () => { + let tmpDir: string + let mux: SshChannelMultiplexer + let dispatcher: RelayDispatcher + let fsHandler: FsHandler + + beforeEach(() => { + fakeListFiles.mockClear() + fakeListFiles.scans.length = 0 + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-listfiles-cancel-')) + + let relayFeedFn: (data: Buffer) => void + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + const clientTransport: MultiplexerTransport = { + write: (data: Buffer) => { + setImmediate(() => relayFeedFn?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + onClose: () => {} + } + dispatcher = new RelayDispatcher((data: Buffer) => { + setImmediate(() => { + for (const cb of clientDataCallbacks) { + cb(data) + } + }) + }) + relayFeedFn = (data: Buffer) => dispatcher.feed(data) + fsHandler = new FsHandler(dispatcher, new RelayContext()) + mux = new SshChannelMultiplexer(clientTransport) + }) + + afterEach(async () => { + mux.dispose() + dispatcher.dispose() + fsHandler.dispose() + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('serves fs.readDir while a full-tree scan is in flight', async () => { + writeFileSync(path.join(tmpDir, 'a.txt'), 'a') + + const scanPromise = mux.request('fs.listFiles', { rootPath: '/big/workspace' }) + await flushPipe() + expect(fakeListFiles.scans).toHaveLength(1) + + // The interactive request must complete while the scan is still pending. + const entries = (await mux.request('fs.readDir', { dirPath: tmpDir })) as { name: string }[] + expect(entries.map((e) => e.name)).toEqual(['a.txt']) + + fakeListFiles.scans[0].resolve(['src/index.ts']) + await expect(scanPromise).resolves.toEqual(['src/index.ts']) + }) + + it('client abort sends rpc.cancel and stops the relay-side scan', async () => { + const controller = new AbortController() + const scanPromise = mux.request( + 'fs.listFiles', + { rootPath: '/big/workspace' }, + { signal: controller.signal } + ) + await flushPipe() + expect(fakeListFiles.scans).toHaveLength(1) + expect(fakeListFiles.scans[0].signal?.aborted).toBe(false) + + controller.abort() + await expect(scanPromise).rejects.toThrow('was cancelled') + await flushPipe() + + // The rpc.cancel notification must have aborted the scan on the relay. + expect(fakeListFiles.scans[0].signal?.aborted).toBe(true) + }) + + it('a scan for a different workspace supersedes the previous scan', async () => { + const first = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) + // Attach the rejection handler up front: the error response arrives on a + // macrotask inside flushPipe, before any later `await expect` could. + const firstOutcome = first.then( + () => null, + (err: Error) => err + ) + await flushPipe() + expect(fakeListFiles.scans).toHaveLength(1) + + const second = mux.request('fs.listFiles', { rootPath: '/workspace/b' }) + await flushPipe() + expect(fakeListFiles.scans).toHaveLength(2) + + // The stale scan is aborted and its request fails fast — it does not run + // to completion behind the new one and does not hit the 30s timeout. + expect(fakeListFiles.scans[0].signal?.aborted).toBe(true) + const firstError = await firstOutcome + expect(firstError?.message).toContain(LIST_FILES_SUPERSEDED_MESSAGE) + + fakeListFiles.scans[1].resolve(['b.ts']) + await expect(second).resolves.toEqual(['b.ts']) + }) + + it('identical concurrent requests coalesce into a single scan', async () => { + const first = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) + const second = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) + await flushPipe() + + expect(fakeListFiles.scans).toHaveLength(1) + fakeListFiles.scans[0].resolve(['shared.ts']) + await expect(first).resolves.toEqual(['shared.ts']) + await expect(second).resolves.toEqual(['shared.ts']) + }) + + it('cancelling one coalesced requester keeps the scan alive for the other', async () => { + const controller = new AbortController() + const first = mux.request( + 'fs.listFiles', + { rootPath: '/workspace/a' }, + { signal: controller.signal } + ) + const second = mux.request('fs.listFiles', { rootPath: '/workspace/a' }) + await flushPipe() + expect(fakeListFiles.scans).toHaveLength(1) + + controller.abort() + await expect(first).rejects.toThrow('was cancelled') + await flushPipe() + expect(fakeListFiles.scans[0].signal?.aborted).toBe(false) + + fakeListFiles.scans[0].resolve(['still.ts']) + await expect(second).resolves.toEqual(['still.ts']) + }) +}) diff --git a/src/relay/fs-list-files-scan-coordinator.test.ts b/src/relay/fs-list-files-scan-coordinator.test.ts new file mode 100644 index 00000000000..a142c120cdb --- /dev/null +++ b/src/relay/fs-list-files-scan-coordinator.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from 'vitest' +import { + LIST_FILES_SUPERSEDED_MESSAGE, + ListFilesScanCoordinator +} from './fs-list-files-scan-coordinator' +import { + FileListingCancelledError, + isFileListingCancellation +} from '../shared/file-listing-cancellation' + +type Deferred = { + promise: Promise + resolve: (files: string[]) => void + reject: (error: Error) => void +} + +function deferred(): Deferred { + let resolve!: (files: string[]) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +/** Scan runner that resolves/rejects on demand and rejects when aborted. */ +function controllableScan(): { + start: (signal: AbortSignal) => Promise + starts: AbortSignal[] + finish: (files: string[]) => void +} { + const starts: AbortSignal[] = [] + let current: Deferred | null = null + return { + starts, + start: (signal: AbortSignal) => { + starts.push(signal) + const scan = deferred() + current = scan + signal.addEventListener( + 'abort', + () => { + scan.reject( + signal.reason instanceof Error ? signal.reason : new FileListingCancelledError() + ) + }, + { once: true } + ) + return scan.promise + }, + finish: (files: string[]) => current?.resolve(files) + } +} + +describe('ListFilesScanCoordinator', () => { + it('coalesces same-key concurrent requests into one scan', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + + const first = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + + expect(scan.starts).toHaveLength(1) + scan.finish(['x.ts']) + await expect(first).resolves.toEqual(['x.ts']) + await expect(second).resolves.toEqual(['x.ts']) + }) + + it('supersedes a different-key scan: aborts the old one and rejects it fast', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + + const first = coordinator.run({ clientId: 1, key: 'workspace-a', start: scan.start }) + const second = coordinator.run({ clientId: 1, key: 'workspace-b', start: scan.start }) + + expect(scan.starts).toHaveLength(2) + expect(scan.starts[0].aborted).toBe(true) + await expect(first).rejects.toThrow(LIST_FILES_SUPERSEDED_MESSAGE) + await first.catch((err) => expect(isFileListingCancellation(err)).toBe(true)) + + expect(scan.starts[1].aborted).toBe(false) + scan.finish(['b.ts']) + await expect(second).resolves.toEqual(['b.ts']) + }) + + it('keeps scans from different clients independent', async () => { + const coordinator = new ListFilesScanCoordinator() + const scanA = controllableScan() + const scanB = controllableScan() + + const first = coordinator.run({ clientId: 1, key: 'workspace-a', start: scanA.start }) + const second = coordinator.run({ clientId: 2, key: 'workspace-b', start: scanB.start }) + + expect(scanA.starts[0].aborted).toBe(false) + scanA.finish(['a.ts']) + scanB.finish(['b.ts']) + await expect(first).resolves.toEqual(['a.ts']) + await expect(second).resolves.toEqual(['b.ts']) + }) + + it('aborts the scan when its only requester cancels', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + const requester = new AbortController() + + const result = coordinator.run({ + clientId: 1, + key: 'a', + signal: requester.signal, + start: scan.start + }) + + requester.abort() + expect(scan.starts[0].aborted).toBe(true) + await expect(result).rejects.toSatisfy(isFileListingCancellation) + }) + + it('keeps a coalesced scan alive while another requester still waits', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + const first = new AbortController() + const second = new AbortController() + + const firstResult = coordinator.run({ + clientId: 1, + key: 'a', + signal: first.signal, + start: scan.start + }) + const secondResult = coordinator.run({ + clientId: 1, + key: 'a', + signal: second.signal, + start: scan.start + }) + + first.abort() + expect(scan.starts[0].aborted).toBe(false) + // The aborting requester observes its own cancellation immediately, + // while the shared scan keeps running for the sibling. + await expect(firstResult).rejects.toSatisfy(isFileListingCancellation) + + second.abort() + expect(scan.starts[0].aborted).toBe(true) + await expect(secondResult).rejects.toSatisfy(isFileListingCancellation) + }) + + it('rejects an aborted coalesced requester while the sibling still resolves', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + const first = new AbortController() + + const firstResult = coordinator.run({ + clientId: 1, + key: 'a', + signal: first.signal, + start: scan.start + }) + const secondResult = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + + first.abort() + scan.finish(['kept.ts']) + + await expect(firstResult).rejects.toSatisfy(isFileListingCancellation) + await expect(secondResult).resolves.toEqual(['kept.ts']) + }) + + it('rejects immediately when the requester is already cancelled', async () => { + const coordinator = new ListFilesScanCoordinator() + const start = vi.fn() + const requester = new AbortController() + requester.abort() + + await expect( + coordinator.run({ + clientId: 1, + key: 'a', + signal: requester.signal, + start: start as unknown as (signal: AbortSignal) => Promise + }) + ).rejects.toSatisfy(isFileListingCancellation) + expect(start).not.toHaveBeenCalled() + }) + + it('starts a fresh scan for the same key after the previous one settled', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + + const first = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + scan.finish(['old.ts']) + await expect(first).resolves.toEqual(['old.ts']) + + const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + expect(scan.starts).toHaveLength(2) + scan.finish(['new.ts']) + await expect(second).resolves.toEqual(['new.ts']) + }) + + it('does not join a scan that is already aborted; starts a replacement', async () => { + const coordinator = new ListFilesScanCoordinator() + const scan = controllableScan() + const requester = new AbortController() + + const first = coordinator.run({ + clientId: 1, + key: 'a', + signal: requester.signal, + start: scan.start + }) + requester.abort() + await expect(first).rejects.toSatisfy(isFileListingCancellation) + + // The aborted entry may still be in the map until its promise settles; + // a new same-key request must get a live scan, not the dead one. + const second = coordinator.run({ clientId: 1, key: 'a', start: scan.start }) + expect(scan.starts).toHaveLength(2) + expect(scan.starts[1].aborted).toBe(false) + scan.finish(['fresh.ts']) + await expect(second).resolves.toEqual(['fresh.ts']) + }) +}) diff --git a/src/relay/fs-list-files-scan-coordinator.ts b/src/relay/fs-list-files-scan-coordinator.ts new file mode 100644 index 00000000000..6e6e30c5f0d --- /dev/null +++ b/src/relay/fs-list-files-scan-coordinator.ts @@ -0,0 +1,104 @@ +/** + * Single-flight coordinator for fs.listFiles full-tree scans (#7721). + * + * Why: rapid workspace switching used to stack N concurrent full-tree scans + * on the single-threaded relay and its one SSH channel, starving small + * interactive fs.readDir/fs.stat requests past their 30s timeout. This + * coordinator guarantees at most one scan in flight per client: + * - a request for the same root/excludes joins the in-flight scan + * (Quick Open + file-explorer filter share one scan), + * - a request for a different root supersedes it — the old scan is aborted + * and its request fails fast instead of running to completion (this also + * protects against older Orca clients that never send rpc.cancel), + * - when every joined requester cancels (rpc.cancel / client detach), the + * scan is aborted so abandoned work stops immediately. + */ +import { + FileListingCancelledError, + fileListingCancellationError +} from '../shared/file-listing-cancellation' + +export const LIST_FILES_SUPERSEDED_MESSAGE = 'File listing superseded by a newer request' + +type ScanEntry = { + key: string + controller: AbortController + promise: Promise + attachedCount: number +} + +export class ListFilesScanCoordinator { + private readonly scansByClient = new Map() + + run(opts: { + clientId: number + key: string + signal?: AbortSignal + start: (signal: AbortSignal) => Promise + }): Promise { + const { clientId, key, signal, start } = opts + if (signal?.aborted) { + return Promise.reject(fileListingCancellationError(signal)) + } + + const existing = this.scansByClient.get(clientId) + if (existing && existing.key === key && !existing.controller.signal.aborted) { + return this.attach(existing, signal) + } + if (existing) { + existing.controller.abort(new FileListingCancelledError(LIST_FILES_SUPERSEDED_MESSAGE)) + } + + const controller = new AbortController() + const entry: ScanEntry = { + key, + controller, + promise: Promise.resolve([]), + attachedCount: 0 + } + this.scansByClient.set(clientId, entry) + entry.promise = start(controller.signal).finally(() => { + if (this.scansByClient.get(clientId) === entry) { + this.scansByClient.delete(clientId) + } + }) + return this.attach(entry, signal) + } + + private attach(entry: ScanEntry, signal?: AbortSignal): Promise { + entry.attachedCount++ + if (!signal) { + return entry.promise + } + // Why: an aborting requester must see its own cancellation even though + // the shared scan keeps running (and may later resolve) for coalesced + // siblings — so each attachment gets its own promise. + return new Promise((resolve, reject) => { + let settled = false + const settle = (complete: () => void): void => { + if (settled) { + return + } + settled = true + signal.removeEventListener('abort', onAbort) + complete() + } + const onAbort = (): void => + settle(() => { + entry.attachedCount-- + const cancellation = fileListingCancellationError(signal) + // Why: only stop the shared scan when nobody is left waiting on it — + // one requester cancelling must not break a coalesced sibling. + if (entry.attachedCount <= 0) { + entry.controller.abort(cancellation) + } + reject(cancellation) + }) + signal.addEventListener('abort', onAbort, { once: true }) + entry.promise.then( + (files) => settle(() => resolve(files)), + (error) => settle(() => reject(error)) + ) + }) + } +} diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 0847c2ae8e7..07b84db43f9 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -24,7 +24,11 @@ type GitBufferSpyTarget = { } type GitSpyTarget = { - git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> + git( + args: string[], + cwd: string, + opts?: { signal?: AbortSignal } + ): Promise<{ stdout: string; stderr: string }> } function deferredRelayBuffer(content: string): { @@ -1882,6 +1886,24 @@ describe('GitHandler', () => { expect(result[0].isMainWorktree).toBe(true) }) + it('passes request cancellation to the git worktree list subprocess', async () => { + const controller = new AbortController() + const gitSpy = vi + .spyOn(handler as unknown as GitSpyTarget, 'git') + .mockRejectedValue(new Error('aborted')) + + const result = await dispatcher.callRequest( + 'git.listWorktrees', + { repoPath: tmpDir }, + { isStale: () => false, signal: controller.signal } + ) + + expect(result).toEqual([]) + expect(gitSpy).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], tmpDir, { + signal: controller.signal + }) + }) + it.skipIf(process.platform === 'win32')( 'normalizes the main worktree path for a separate-git-dir repo', async () => { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 746dbffd803..2d4bb2bb09b 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -216,7 +216,7 @@ export class GitHandler { this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p)) this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p)) - this.dispatcher.onRequest('git.listWorktrees', (p) => this.listWorktrees(p)) + this.dispatcher.onRequest('git.listWorktrees', (p, context) => this.listWorktrees(p, context)) this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) this.dispatcher.onRequest('git.worktreeIsClean', (p) => this.worktreeIsClean(p)) @@ -1263,10 +1263,12 @@ export class GitHandler { return normalized } - private async listWorktrees(params: Record) { + private async listWorktrees(params: Record, context?: RequestContext) { const repoPath = params.repoPath as string try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath) + const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, { + signal: context?.signal + }) return this.normalizeMainWorktreePath( repoPath, parseWorktreeList(stdout, { nulDelimited: true }) @@ -1280,7 +1282,9 @@ export class GitHandler { // Why: `-z` keeps newline-containing SSH worktree paths intact, but older // Git rejects it. Fall back to the original line-block parser there. try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath) + const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { + signal: context?.signal + }) return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) } catch { return [] diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 668354b03db..80bc2ba490a 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' const { execFileAsyncMock } = vi.hoisted(() => ({ execFileAsyncMock: vi.fn() @@ -38,7 +39,8 @@ function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { return [ mode, [ - `if resolved=$(command -v ${command} 2>/dev/null); then`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', 'fi' ].join('\n') @@ -77,14 +79,14 @@ describe('buildCommandLookupSpec', () => { it('falls back to sh for POSIX probes without a configured shell', () => { expect(buildCommandLookupSpec('codex', 'linux', {}, null)).toEqual({ file: '/bin/sh', - args: lookupArgs("'codex'") + args: lookupArgs('codex') }) }) it('uses the configured remote shell for POSIX probes', () => { expect(buildCommandLookupSpec('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual({ file: '/bin/zsh', - args: lookupArgs("'codex'", '-ilc') + args: lookupArgs('codex', '-ilc') }) }) @@ -93,7 +95,7 @@ describe('buildCommandLookupSpec', () => { buildCommandLookupSpec("agent'cli", 'linux', { SHELL: '/bin/bash' }, '/bin/bash') ).toEqual({ file: '/bin/bash', - args: lookupArgs("'agent'\\''cli'", '-ilc') + args: lookupArgs("agent'cli", '-ilc') }) }) }) @@ -101,8 +103,8 @@ describe('buildCommandLookupSpec', () => { describe('buildCommandLookupSpecs', () => { it('falls back to inherited PATH after a trusted configured POSIX shell', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual([ - { file: '/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/bin/zsh', args: lookupArgs('codex', '-ilc') }, + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) @@ -115,15 +117,15 @@ describe('buildCommandLookupSpecs', () => { '/opt/homebrew/bin/zsh' ) ).toEqual([ - { file: '/opt/homebrew/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/opt/homebrew/bin/zsh', args: lookupArgs('codex', '-ilc') }, + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) it('allows conservative system shell paths when account lookup is unavailable', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/bash' }, null)[0]).toEqual({ file: '/usr/bin/bash', - args: lookupArgs("'codex'", '-ilc') + args: lookupArgs('codex', '-ilc') }) }) @@ -136,14 +138,14 @@ describe('buildCommandLookupSpecs', () => { it('ignores untrusted temp shell paths even when the basename is supported', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/tmp/zsh' }, '/bin/bash')).toEqual([ - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) it('ignores untrusted home-bin shell paths even when the basename is supported', () => { expect( buildCommandLookupSpecs('codex', 'linux', { SHELL: '/home/test/bin/bash' }, '/bin/bash') - ).toEqual([{ file: '/bin/sh', args: lookupArgs("'codex'") }]) + ).toEqual([{ file: '/bin/sh', args: lookupArgs('codex') }]) }) }) @@ -160,17 +162,12 @@ describe('isCommandOnPathForRelay', () => { accountLoginShell: '/bin/zsh' }) ).resolves.toBe(true) - expect(execFileAsyncMock).toHaveBeenNthCalledWith( - 1, - '/bin/zsh', - lookupArgs("'codex'", '-ilc'), - { - encoding: 'utf-8', - env: expect.objectContaining({ SHELL: '/bin/zsh' }), - timeout: 5000 - } - ) - expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs("'codex'"), { + expect(execFileAsyncMock).toHaveBeenNthCalledWith(1, '/bin/zsh', lookupArgs('codex', '-ilc'), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/bin/zsh' }), + timeout: 5000 + }) + expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs('codex'), { encoding: 'utf-8', env: expect.objectContaining({ SHELL: '/bin/zsh' }), timeout: 5000 @@ -203,7 +200,7 @@ describe('isCommandOnPathForRelay', () => { }) ).resolves.toBe(true) expect(execFileAsyncMock).toHaveBeenCalledTimes(1) - expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs("'codex'"), { + expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs('codex'), { encoding: 'utf-8', env: expect.objectContaining({ SHELL: '/tmp/zsh' }), timeout: 5000 @@ -236,6 +233,86 @@ describe('hasAbsoluteCommandPath', () => { }) describe('PreflightHandler', () => { + it('honors required commands when reporting detected agents', async () => { + execFileAsyncMock.mockImplementation(async (_file, args) => { + const script = String(args[1]) + if (script.includes("'orca'")) { + return { stdout: '__ORCA_AGENT_PATH__/relay/path/orca\n' } + } + throw new Error('not found') + }) + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + + new PreflightHandler(dispatcher as never) + + const handler = requestHandlers.get('preflight.detectAgents') + expect(handler).toBeDefined() + await expect( + handler!({ + commands: [ + { id: 'claude-agent-teams', cmd: 'orca', requiredCommands: ['claude'] }, + { id: 'claude', cmd: 'claude' } + ] + }) + ).resolves.toEqual({ agents: [] }) + }) + + it('does not report platform-unsupported agents on native Windows SSH hosts', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + execFileAsyncMock.mockImplementation(async (_file, args) => { + if (String(args[0]) === 'claude') { + return { stdout: 'C:\\Users\\test\\AppData\\Roaming\\npm\\claude.cmd\r\n' } + } + if (String(args[0]) === 'orca') { + return { stdout: 'C:\\Program Files\\Orca\\orca.cmd\r\n' } + } + throw new Error('not found') + }) + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + + try { + new PreflightHandler(dispatcher as never) + const handler = requestHandlers.get('preflight.detectAgents') + expect(handler).toBeDefined() + await expect( + handler!({ + commands: [ + { + id: 'claude-agent-teams', + cmd: 'orca', + requiredCommands: ['claude'], + unsupportedRuntimes: ['win32'] + }, + { id: 'claude', cmd: 'claude' } + ] + }) + ).resolves.toEqual({ agents: ['claude'] }) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('reports remote Windows shell capabilities through the SSH preflight path', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 3eff8121368..101c796137c 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -7,6 +7,7 @@ import { buildRelayCommandEnv } from './relay-command-env' import { isPwshAvailable } from '../main/pwsh' import { isWslAvailable, listWslDistros } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' +import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' const execFileAsync = promisify(execFile) @@ -22,6 +23,15 @@ type RelayCommandLookupOptions = { accountLoginShell?: string | null } +type AgentDetectionRuntime = NodeJS.Platform | 'wsl' + +type AgentDetectionCommand = { + id: string + cmd: string + requiredCommands?: readonly string[] + unsupportedRuntimes?: readonly AgentDetectionRuntime[] +} + const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish']) const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin']) const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__' @@ -45,19 +55,42 @@ export class PreflightHandler { // on the relay side. This keeps the relay bundle minimal and makes the protocol // self-describing — the relay doesn't need to know the agent catalog. private async detectAgents(params: Record): Promise<{ agents: string[] }> { - const commands = params.commands as { id: string; cmd: string }[] + const commands = params.commands as AgentDetectionCommand[] if (!Array.isArray(commands)) { return { agents: [] } } + const probeCommands = [ + ...new Set( + commands + .filter((command) => !isDetectionUnsupportedInRuntime(command, process.platform)) + .flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])]) + ) + ] const results = await Promise.all( - commands.map(async ({ id, cmd }) => ({ - id, + probeCommands.map(async (cmd) => ({ + cmd, installed: await this.isCommandOnPath(cmd) })) ) + const foundCommands = new Set( + results.filter((result) => result.installed).map(({ cmd }) => cmd) + ) - return { agents: [...new Set(results.filter((r) => r.installed).map((r) => r.id))] } + return { + agents: [ + ...new Set( + commands + .filter( + (command) => + !isDetectionUnsupportedInRuntime(command, process.platform) && + foundCommands.has(command.cmd) && + (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) + ) + .map(({ id }) => id) + ) + ] + } } private async detectWindowsTerminalCapabilities(): Promise<{ @@ -91,6 +124,13 @@ export class PreflightHandler { } } +function isDetectionUnsupportedInRuntime( + command: AgentDetectionCommand, + runtime: AgentDetectionRuntime +): boolean { + return command.unsupportedRuntimes?.includes(runtime) === true +} + export function buildCommandLookupSpec( command: string, platform: NodeJS.Platform, @@ -180,9 +220,10 @@ function buildPosixCommandLookupSpec(command: string, shell: string): CommandLoo } function buildShCommandLookupScript(command: string): string { - const quotedCommand = shellQuote(command) + // Why: login shells may define aliases or functions that mask the PATH executable. return [ - `if resolved=$(command -v ${quotedCommand} 2>/dev/null); then`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, 'fi' ].join('\n') diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index aa35e99571a..7d5198e87a9 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -14,7 +14,10 @@ import { const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ mockPtySpawn: vi.fn(), mockPtyInstance: { - pid: 12345, + // Why: attach now proves the backing pid is alive before replaying, so the + // default managed PTY must report a live pid. Reuse the test runner's own + // pid — always alive — so unrelated attach tests are not seen as dead. + pid: process.pid, onData: vi.fn(), onExit: vi.fn(), write: vi.fn(), @@ -609,6 +612,71 @@ describe('PtyHandler', () => { } ) + it('reports not-found and reaps a reattach whose backing shell is dead', async () => { + // Why: a lingering managed entry whose child died without an onExit would + // otherwise attach-succeed with an empty replay and strand the pane on a + // black shell. A provably-dead pid must surface as not-found so the SSH + // provider maps it to SSH_SESSION_EXPIRED and the pane respawns fresh. + let onExitCb: ((evt: { exitCode: number }) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) + const exits: { id: string; paneKey?: string }[] = [] + handler.setExitListener((evt) => exits.push(evt)) + + await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dead:0' } }) + expect(handler.activePtyCount).toBe(1) + expect(onExitCb).toBeDefined() + + const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(false) + try { + await expect( + dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true }) + ).rejects.toThrow('PTY "pty-1" not found') + } finally { + aliveSpy.mockRestore() + } + + // The stale entry is reaped: cache-eviction observers fire and the map slot + // is freed so a later attach also cleanly reports not-found. + expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-dead:0' }]) + expect(handler.activePtyCount).toBe(0) + await expect( + dispatcher.callRequest('pty.attach', { id: 'pty-1', suppressReplayNotification: true }) + ).rejects.toThrow('PTY "pty-1" not found') + }) + + it('replays for a reattach whose backing shell is still alive', async () => { + // Guard the other direction: a live pid must never be reaped, so a quiet + // but live session still returns its buffered replay on reattach. + let dataCallback: ((data: string) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallback = cb + }), + onExit: vi.fn() + }) + await dispatcher.callRequest('pty.spawn', {}) + dataCallback?.('prompt$ ') + + const aliveSpy = vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) + try { + const result = await dispatcher.callRequest('pty.attach', { + id: 'pty-1', + suppressReplayNotification: true + }) + expect(result).toEqual({ replay: 'prompt$ ' }) + } finally { + aliveSpy.mockRestore() + } + expect(handler.activePtyCount).toBe(1) + }) + it('terminates spawned PTY when request becomes stale before response', async () => { const killSpy = vi.fn() const term = { ...mockPtyInstance, kill: killSpy, onData: vi.fn(), onExit: vi.fn() } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 7e94eb9a74f..3a812415909 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -10,6 +10,7 @@ import { resolveProcessCwd, processHasChildren, getForegroundProcessName, + isProcessAlive, listShellProfiles } from './pty-shell-utils' import { getRelayShellLaunchConfig } from './pty-shell-launch' @@ -692,6 +693,22 @@ export class PtyHandler { throw new Error(`PTY "${id}" not found`) } + // Why: a reattach can arrive for a relay PTY whose backing shell already + // died without node-pty delivering onExit (e.g. the child was reaped out of + // band while the SSH channel was down). The map entry lingers, so attach + // would otherwise "succeed" with an empty replay and strand the reattached + // pane on a black, unresponsive shell. Prove liveness here; if the pid is + // provably gone, reap the stale entry and report not-found so the caller + // drops the dead lease and spawns fresh — the same recovery path an expired + // grace window already takes. + if (managed.pty.pid && !isProcessAlive(managed.pty.pid)) { + this.notifyExitListener(managed) + disposeManagedPty(managed) + this.ptys.delete(id) + this.clearPtyFlowState(id) + throw new Error(`PTY "${id}" not found`) + } + // Why: PTY ids are a per-relay-process counter (pty-1, pty-2, …). When the // relay changes generation — an app update deploys a new content-hashed // relay dir, or a grace-expired relay restarts — the counter resets, so an diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts index e2b7adcadd0..544e1509c12 100644 --- a/src/relay/pty-shell-utils.test.ts +++ b/src/relay/pty-shell-utils.test.ts @@ -11,6 +11,7 @@ vi.mock('child_process', () => ({ import { resetProcessTableSnapshotForTests } from '../shared/process-table-snapshot' import { getForegroundProcessName, + isProcessAlive, resolveDefaultCwd, resolveWindowsDefaultShell } from './pty-shell-utils' @@ -51,6 +52,40 @@ beforeEach(() => { resetProcessTableSnapshotForTests() }) +describe('isProcessAlive', () => { + it('reports the test runner process as alive', () => { + expect(isProcessAlive(process.pid)).toBe(true) + }) + + it('reports a process as dead ONLY on ESRCH', () => { + // Why: attach() reaps a lingering managed PTY based on this, so a false + // positive would kill a live remote shell. Only "no such process" counts. + const spy = vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('no such process') as NodeJS.ErrnoException + err.code = 'ESRCH' + throw err + }) + try { + expect(isProcessAlive(2147483646)).toBe(false) + } finally { + spy.mockRestore() + } + }) + + it('treats an unsignalable process (EPERM) as alive', () => { + const spy = vi.spyOn(process, 'kill').mockImplementation(() => { + const err = new Error('operation not permitted') as NodeJS.ErrnoException + err.code = 'EPERM' + throw err + }) + try { + expect(isProcessAlive(1)).toBe(true) + } finally { + spy.mockRestore() + } + }) +}) + describe('resolveWindowsDefaultShell', () => { it('uses an existing SHELL override when one is provided', () => { expect( diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index 6a9c4507372..87035c9510d 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -147,6 +147,19 @@ export async function processHasChildren(pid: number): Promise { } } +// Why: signal 0 probes existence without delivering a signal. Only ESRCH ("no +// such process") proves the pid is gone; EPERM means it exists but is +// unsignalable, so treat every non-ESRCH outcome as alive. Kept conservative so +// a liveness check can only ever declare a *provably* dead process dead. +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (err) { + return (err as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + function collectDescendants( rows: ProcessTableRow[], rootPid: number diff --git a/src/relay/relay-diagnostic-log.test.ts b/src/relay/relay-diagnostic-log.test.ts new file mode 100644 index 00000000000..38adbcb7669 --- /dev/null +++ b/src/relay/relay-diagnostic-log.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' +import { relayLogLine } from './relay-diagnostic-log' + +describe('relayLogLine', () => { + it('prefixes each log line with an ISO timestamp', () => { + const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + try { + relayLogLine('[relay] Grace started (stdin ended)') + + expect(writeSpy).toHaveBeenCalledTimes(1) + const line = writeSpy.mock.calls[0][0] as string + // Why: relay.log correlation depends on a grep-stable + // " " shape (#7773). + expect(line).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[relay\] Grace started \(stdin ended\)\n$/ + ) + } finally { + writeSpy.mockRestore() + } + }) +}) diff --git a/src/relay/relay-diagnostic-log.ts b/src/relay/relay-diagnostic-log.ts new file mode 100644 index 00000000000..a42106101d9 --- /dev/null +++ b/src/relay/relay-diagnostic-log.ts @@ -0,0 +1,7 @@ +// Why: the relay daemon's stderr is redirected to relay.log on the remote +// host. Without timestamps, reconnect flaps in that log cannot be correlated +// with user activity or sleep/wake windows during diagnosis (#7773). Keep the +// format grep-stable: ISO timestamp, single space, then the original line. +export function relayLogLine(message: string): void { + process.stderr.write(`${new Date().toISOString()} ${message}\n`) +} diff --git a/src/relay/relay-handshake.ts b/src/relay/relay-handshake.ts index 6db263bcc92..fb64c57ec16 100644 --- a/src/relay/relay-handshake.ts +++ b/src/relay/relay-handshake.ts @@ -17,6 +17,7 @@ import { parseHandshakeMessage, type DecodedFrame } from './protocol' +import { relayLogLine } from './relay-diagnostic-log' // Why: a unique exit code reserved for the wire-level version-mismatch terminal // condition. The client (waitForSentinel + ssh.ts) maps this exit code to a @@ -132,22 +133,18 @@ function handleDaemonHandshakeFrame( try { msg = parseHandshakeMessage(frame.payload) } catch (err) { - process.stderr.write( - `[relay] Could not parse handshake: ${(err as Error).message}; closing socket\n` - ) + relayLogLine(`[relay] Could not parse handshake: ${(err as Error).message}; closing socket`) sock.destroy() return false } if (msg.type !== 'orca-relay-handshake') { - process.stderr.write( - `[relay] Unexpected handshake type from client: ${msg.type}; closing socket\n` - ) + relayLogLine(`[relay] Unexpected handshake type from client: ${msg.type}; closing socket`) sock.destroy() return false } if (msg.version !== launchVersion) { - process.stderr.write( - `[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket\n` + relayLogLine( + `[relay] Handshake mismatch: own=${launchVersion}, client=${msg.version}; closing socket` ) try { sock.write( diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 99225227903..65f90e88628 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -59,6 +59,7 @@ import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugi import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' import { pickRemoteCliEnv } from './remote-cli-env' +import { relayLogLine } from './relay-diagnostic-log' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' @@ -358,13 +359,13 @@ async function main(): Promise { // would risk silent data corruption or zombie PTYs. We log for diagnostics // and then exit so the client can detect the disconnect and reconnect cleanly. process.on('uncaughtException', (err) => { - process.stderr.write(`[relay] Uncaught exception: ${err.message}\n${err.stack}\n`) + relayLogLine(`[relay] Uncaught exception: ${err.message}\n${err.stack}`) cleanupOwnedSocket() process.exit(1) }) process.on('unhandledRejection', (reason) => { - process.stderr.write(`[relay] Unhandled rejection: ${reason}\n`) + relayLogLine(`[relay] Unhandled rejection: ${reason}`) }) // Why: stdoutAlive tracks whether process.stdout is still writable. @@ -529,8 +530,8 @@ async function main(): Promise { try { await hookServer.start({ publishEndpoint: false }) } catch (err) { - process.stderr.write( - `[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}\n` + relayLogLine( + `[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}` ) } @@ -688,7 +689,7 @@ async function main(): Promise { function cancelGrace(reason: string): void { if (ptyHandler.graceTimerActive) { - process.stderr.write(`[relay] Grace canceled: ${reason}\n`) + relayLogLine(`[relay] Grace canceled: ${reason}`) } graceDeadlineAt = null graceReason = null @@ -704,8 +705,8 @@ async function main(): Promise { hasAcceptedSocketClient = true acceptedSocketConnections++ - process.stderr.write( - `[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})\n` + relayLogLine( + `[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})` ) cancelGrace('socket client accepted') @@ -779,7 +780,7 @@ async function main(): Promise { if (clientId !== undefined) { dispatcher.detachClient(clientId) } - process.stderr.write(`[relay] Socket client closed (clients=${socketClients.size})\n`) + relayLogLine(`[relay] Socket client closed (clients=${socketClients.size})`) if (!stdoutAlive && socketClients.size === 0) { startGrace('socket client closed') } @@ -821,9 +822,9 @@ async function main(): Promise { ownsSocketPath = true ownedSocketIdentity = readSocketIdentity(sockPath) server.on('error', (err) => { - process.stderr.write(`[relay] Socket server error: ${err.message}\n`) + relayLogLine(`[relay] Socket server error: ${err.message}`) }) - process.stderr.write(`[relay] Socket server listening: ${sockPath}\n`) + relayLogLine(`[relay] Socket server listening: ${sockPath}`) resolve() } @@ -831,11 +832,11 @@ async function main(): Promise { removeStartupListeners() restoreUmask() if (err.code === 'EADDRINUSE') { - process.stderr.write( - `[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.\n` + relayLogLine( + `[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.` ) } else { - process.stderr.write(`[relay] Socket server error before listen: ${err.message}\n`) + relayLogLine(`[relay] Socket server error before listen: ${err.message}`) } reject(err) } @@ -903,9 +904,7 @@ async function main(): Promise { failInitial(err) return } - process.stderr.write( - `[relay] Removed stale socket at ${sockPath} and retrying listen\n` - ) + relayLogLine(`[relay] Removed stale socket at ${sockPath} and retrying listen`) removeStartupListeners() listenForStartupError(failInitial) }) @@ -958,11 +957,11 @@ async function main(): Promise { : graceTimeMs graceDeadlineAt = timeoutMs === 0 ? null : Date.now() + timeoutMs graceReason = reason - process.stderr.write( - `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}\n` + relayLogLine( + `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, startupEmptyDetached=${startupEmptyDetached}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}` ) ptyHandler.startGraceTimer(() => { - process.stderr.write(`[relay] Grace expired (${reason}); shutting down\n`) + relayLogLine(`[relay] Grace expired (${reason}); shutting down`) shutdown() }, timeoutMs) } @@ -1006,8 +1005,8 @@ async function main(): Promise { } function shutdown(): void { - process.stderr.write( - `[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}\n` + relayLogLine( + `[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}` ) graceDeadlineAt = null graceReason = null @@ -1034,10 +1033,10 @@ async function main(): Promise { // window — a reconnecting client can then bridge to the live relay via // --connect and reattach to the still-running PTY sessions. process.on('SIGHUP', () => { - process.stderr.write('[relay] Received SIGHUP (SSH session dropped), ignoring\n') + relayLogLine('[relay] Received SIGHUP (SSH session dropped), ignoring') }) process.on('exit', (code) => { - process.stderr.write(`[relay] Process exiting with code ${code}\n`) + relayLogLine(`[relay] Process exiting with code ${code}`) }) // Signal readiness to the client — the client watches for this exact @@ -1059,8 +1058,8 @@ function cleanupSocket(sockPath: string): void { } void main().catch((err) => { - process.stderr.write( - `[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n` + relayLogLine( + `[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}` ) process.exit(1) }) diff --git a/src/relay/remote-cli-env.test.ts b/src/relay/remote-cli-env.test.ts index 844055a832c..1546230a5dd 100644 --- a/src/relay/remote-cli-env.test.ts +++ b/src/relay/remote-cli-env.test.ts @@ -7,6 +7,8 @@ describe('pickRemoteCliEnv', () => { pickRemoteCliEnv({ ORCA_TERMINAL_HANDLE: 'term_ssh', ORCA_WORKTREE_ID: 'repo::remote', + ORCA_PANE_KEY: 'pane-1', + ORCA_WORKSPACE_ID: 'workspace-1', ORCA_USER_DATA_PATH: '/tmp/orca', PATH: '/usr/bin', SECRET_TOKEN: 'nope' @@ -14,6 +16,8 @@ describe('pickRemoteCliEnv', () => { ).toEqual({ ORCA_TERMINAL_HANDLE: 'term_ssh', ORCA_WORKTREE_ID: 'repo::remote', + ORCA_PANE_KEY: 'pane-1', + ORCA_WORKSPACE_ID: 'workspace-1', ORCA_USER_DATA_PATH: '/tmp/orca', PATH: '/usr/bin' }) diff --git a/src/relay/remote-cli-env.ts b/src/relay/remote-cli-env.ts index 360b6074b9a..d8bc800c660 100644 --- a/src/relay/remote-cli-env.ts +++ b/src/relay/remote-cli-env.ts @@ -3,6 +3,8 @@ export function pickRemoteCliEnv(env: NodeJS.ProcessEnv): Record for (const key of [ 'ORCA_TERMINAL_HANDLE', 'ORCA_WORKTREE_ID', + 'ORCA_PANE_KEY', + 'ORCA_WORKSPACE_ID', 'ORCA_USER_DATA_PATH', 'PATH', 'Path' diff --git a/src/relay/remote-cli-stdin.test.ts b/src/relay/remote-cli-stdin.test.ts index 62fde790a2f..494ad94e5d5 100644 --- a/src/relay/remote-cli-stdin.test.ts +++ b/src/relay/remote-cli-stdin.test.ts @@ -15,4 +15,16 @@ describe('shouldReadRemoteCliStdin', () => { false ) }) + + it('reads stdin for *-stdin payload flags bridged to the full host CLI', () => { + expect(shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text-stdin'])).toBe( + true + ) + expect(shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text', 'hi'])).toBe( + false + ) + expect( + shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text-stdin', '--help']) + ).toBe(false) + }) }) diff --git a/src/relay/remote-cli-stdin.ts b/src/relay/remote-cli-stdin.ts index 23ca208978e..f9ab8c56da8 100644 --- a/src/relay/remote-cli-stdin.ts +++ b/src/relay/remote-cli-stdin.ts @@ -2,6 +2,12 @@ export function shouldReadRemoteCliStdin(argv: string[]): boolean { if (argv.includes('--help') || argv.includes('-h')) { return false } + // Why: computer-use style flags (`--text-stdin`, ...) declare a stdin + // payload directly in the flag name; the full-CLI bridge (#7716) must + // forward stdin for them the same way local shells provide it. + if (argv.some((part) => /^--[a-z0-9][a-z0-9-]*-stdin(?:=|$)/.test(part))) { + return true + } const commandPath = parseRemoteCliCommandPath(argv) if (!isLinearBodyWriteCommand(commandPath)) { return false diff --git a/src/relay/remote-cli-timeout.test.ts b/src/relay/remote-cli-timeout.test.ts index 58a62ac5a19..c09a0ccfeaa 100644 --- a/src/relay/remote-cli-timeout.test.ts +++ b/src/relay/remote-cli-timeout.test.ts @@ -2,58 +2,64 @@ import { describe, expect, it } from 'vitest' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' describe('remoteCliRequestTimeoutMs', () => { - it('extends SSH remote CLI timeout for Linear issue context reads', () => { + it('gives Linear issue context reads the general CLI budget', () => { expect( remoteCliRequestTimeoutMs({ argv: ['linear', 'issue', 'ENG-123', '--json'] }) - ).toBe(120_000) + ).toBe(300_000) }) - it('extends the timeout when global flags appear before the Linear command', () => { + it('gives ordinary remote CLI requests the general CLI budget instead of the 30s relay default', () => { + // Why: mutation commands bridged through the full host CLI (worktree + // create, orchestration dispatch, ...) can legitimately exceed 30s. + expect(remoteCliRequestTimeoutMs({ argv: ['status'] })).toBe(300_000) + expect(remoteCliRequestTimeoutMs({ argv: ['worktree', 'create', '--repo', 'r'] })).toBe(300_000) + }) + + it('extends the timeout for wait-style commands', () => { + expect(remoteCliRequestTimeoutMs({ argv: ['terminal', 'wait', '--for', 'exit'] })).toBe(600_000) expect( remoteCliRequestTimeoutMs({ - argv: ['--json', 'linear', 'issue', 'ENG-123', '--workspace', 'workspace-1', '--full'] + argv: ['orchestration', 'check', '--wait', '--json'] }) - ).toBe(120_000) - }) - - it('extends SSH remote CLI timeout for Linear search', () => { + ).toBe(600_000) expect( remoteCliRequestTimeoutMs({ - argv: ['linear', 'search', 'auth', '--limit', '1'] + argv: ['orchestration', 'ask', '--to', 'term_x', '--question', 'ok?'] }) - ).toBe(120_000) + ).toBe(600_000) }) - it('extends the timeout when boolean flags appear between Linear and search', () => { + it('extends past an explicit --timeout-ms waiter budget', () => { expect( remoteCliRequestTimeoutMs({ - argv: ['linear', '--json', 'search', 'auth', '--limit', '1'] + argv: ['terminal', 'wait', '--for', 'exit', '--timeout-ms', '1800000'] }) - ).toBe(120_000) - }) - - it('extends the timeout when boolean flags appear between Linear and issue', () => { + ).toBe(1_860_000) expect( remoteCliRequestTimeoutMs({ - argv: ['linear', '--json', 'issue', 'ENG-123', '--full'] + argv: ['orchestration', 'check', '--wait', '--timeout-ms=1800000'] }) - ).toBe(120_000) + ).toBe(1_860_000) }) - it('extends SSH remote CLI timeout for Linear writes', () => { - for (const argv of [ - ['linear', 'status', 'set', 'ENG-123', '--to', 'Done'], - ['linear', 'comment', 'add', 'ENG-123', '--body', 'Done'], - ['linear', 'attach', 'ENG-123', '--url', 'https://example.invalid/review'], - ['linear', 'create', '--team', 'ENG', '--title', 'Follow up'] - ]) { - expect(remoteCliRequestTimeoutMs({ argv })).toBe(120_000) - } + it('keeps the wait base budget when --timeout-ms is small', () => { + expect( + remoteCliRequestTimeoutMs({ + argv: ['terminal', 'wait', '--for', 'exit', '--timeout-ms', '5000'] + }) + ).toBe(600_000) }) - it('keeps ordinary remote CLI requests on the relay default timeout', () => { - expect(remoteCliRequestTimeoutMs({ argv: ['status'] })).toBeUndefined() + it('does not treat a flag value named wait as a command path element', () => { + expect(remoteCliRequestTimeoutMs({ argv: ['terminal', 'read', '--terminal', 'wait'] })).toBe( + 300_000 + ) + }) + + it('falls back to the relay default for malformed argv', () => { + expect(remoteCliRequestTimeoutMs({ argv: 'status' })).toBeUndefined() + expect(remoteCliRequestTimeoutMs({})).toBeUndefined() }) }) diff --git a/src/relay/remote-cli-timeout.ts b/src/relay/remote-cli-timeout.ts index a7f73e0bf6c..b28ef0ee313 100644 --- a/src/relay/remote-cli-timeout.ts +++ b/src/relay/remote-cli-timeout.ts @@ -1,4 +1,13 @@ -const LINEAR_ISSUE_CONTEXT_TIMEOUT_MS = 120_000 +// Why: the host bridges the full Orca CLI over the relay (#7716), so mutation +// commands (worktree create, orchestration dispatch, Linear writes, ...) can +// legitimately outlive the relay's 30 s default request timeout. Long-poll +// commands carry their waiter budget in --timeout-ms; extend past it so the +// host-side CLI produces its proper timeout error instead of the relay +// cutting the request short. +const REMOTE_CLI_DEFAULT_TIMEOUT_MS = 5 * 60_000 +const REMOTE_CLI_WAIT_TIMEOUT_MS = 10 * 60_000 +const REMOTE_CLI_TIMEOUT_GRACE_MS = 60_000 + const REMOTE_TIMEOUT_BOOLEAN_FLAGS = new Set([ 'all', 'attachments', @@ -15,30 +24,54 @@ const REMOTE_TIMEOUT_BOOLEAN_FLAGS = new Set([ ]) export function remoteCliRequestTimeoutMs(params: Record): number | undefined { - return isLinearCliRequest(params) ? LINEAR_ISSUE_CONTEXT_TIMEOUT_MS : undefined + const argv = getStringArgv(params) + if (!argv) { + return undefined + } + const base = isWaitStyleCliRequest(argv) + ? REMOTE_CLI_WAIT_TIMEOUT_MS + : REMOTE_CLI_DEFAULT_TIMEOUT_MS + const explicit = parseTimeoutMsFlag(argv) + if (explicit !== null && explicit > 0) { + return Math.max(base, explicit + REMOTE_CLI_TIMEOUT_GRACE_MS) + } + return base } -function isLinearCliRequest(params: Record): boolean { - const argv = params.argv - if (!Array.isArray(argv) || !argv.every((part) => typeof part === 'string')) { - return false +function isWaitStyleCliRequest(argv: string[]): boolean { + if (argv.includes('--wait')) { + return true } const commandPath = parseRemoteCommandPath(argv) - return commandPath.some( - (part, index) => - part === 'linear' && isExtendedLinearCliCommand(commandPath.slice(index + 1, index + 4)) + return ( + (commandPath[0] === 'terminal' && commandPath[1] === 'wait') || + (commandPath[0] === 'orchestration' && commandPath[1] === 'ask') ) } -function isExtendedLinearCliCommand(command: string[]): boolean { - const [first, second] = command - if (first === 'issue' || first === 'search' || first === 'attach' || first === 'create') { - return true +function parseTimeoutMsFlag(argv: string[]): number | null { + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + let raw: string | undefined + if (token === '--timeout-ms') { + raw = argv[index + 1] + } else if (token.startsWith('--timeout-ms=')) { + raw = token.slice('--timeout-ms='.length) + } else { + continue + } + const parsed = raw === undefined ? Number.NaN : Number(raw) + return Number.isFinite(parsed) ? parsed : null } - if (first === 'status' && second === 'set') { - return true + return null +} + +function getStringArgv(params: Record): string[] | null { + const argv = params.argv + if (!Array.isArray(argv) || !argv.every((part) => typeof part === 'string')) { + return null } - return first === 'comment' && second === 'add' + return argv } function parseRemoteCommandPath(argv: string[]): string[] { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cb881ea97d9..ce443c55454 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,6 +7,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, type SetStateAction } from 'react' import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' @@ -64,6 +65,7 @@ import { onOnboardingReopened } from './components/onboarding/show-onboarding-ev import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding' import { MarkdownTemplatePicker } from './components/editor/MarkdownTemplatePicker' import { FloatingTerminalToggleButton } from './components/floating-terminal/FloatingTerminalToggleButton' +import { OrcaProfileSwitcher } from './components/orca-profiles/OrcaProfileSwitcher' import { TOGGLE_FLOATING_TERMINAL_EVENT, requestFloatingTerminalOpenMaximized @@ -169,6 +171,10 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' import { translate } from '@/i18n/i18n' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' +import { + hasRequestedBackgroundTerminalWorktreeMount, + subscribeBackgroundTerminalWorktreeMountRequests +} from './components/terminal/background-terminal-worktree-mount' const isMac = navigator.userAgent.includes('Mac') const isWindows = !isMac && navigator.userAgent.includes('Windows') @@ -418,6 +424,7 @@ function App(): React.JSX.Element { fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts, fetchAllWorktrees: s.fetchAllWorktrees, fetchWorktreeLineage: s.fetchWorktreeLineage, + fetchOrcaProfiles: s.fetchOrcaProfiles, fetchSettings: s.fetchSettings, fetchKeybindings: s.fetchKeybindings, initGitHubCache: s.initGitHubCache, @@ -479,6 +486,11 @@ function App(): React.JSX.Element { const worktreeSidebarScrollAnchorRef = useRef(null) const floatingVisibleTabCount = useAppStore(selectFloatingVisibleTabCount) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const backgroundTerminalMountRequested = useSyncExternalStore( + subscribeBackgroundTerminalWorktreeMountRequests, + hasRequestedBackgroundTerminalWorktreeMount, + hasRequestedBackgroundTerminalWorktreeMount + ) const keybindings = useAppStore((s) => s.keybindings) const updateStatus = useAppStore((s) => s.updateStatus) const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) @@ -495,14 +507,16 @@ function App(): React.JSX.Element { floatingTerminalEnabled && (floatingTerminalTriggerLocation === 'floating-button' || !statusBarVisible) const hasMountedTerminalWorkbenchRef = useRef(false) - if (activeWorktreeId !== null) { + if (activeWorktreeId !== null || backgroundTerminalMountRequested) { hasMountedTerminalWorkbenchRef.current = true } // Why: skip the terminal bundle on the no-workspace landing path, but once a // workspace has mounted, keep Terminal-owned hidden panes alive through sleep // and shutdown transitions where activeWorktreeId can briefly become null. const shouldMountTerminalWorkbench = - activeWorktreeId !== null || hasMountedTerminalWorkbenchRef.current + activeWorktreeId !== null || + backgroundTerminalMountRequested || + hasMountedTerminalWorkbenchRef.current // Why: visible worktree creation owns its faux tab strip from start to finish; // the previous workspace must stay mounted for retention without rendering // real chrome. @@ -864,6 +878,10 @@ function App(): React.JSX.Element { const startupStartedAt = performance.now() logRendererStartupDiagnostic('startup-chain-start') try { + // Why: profile state only feeds the switcher and the add-project + // advisory — nothing in the hydration chain reads it synchronously, + // so it must not add a serial IPC round-trip before fetchSettings. + void actions.fetchOrcaProfiles() // Why: repo/worktree hydration routes through settings.activeRuntimeEnvironmentId. // Load settings first so a persisted remote runtime does not boot against // the local filesystem and then hydrate stale local workspace state. @@ -1494,6 +1512,8 @@ function App(): React.JSX.Element { // Why: suppress right sidebar controls on full-page navigation surfaces // since those surfaces intentionally own the full content area. const showRightSidebarControls = !creationLayoutActive && canShowRightSidebarForView(activeView) + const showProfileSwitcherInSidebarFooter = showSidebar && sidebarOpen + const showProfileSwitcherInTopRight = !showProfileSwitcherInSidebarFooter const handleToggleExpand = (): void => { if (!effectiveActiveTabId) { @@ -2135,6 +2155,7 @@ function App(): React.JSX.Element { )} + {showProfileSwitcherInTopRight ? : null} {/* Why: when the right sidebar is open, its own header renders an identical close button — hide this copy so only one is visible at a time. */} @@ -2144,6 +2165,25 @@ function App(): React.JSX.Element { {hasCustomTitleBar &&
} ) + const workspaceProfileSwitcher = + showProfileSwitcherInTopRight && + workspaceChromeActive && + leftTitlebarChromeLayout.shouldMount && + !stackedSidebarOpen ? ( +
+ +
+ ) : null return (
)} + {workspaceProfileSwitcher}
{shouldMountTerminalWorkbench ? (
{ expect(source).not.toContain("from './components/Terminal'") expect(source).toContain('const hasMountedTerminalWorkbenchRef = useRef(false)') expect(source).toContain('hasMountedTerminalWorkbenchRef.current = true') - expect(source).toContain('activeWorktreeId !== null || hasMountedTerminalWorkbenchRef.current') + expect(source).toContain('activeWorktreeId !== null || backgroundTerminalMountRequested') + expect(source).toContain('backgroundTerminalMountRequested ||') + expect(source).toContain('hasMountedTerminalWorkbenchRef.current') expect(source).toContain('shouldMountTerminalWorkbench ?') }) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index f028b641384..a85d7fb6c43 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -101,6 +101,8 @@ --color-status-success: var(--status-success); --color-status-success-background: var(--status-success-background); --color-status-success-border: var(--status-success-border); + --color-tab-group-split-divider: var(--tab-group-split-divider); + --color-tab-group-split-divider-strong: var(--tab-group-split-divider-strong); --color-git-graph-ref: var(--git-graph-ref); --color-git-graph-remote-ref: var(--git-graph-remote-ref); --color-git-graph-base-ref: var(--git-graph-base-ref); @@ -202,6 +204,11 @@ --git-graph-lane-4: #40b0a6; --git-graph-lane-5: #b66dff; --annotation-highlight: #f59e0b; + /* Why: tab-group splits use a dedicated divider — `--border` is too faint on + card/editor surfaces when panes are side-by-side or stacked. Default must + meet >=3:1 against `--card`, not only the hover/drag strong state. */ + --tab-group-split-divider: #868690; + --tab-group-split-divider-strong: #71717a; } /* ── Dark Mode ───────────────────────────────────────── */ @@ -285,6 +292,9 @@ --git-graph-lane-4: #40b0a6; --git-graph-lane-5: #b66dff; --annotation-highlight: #fbbf24; + /* Brighter than terminal defaults so the always-visible line clears 3:1 on `--card`. */ + --tab-group-split-divider: #71717a; + --tab-group-split-divider-strong: #a1a1aa; } .linear-priority-bars { @@ -463,12 +473,12 @@ scrollbar-color: color-mix(in srgb, var(--muted-foreground, #737373) 48%, transparent) transparent; } -/* Keep the sidebar gutter reserved; only the thumb fades in on hover. */ +/* Why: no reserved `scrollbar-gutter` — paired with `overflow-y: auto`, a list + that fits shows no scrollbar (and no Windows scrollbar arrow buttons), so its + rows stay flush with the fixed header controls, matching macOS. The gutter is + only taken while actually scrolling. Thumb still fades in on hover. */ .worktree-sidebar-scrollbar { - /* Why: the scrollbar gutter is paint-only in Chromium; this keeps card - surfaces clear of Orca's resize handle and right-edge sidebar chrome. */ padding-right: 4px; - scrollbar-gutter: stable; scrollbar-color: transparent transparent; } @@ -519,27 +529,32 @@ /* ── Editor-style scrollbar (matches Monaco) ────────── */ -.scrollbar-editor { +.scrollbar-editor, +.xterm .xterm-viewport { scrollbar-width: thin; scrollbar-color: rgba(121, 121, 121, 0.4) transparent; } -.scrollbar-editor::-webkit-scrollbar { +.scrollbar-editor::-webkit-scrollbar, +.xterm .xterm-viewport::-webkit-scrollbar { width: 14px; } -.scrollbar-editor::-webkit-scrollbar-track { +.scrollbar-editor::-webkit-scrollbar-track, +.xterm .xterm-viewport::-webkit-scrollbar-track { background: transparent; } -.scrollbar-editor::-webkit-scrollbar-thumb { +.scrollbar-editor::-webkit-scrollbar-thumb, +.xterm .xterm-viewport::-webkit-scrollbar-thumb { background: rgba(121, 121, 121, 0.4); border: 3px solid transparent; border-radius: 7px; background-clip: padding-box; } -.scrollbar-editor::-webkit-scrollbar-thumb:hover { +.scrollbar-editor::-webkit-scrollbar-thumb:hover, +.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { background: rgba(121, 121, 121, 0.7); border: 3px solid transparent; background-clip: padding-box; @@ -618,6 +633,52 @@ overflow: hidden; } +/* Tab-group split resize handle — wide hit area, visible center line. */ +.tab-group-split-resize-handle { + flex-shrink: 0; + position: relative; + background: transparent; +} + +.tab-group-split-resize-handle.is-vertical { + width: 6px; + cursor: col-resize; +} + +.tab-group-split-resize-handle.is-horizontal { + height: 6px; + cursor: row-resize; +} + +.tab-group-split-resize-handle::after { + content: ''; + position: absolute; + border-radius: 1px; + background: var(--tab-group-split-divider); + transition: background 100ms ease; +} + +.tab-group-split-resize-handle.is-vertical::after { + top: 0; + bottom: 0; + left: 50%; + width: 3px; + transform: translateX(-50%); +} + +.tab-group-split-resize-handle.is-horizontal::after { + left: 0; + right: 0; + top: 50%; + height: 3px; + transform: translateY(-50%); +} + +.tab-group-split-resize-handle:hover::after, +.tab-group-split-resize-handle.is-dragging::after { + background: var(--tab-group-split-divider-strong); +} + /* ── Titlebar ────────────────────────────────────────── */ /* Why: Radix portals (dropdowns, popovers, dialogs, sheets) render at the @@ -901,6 +962,11 @@ color: var(--muted-foreground); } +.titlebar-profile-switcher { + -webkit-app-region: no-drag; + flex-shrink: 0; +} + .editor-header { display: flex; align-items: center; @@ -1915,6 +1981,16 @@ pointer-events: auto; } +/* Why: Monaco leaves the peek preview container `inline-block` and only sizes the + editor child, so the container shrink-wraps the child in BOTH axes while the + inherited automaticLayout ResizeObserver measures the container — a feedback + loop that ratchets the preview to blank at fractional zoom/DPI (issue #7405). + Pinning both dimensions to the splitview slot severs the loop. */ +.monaco-editor .reference-zone-widget .preview.inline { + height: 100%; + width: 100%; +} + /* ── Git conflict decorations ───────────────────────────────────── */ .monaco-editor .orca-conflict-marker-line { diff --git a/src/renderer/src/assets/terminal-scrollbar-style.test.ts b/src/renderer/src/assets/terminal-scrollbar-style.test.ts new file mode 100644 index 00000000000..642678e264c --- /dev/null +++ b/src/renderer/src/assets/terminal-scrollbar-style.test.ts @@ -0,0 +1,17 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const terminalCss = fs.readFileSync(new URL('./terminal.css', import.meta.url), 'utf8') +const mainCss = fs.readFileSync(new URL('./main.css', import.meta.url), 'utf8') + +describe('terminal scrollbar styling', () => { + it('reuses the canonical editor scrollbar with a transparent gutter', () => { + expect(mainCss).toMatch( + /\.scrollbar-editor,\s*\.xterm \.xterm-viewport\s*{[^}]*scrollbar-color:\s*rgba\(121, 121, 121, 0\.4\) transparent/s + ) + expect(mainCss).toMatch( + /\.scrollbar-editor::-webkit-scrollbar-track,\s*\.xterm \.xterm-viewport::-webkit-scrollbar-track\s*{[^}]*background:\s*transparent/s + ) + expect(terminalCss).not.toContain('--xterm-scrollbar-thumb') + }) +}) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index d9763b3e341..31c2d142e43 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -36,8 +36,8 @@ the install terminals inside centered setup modals), that black layer anti-aliases into a faint 1px line above/below the themed .xterm. Match xterm's own selector so this also covers terminals mounted outside the pane - manager, and let the themed .xterm background show through; our scrollbar is - already custom-styled below. */ + manager, and let the themed .xterm background show through; the viewport + reuses the canonical editor scrollbar rules in main.css. */ .xterm:not(.allow-transparency) .xterm-viewport { background-color: transparent; } diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 863928204b0..ff76246a24c 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -166,6 +166,7 @@ import { import { lookupGitHubWorkItemDetailsForSource } from '@/lib/github-work-item-source-lookup' import { canUseGitHubRepoContext, + getGitHubMutationRoutingSettings, getGitHubRuntimeRepoId, getGitHubSourceRuntimeHost } from '@/lib/github-source-runtime-context' @@ -3848,7 +3849,11 @@ function PRActionsPanel({ const actionItem = { ...item, state: localState } const mergePresentation = presentGitHubPRMergeState(actionItem) const mergeMethods = resolveGitHubPRMergeMethods(actionItem.mergeMethodSettings) - const sourceSettings = getTaskSourceRuntimeSettings(sourceContext) + const sourceSettings = useAppStore( + useShallow((s) => + getGitHubMutationRoutingSettings(s, item.repoId ?? repoId ?? null, sourceContext) + ) + ) const mergeTarget = getActiveRuntimeTarget(sourceSettings) const canMutateWithRepoContext = !!repoPath || !!projectOrigin || mergeTarget.kind === 'environment' @@ -5451,32 +5456,36 @@ async function runPullRequestStateUpdate(args: { } return } - const runtimeHost = getGitHubSourceRuntimeHost(args.sourceContext) - if (!args.repoPath && !runtimeHost) { + // Why: close/reopen must route by the repo owner host like merge (#6957). + const target = getActiveRuntimeTarget( + getGitHubMutationRoutingSettings(useAppStore.getState(), args.repoId, args.sourceContext) + ) + if (!args.repoPath && target.kind !== 'environment') { throw new Error('No repo context available for this pull request.') } - const res = runtimeHost - ? await callRuntimeRpc>>( - { kind: 'environment', environmentId: runtimeHost.environmentId }, - 'github.updatePRState', - { - repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.updatePRState', + { + repo: getGitHubRuntimeRepoId(args.sourceContext, args.repoId ?? ''), + prNumber: args.number, + updates: args.updates + }, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updatePRState({ + repoPath: args.repoPath ?? '', + repoId: args.repoId ?? undefined, + sourceContext: args.sourceContext, prNumber: args.number, updates: args.updates - }, - { timeoutMs: 30_000 } - ) - : await window.api.gh.updatePRState({ - repoPath: args.repoPath ?? '', - repoId: args.repoId ?? undefined, - sourceContext: args.sourceContext, - prNumber: args.number, - updates: args.updates - }) + }) if (!res.ok) { throw new Error(res.error) } - if (runtimeHost) { + if (target.kind === 'environment') { notifyWorkItemDetailsMutation( { repoPath: args.repoPath ?? '', @@ -5557,10 +5566,10 @@ function GHEditSection({ onUse: (item: GitHubWorkItem) => void onOpenOrUse?: (item: GitHubWorkItem) => void attachedWorkspaceLabel?: string | null - /** `'horizontal'` is the legacy strip rendered above the conversation; the - * `'sidebar'` layout matches the GitHub issue page's right rail with each - * metadata row stacked under a section heading. */ - layout?: 'horizontal' | 'sidebar' + /** Two surfaces only: compact pill strip for the non-issue drawer/header + * (`horizontal`), and labeled property columns above the issue page body + * (`top-columns`). */ + layout?: 'horizontal' | 'top-columns' }): React.JSX.Element | null { const [labelPopoverOpen, setLabelPopoverOpen] = useState(false) const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) @@ -6141,11 +6150,13 @@ function GHEditSection({ ) - if (layout === 'sidebar') { + if (layout === 'top-columns') { + // Why: issue page body should match the header width — property fields sit + // as top columns so the description is not squeezed by a right rail. return ( -