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/pr.yml b/.github/workflows/pr.yml index d9f19dcc1b8..2f91c946bc7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -69,6 +69,14 @@ jobs: - name: Check reliability gate manifest run: pnpm run check:reliability-gates + # Why: oxlint fails any file over max-lines that is NOT suppressed, so this + # ratchet forbids ADDING a new suppression (inline disable or mobile max + # bump). Existing oversized files are grandfathered in + # config/max-lines-baseline.txt, which may only shrink — new bypasses fail + # here with a clear message instead of silently growing the debt. + - name: Enforce max-lines ratchet (no new bypasses) + run: pnpm run check:max-lines-ratchet + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names 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/.gitignore b/.gitignore index 7cdf3374035..24595e4c708 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ coverage/ # Benchmark run output /tools/benchmarks/results/*.json +/.bench-fixtures/ # Temp tmp/ diff --git a/AGENTS.md b/AGENTS.md index 647c598a25e..b10035e9b17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,9 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s ## Lint Rules: Do Not Disable Max Lines -Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants). Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead. +Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead. + +This is enforced in CI by `pnpm check:max-lines-ratchet` (`config/scripts/check-max-lines-ratchet.mjs`). Files already over the limit are grandfathered in `config/max-lines-baseline.txt`; that list may only **shrink**. Adding a new suppression fails the build. If you remove a suppression (great — split it!), run `pnpm check:max-lines-ratchet --prune` to drop its baseline line. ## File and Module Naming diff --git a/README.md b/README.md index 1043bc9d670..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.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/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.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/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..0148b3cbafd 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -2,6 +2,7 @@ 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 { verifyPackagedDaemonEntryBoots } = require('./scripts/verify-packaged-daemon-entry.cjs') const { createPackagedRuntimeNodeModuleResources, prunePackagedRuntimeNodeModules, @@ -106,6 +107,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 +131,20 @@ 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 { + console.log( + `[verify-packaged-daemon-entry] skipped 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 +158,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 +193,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 +253,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 +431,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/max-lines-baseline.txt b/config/max-lines-baseline.txt new file mode 100644 index 00000000000..543ff2e6a60 --- /dev/null +++ b/config/max-lines-baseline.txt @@ -0,0 +1,359 @@ +# Files/globs currently allowed to exceed the oxlint `max-lines` budget. +# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green — +# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). +# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) +inline mobile/src/constants/marine-creatures.ts +inline src/cli/handlers/automations.ts +inline src/cli/handlers/orchestration.ts +inline src/cli/help.ts +inline src/cli/index.test.ts +inline src/main/agent-hooks/server.test.ts +inline src/main/agent-hooks/server.ts +inline src/main/amp/hook-service.ts +inline src/main/antigravity/hook-service.ts +inline src/main/attribution/terminal-attribution.ts +inline src/main/automations/external-manager.ts +inline src/main/automations/hermes-cron-output.ts +inline src/main/browser/agent-browser-bridge.test.ts +inline src/main/browser/agent-browser-bridge.ts +inline src/main/browser/browser-cookie-import.ts +inline src/main/browser/browser-guest-ui.ts +inline src/main/browser/browser-manager-grab.test.ts +inline src/main/browser/browser-manager.test.ts +inline src/main/browser/browser-manager.ts +inline src/main/browser/browser-screencast-stream.ts +inline src/main/browser/browser-session-registry.ts +inline src/main/browser/cdp-bridge.ts +inline src/main/browser/cdp-ws-proxy.ts +inline src/main/browser/grab-guest-script.ts +inline src/main/browser/snapshot-engine.ts +inline src/main/claude-accounts/runtime-auth-service.test.ts +inline src/main/claude-accounts/runtime-auth-service.ts +inline src/main/claude-accounts/service.test.ts +inline src/main/claude-accounts/service.ts +inline src/main/claude-usage/scanner.ts +inline src/main/claude-usage/store.ts +inline src/main/cli/cli-installer.test.ts +inline src/main/cli/cli-installer.ts +inline src/main/cli/wsl-cli-installer.ts +inline src/main/codex-accounts/runtime-home-service.test.ts +inline src/main/codex-accounts/runtime-home-service.ts +inline src/main/codex-accounts/service.test.ts +inline src/main/codex-accounts/service.ts +inline src/main/codex-usage/scanner.ts +inline src/main/codex-usage/store.test.ts +inline src/main/codex-usage/store.ts +inline src/main/codex/config-toml-trust.test.ts +inline src/main/codex/config-toml-trust.ts +inline src/main/codex/hook-service.test.ts +inline src/main/codex/hook-service.ts +inline src/main/copilot/hook-service.ts +inline src/main/daemon/client.ts +inline src/main/daemon/daemon-health.ts +inline src/main/daemon/daemon-init.test.ts +inline src/main/daemon/daemon-init.ts +inline src/main/daemon/daemon-pty-adapter.test.ts +inline src/main/daemon/daemon-pty-adapter.ts +inline src/main/daemon/daemon-server.ts +inline src/main/daemon/pty-subprocess.test.ts +inline src/main/daemon/pty-subprocess.ts +inline src/main/daemon/session.ts +inline src/main/daemon/shell-ready.ts +inline src/main/git/remove-worktree.test.ts +inline src/main/git/repo.ts +inline src/main/git/runner.ts +inline src/main/git/status.test.ts +inline src/main/git/status.ts +inline src/main/git/worktree.test.ts +inline src/main/git/worktree.ts +inline src/main/github/client.test.ts +inline src/main/github/client.ts +inline src/main/github/issues.ts +inline src/main/github/pr-refresh-coordinator.test.ts +inline src/main/github/pr-refresh-coordinator.ts +inline src/main/github/project-view.ts +inline src/main/github/project-view/mutations.ts +inline src/main/github/work-item-details.ts +inline src/main/gitlab/client-mr.test.ts +inline src/main/gitlab/client.ts +inline src/main/gitlab/issues.ts +inline src/main/gitlab/work-item-details.ts +inline src/main/hermes/hook-service.ts +inline src/main/hooks.test.ts +inline src/main/hooks.ts +inline src/main/index.ts +inline src/main/ipc/browser.ts +inline src/main/ipc/crash-reporting.ts +inline src/main/ipc/filesystem-auth.ts +inline src/main/ipc/filesystem-mutations.ts +inline src/main/ipc/filesystem-watcher.ts +inline src/main/ipc/filesystem.test.ts +inline src/main/ipc/filesystem.ts +inline src/main/ipc/github.test.ts +inline src/main/ipc/github.ts +inline src/main/ipc/gitlab.ts +inline src/main/ipc/linear.ts +inline src/main/ipc/notifications.test.ts +inline src/main/ipc/notifications.ts +inline src/main/ipc/pet.ts +inline src/main/ipc/preflight.test.ts +inline src/main/ipc/pty.test.ts +inline src/main/ipc/pty.ts +inline src/main/ipc/remote-workspace.ts +inline src/main/ipc/repos-remote.test.ts +inline src/main/ipc/repos.ts +inline src/main/ipc/runtime-environments.test.ts +inline src/main/ipc/ssh.test.ts +inline src/main/ipc/ssh.ts +inline src/main/ipc/worktree-remote.ts +inline src/main/ipc/worktrees.test.ts +inline src/main/ipc/worktrees.ts +inline src/main/jira/client.ts +inline src/main/jira/issues.ts +inline src/main/keybindings/keybinding-file.ts +inline src/main/linear/client.ts +inline src/main/linear/issues.ts +inline src/main/linear/projects.ts +inline src/main/memory/collector.ts +inline src/main/opencode-usage/scanner.ts +inline src/main/opencode-usage/store.ts +inline src/main/opencode/hook-service.ts +inline src/main/persistence.test.ts +inline src/main/persistence.ts +inline src/main/ports/advertised-url-watcher.ts +inline src/main/ports/local-workspace-port-scanner.ts +inline src/main/project-groups/nested-repo-discovery.ts +inline src/main/providers/local-pty-provider.test.ts +inline src/main/providers/local-pty-provider.ts +inline src/main/providers/local-pty-shell-ready.test.ts +inline src/main/providers/local-pty-shell-ready.ts +inline src/main/providers/ssh-git-provider.test.ts +inline src/main/providers/ssh-git-provider.ts +inline src/main/rate-limits/claude-fetcher.test.ts +inline src/main/rate-limits/claude-fetcher.ts +inline src/main/rate-limits/claude-pty.ts +inline src/main/rate-limits/codex-fetcher.ts +inline src/main/rate-limits/service.test.ts +inline src/main/rate-limits/service.ts +inline src/main/runtime/orca-runtime-browser.ts +inline src/main/runtime/orca-runtime-files.test.ts +inline src/main/runtime/orca-runtime-files.ts +inline src/main/runtime/orca-runtime-git.ts +inline src/main/runtime/orca-runtime.test.ts +inline src/main/runtime/orca-runtime.ts +inline src/main/runtime/orchestration/coordinator.ts +inline src/main/runtime/orchestration/db.ts +inline src/main/runtime/rpc/methods/files.ts +inline src/main/runtime/rpc/methods/git.ts +inline src/main/runtime/rpc/methods/github.ts +inline src/main/runtime/rpc/methods/orchestration.test.ts +inline src/main/runtime/rpc/methods/orchestration.ts +inline src/main/runtime/rpc/methods/terminal.ts +inline src/main/runtime/rpc/terminal-multiplex.test.ts +inline src/main/runtime/runtime-rpc.test.ts +inline src/main/runtime/runtime-rpc.ts +inline src/main/source-control/hosted-review-creation.ts +inline src/main/speech/model-manager.ts +inline src/main/speech/stt-service.ts +inline src/main/ssh/ssh-channel-multiplexer.ts +inline src/main/ssh/ssh-connection.test.ts +inline src/main/ssh/ssh-connection.ts +inline src/main/ssh/ssh-relay-deploy.ts +inline src/main/ssh/ssh-relay-session.ts +inline src/main/star-nag/service.test.ts +inline src/main/text-generation/commit-message-text-generation.test.ts +inline src/main/text-generation/commit-message-text-generation.ts +inline src/main/updater.test.ts +inline src/main/updater.ts +inline src/main/window/attach-main-window-services.ts +inline src/main/window/createMainWindow.test.ts +inline src/main/window/createMainWindow.ts +inline src/main/workspace-space-analysis.ts +inline src/preload/api-types.ts +inline src/preload/index.ts +inline src/relay/agent-hook-server.ts +inline src/relay/dispatcher.ts +inline src/relay/external-automations-handler.ts +inline src/relay/fs-handler.ts +inline src/relay/git-handler.test.ts +inline src/relay/git-handler.ts +inline src/relay/pty-handler.test.ts +inline src/relay/pty-handler.ts +inline src/relay/relay.ts +inline src/relay/workspace-space-scan.ts +inline src/renderer/src/App.tsx +inline src/renderer/src/components/GitHubItemDialog.tsx +inline src/renderer/src/components/GitLabItemDialog.tsx +inline src/renderer/src/components/JiraIssueWorkspace.tsx +inline src/renderer/src/components/LinearIssueWorkspace.tsx +inline src/renderer/src/components/LinearItemDrawer.tsx +inline src/renderer/src/components/NewWorkspaceComposerCard.tsx +inline src/renderer/src/components/PullRequestPage.tsx +inline src/renderer/src/components/TaskPage.tsx +inline src/renderer/src/components/Terminal.tsx +inline src/renderer/src/components/UpdateCard.tsx +inline src/renderer/src/components/WorktreeJumpPalette.tsx +inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts +inline src/renderer/src/components/activity/ActivityPrototypePage.tsx +inline src/renderer/src/components/automations/AutomationsPage.tsx +inline src/renderer/src/components/browser-pane/BrowserPane.tsx +inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +inline src/renderer/src/components/editor/CombinedDiffViewer.tsx +inline src/renderer/src/components/editor/EditorContent.tsx +inline src/renderer/src/components/editor/IpynbViewer.tsx +inline src/renderer/src/components/editor/MarkdownPreview.tsx +inline src/renderer/src/components/editor/MonacoEditor.tsx +inline src/renderer/src/components/editor/editor-autosave-controller.ts +inline src/renderer/src/components/editor/ipynb-parse.ts +inline src/renderer/src/components/editor/useEditorPanelContentState.ts +inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx +inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx +inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx +inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +inline src/renderer/src/components/github-project/ProjectCell.tsx +inline src/renderer/src/components/github-project/ProjectPicker.tsx +inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx +inline src/renderer/src/components/linear-project-view-surfaces.tsx +inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +inline src/renderer/src/components/onboarding/use-onboarding-flow.ts +inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx +inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +inline src/renderer/src/components/right-sidebar/FileExplorer.tsx +inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +inline src/renderer/src/components/right-sidebar/PortsPanel.tsx +inline src/renderer/src/components/right-sidebar/SourceControl.tsx +inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx +inline src/renderer/src/components/right-sidebar/index.tsx +inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts +inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts +inline src/renderer/src/components/settings/AccountsPane.tsx +inline src/renderer/src/components/settings/AgentsPane.tsx +inline src/renderer/src/components/settings/RepositoryHooksSection.tsx +inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +inline src/renderer/src/components/settings/Settings.tsx +inline src/renderer/src/components/settings/SettingsFormControls.tsx +inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx +inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +inline src/renderer/src/components/sidebar/WorktreeCard.tsx +inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +inline src/renderer/src/components/sidebar/WorktreeList.tsx +inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts +inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts +inline src/renderer/src/components/sidebar/worktree-list-groups.ts +inline src/renderer/src/components/stats/usage-overview-model.ts +inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +inline src/renderer/src/components/status-bar/StatusBar.tsx +inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +inline src/renderer/src/components/tab-bar/TabBar.tsx +inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +inline src/renderer/src/components/tab-group/useTabDragSplit.ts +inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +inline src/renderer/src/components/task-page-cache-selectors.ts +inline src/renderer/src/components/terminal-pane/TerminalPane.tsx +inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts +inline src/renderer/src/components/terminal-pane/pty-connection.test.ts +inline src/renderer/src/components/terminal-pane/pty-connection.ts +inline src/renderer/src/components/terminal-pane/pty-transport.test.ts +inline src/renderer/src/components/terminal-pane/pty-transport.ts +inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +inline src/renderer/src/hooks/useAutomationDispatchEvents.ts +inline src/renderer/src/hooks/useComposerState.ts +inline src/renderer/src/hooks/useEditorExternalWatch.ts +inline src/renderer/src/hooks/useIpcEvents.test.ts +inline src/renderer/src/hooks/useIpcEvents.ts +inline src/renderer/src/hooks/useIssueMetadata.ts +inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts +inline src/renderer/src/lib/active-agent-note-send.test.ts +inline src/renderer/src/lib/file-type-icons.ts +inline src/renderer/src/lib/pane-manager/pane-manager.ts +inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts +inline src/renderer/src/lib/terminal-links.ts +inline src/renderer/src/lib/worktree-activation.test.ts +inline src/renderer/src/lib/worktree-activation.ts +inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +inline src/renderer/src/runtime/runtime-file-client.test.ts +inline src/renderer/src/runtime/runtime-file-client.ts +inline src/renderer/src/runtime/runtime-git-client.ts +inline src/renderer/src/runtime/runtime-linear-client.ts +inline src/renderer/src/runtime/sync-runtime-graph.test.ts +inline src/renderer/src/runtime/sync-runtime-graph.ts +inline src/renderer/src/runtime/web-runtime-session.test.ts +inline src/renderer/src/runtime/web-runtime-session.ts +inline src/renderer/src/runtime/web-session-tabs-sync.test.ts +inline src/renderer/src/runtime/web-session-tabs-sync.ts +inline src/renderer/src/store/slices/agent-status.test.ts +inline src/renderer/src/store/slices/agent-status.ts +inline src/renderer/src/store/slices/browser.test.ts +inline src/renderer/src/store/slices/browser.ts +inline src/renderer/src/store/slices/diffComments.ts +inline src/renderer/src/store/slices/editor.test.ts +inline src/renderer/src/store/slices/editor.ts +inline src/renderer/src/store/slices/github.test.ts +inline src/renderer/src/store/slices/github.ts +inline src/renderer/src/store/slices/hosted-review.ts +inline src/renderer/src/store/slices/jira.ts +inline src/renderer/src/store/slices/linear.test.ts +inline src/renderer/src/store/slices/linear.ts +inline src/renderer/src/store/slices/repos.ts +inline src/renderer/src/store/slices/store-cascades.test.ts +inline src/renderer/src/store/slices/store-session-cascades.test.ts +inline src/renderer/src/store/slices/tabs.test.ts +inline src/renderer/src/store/slices/tabs.ts +inline src/renderer/src/store/slices/terminals.ts +inline src/renderer/src/store/slices/ui.test.ts +inline src/renderer/src/store/slices/ui.ts +inline src/renderer/src/store/slices/workspace-cleanup.ts +inline src/renderer/src/store/slices/worktrees.test.ts +inline src/renderer/src/store/slices/worktrees.ts +inline src/renderer/src/web/web-preload-api.test.ts +inline src/renderer/src/web/web-preload-api.ts +inline src/renderer/src/web/web-runtime-client.ts +inline src/shared/agent-hook-listener.test.ts +inline src/shared/agent-hook-listener.ts +inline src/shared/automation-schedules.ts +inline src/shared/commit-message-agent-spec.ts +inline src/shared/constants.ts +inline src/shared/github-project-types.ts +inline src/shared/keybindings.test.ts +inline src/shared/keybindings.ts +inline src/shared/marine-creatures.ts +inline src/shared/remote-runtime-client.ts +inline src/shared/runtime-types.ts +inline src/shared/source-control-ai.ts +inline src/shared/telemetry-events.ts +inline src/shared/text-search.ts +inline src/shared/types.ts +inline tests/e2e/helpers/terminal.ts +inline tests/e2e/terminal-panes.spec.ts +mobile-config app/h/*/files/*.tsx +mobile-config app/h/*/index.tsx +mobile-config app/h/*/session/*.tsx +mobile-config app/h/*/source-control/*.tsx +mobile-config app/h/*/tasks.tsx +mobile-config app/index.tsx +mobile-config app/pair-scan.tsx +mobile-config app/terminal-settings.tsx +mobile-config app/troubleshoot.tsx +mobile-config scripts/mock-server.ts +mobile-config scripts/repro-terminal-colors.ts +mobile-config scripts/repro-worktree-startup-stream.ts +mobile-config src/browser/MobileBrowserPane.tsx +mobile-config src/components/CustomKeyModal.tsx +mobile-config src/components/NewWorktreeModal.tsx +mobile-config src/components/mobile-rich-markdown-editor-html.ts +mobile-config src/terminal/terminal-accessory-keys.ts +mobile-config src/terminal/terminal-webview-html.ts +mobile-config src/transport/rpc-client.ts 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 bd90a3370b8..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", @@ -458,6 +615,97 @@ ], "demotionRule": "Cannot promote while it only checks visual rendering." }, + { + "id": "terminal-scroll.intent-preservation", + "title": "Scrollbar drag intent survives tab and visibility resume", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-unit", + "surfaces": [ + "terminal lifecycle", + "hidden-to-visible resume", + "tab switching", + "xterm scrollbar DOM", + "scrollback" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Renderer-unit coverage proves the shared xterm DOM intent path. Live Electron evidence is PR validation evidence for local macOS only until the flow has stable automation; live SSH, WSL, Linux, and Windows paths remain unproved.", + "motivatingLinks": [ + "STA-1341" + ], + "invariant": "A user-driven xterm scrollbar thumb or track scroll updates the live terminal scroll intent before tab, visibility, or layout resume enforces intent, so resume preserves the latest dragged viewport instead of an older pinned line.", + "oracle": "Pointerdown on .xterm-scrollbar or .xterm-slider followed by xterm viewport movement records the new pinned viewport, and enforcing current intent restores that dragged line instead of stale top intent.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts" + ], + "testFiles": [ + "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts", + "assertions": [ + "pointer-driven .xterm-scrollbar and .xterm-slider scrolls update terminal scroll intent", + "a scrollbar-dragged viewport is restored instead of stale top intent", + "terminal body pointer activity is not treated as scrollbar intent" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-05", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts", + "result": "passed", + "durationSeconds": 0.3, + "summary": "1 test file(s) passed, 17 tests passed in this worktree." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "focused renderer unit test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New experimental gate; needs soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The new scrollbar DOM target assertions fail when only .xterm-viewport pointerdown is recognized. Needs saved CI/intentional-break artifact before blocking promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "Runtime fix is limited to constant-time class/closest checks on pointerdown. It must not add polling, output parsing, PTY listing, hidden-pane wakeups, resize calls, or startup awaits." + }, + "promotionCriteria": [ + "Collect stable CI soak history for the renderer-unit gate.", + "Add stable live Electron automation for the scrollbar drag plus tab-switch repro.", + "Attach red/green evidence for stale intent restoring the wrong viewport." + ], + "knownGaps": [ + "Manifest command is deterministic renderer-unit coverage, not a live Electron scrollbar drag.", + "Live daemon, SSH, WSL, remote-runtime, Linux, and Windows validation is not covered by this gate.", + "Future xterm scrollbar DOM class changes are only caught if they break the currently modeled class contract." + ], + "demotionRule": "Demote or quarantine if the unit gate flakes without a product bug or harness bug filed to the owner." + }, { "id": "startup-upgrade.persisted-session-corpus", "title": "Current Orca preserves or recovers old production persisted sessions", @@ -1182,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", @@ -1190,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": [ { @@ -1218,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.", @@ -1253,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." @@ -1320,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/check-max-lines-ratchet.mjs b/config/scripts/check-max-lines-ratchet.mjs new file mode 100644 index 00000000000..d4e8b9fa072 --- /dev/null +++ b/config/scripts/check-max-lines-ratchet.mjs @@ -0,0 +1,254 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +// Ratchet gate for the oxlint `max-lines` rule. +// +// oxlint already fails any file that exceeds max-lines WITHOUT a suppression, so +// the only way a file grows past the budget is by adding an `eslint/oxlint-disable +// max-lines` comment or a per-file `max-lines` bump in mobile/.oxlintrc.json. This +// check freezes the set of files currently allowed to do that (the baseline) and +// fails CI when a NEW bypass appears — the existing over-limit files are +// grandfathered; new ones must split instead. The baseline may only shrink. + +const BASELINE_PATH = 'config/max-lines-baseline.txt' +const MOBILE_CONFIG_PATH = 'mobile/.oxlintrc.json' +// These two files legitimately contain the directive text as data (regex, fixtures), +// so scanning them would self-flag. The ratchet does not police itself. +const SELF_FILES = new Set([ + 'config/scripts/check-max-lines-ratchet.mjs', + 'config/scripts/check-max-lines-ratchet.test.mjs' +]) + +// Default max-lines budgets from .oxlintrc.json (counted lines). +export function defaultLimitForPath(p) { + if (/\.(test|spec)\.(ts|tsx)$/.test(p)) { + return 800 + } + if (p.endsWith('.tsx')) { + return 400 + } + if (p.endsWith('.mjs')) { + return 600 + } + return 300 +} + +// True if the source contains an eslint/oxlint disable directive listing `max-lines` +// (block or line, bare or compound, with or without a `-- Why:` reason). +export function hasMaxLinesDisable(sourceText) { + const re = /(?:eslint|oxlint)-disable(?:-next-line|-line)?\b([^\n]*)/g + let m + while ((m = re.exec(sourceText)) !== null) { + let rules = m[1] + rules = rules.split('--')[0] // strip the reason + const close = rules.indexOf('*/') + if (close !== -1) { + rules = rules.slice(0, close) // strip block-comment tail + } + if (/\bmax-lines\b/.test(rules)) { + return true + } + } + return false +} + +// Per-file `max-lines` bumps in mobile/.oxlintrc.json whose `max` exceeds the +// default for that glob (a lower `max` is stricter, not a bypass). +export function collectMobileBumps(configText) { + const cfg = JSON.parse(configText) + const bumps = [] + for (const override of cfg.overrides ?? []) { + const rule = override.rules?.['max-lines'] + if (!Array.isArray(rule) || typeof rule[1]?.max !== 'number') { + continue + } + for (const glob of override.files ?? []) { + if (rule[1].max > defaultLimitForPath(glob)) { + bumps.push(`mobile-config ${glob}`) + } + } + } + return bumps +} + +export function parseBaseline(text) { + return new Set( + text + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#')) + ) +} + +export function diffBaseline(current, baseline) { + const cur = new Set(current) + const base = baseline instanceof Set ? baseline : new Set(baseline) + const added = [...cur].filter((e) => !base.has(e)).sort() + const stale = [...base].filter((e) => !cur.has(e)).sort() + return { added, stale } +} + +// Collect every current suppression entry from the tracked tree. +export function collectCurrentSuppressions(root = process.cwd()) { + const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mjs'], { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024 + }) + .split('\n') + .filter(Boolean) + .filter((f) => !SELF_FILES.has(f)) + + const entries = [] + for (const rel of tracked) { + let src + try { + src = fs.readFileSync(path.join(root, rel), 'utf8') + } catch { + continue + } + if (hasMaxLinesDisable(src)) { + entries.push(`inline ${rel}`) + } + } + + const mobileCfgPath = path.join(root, MOBILE_CONFIG_PATH) + if (fs.existsSync(mobileCfgPath)) { + entries.push(...collectMobileBumps(fs.readFileSync(mobileCfgPath, 'utf8'))) + } + + return entries.sort() +} + +function printAddedFailure(added) { + for (const entry of added) { + console.error(`::error::New max-lines bypass not allowed: ${entry}`) + } + console.error('') + console.error('╭────────────────────────────────────────────────────────────────────────────╮') + console.error('│ ❌ max-lines ratchet failed — a NEW file is trying to exceed the line cap. │') + console.error('╰────────────────────────────────────────────────────────────────────────────╯') + console.error('') + console.error(` ${added.length} file(s)/glob(s) newly bypass the oxlint \`max-lines\` rule:`) + console.error('') + for (const entry of added) { + const [kind, ...rest] = entry.split(' ') + const target = rest.join(' ') + const how = + kind === 'inline' + ? 'added an eslint/oxlint-disable max-lines comment' + : 'added a per-file max-lines bump in mobile/.oxlintrc.json' + console.error(` • ${target}\n ↳ ${how}`) + } + console.error('') + console.error(' Orca caps file size (300 .ts / 400 .tsx / 600 .mjs / 800 test — non-blank,') + console.error( + ' non-comment lines). Existing oversized files are grandfathered; NEW ones are not.' + ) + console.error('') + console.error(' ✅ Fix it: SPLIT the file into focused modules — do NOT suppress the rule.') + console.error(' See AGENTS.md → "Lint Rules: Do Not Disable Max Lines".') + console.error('') + console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable') + console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`) + console.error('') +} + +function printStaleFailure(stale) { + for (const entry of stale) { + console.error(`::error::Stale max-lines baseline entry (prune it): ${entry}`) + } + console.error('') + console.error('╭────────────────────────────────────────────────────────────────────────────╮') + console.error('│ ⚠️ max-lines baseline is out of date — nice work removing a bypass! │') + console.error('╰────────────────────────────────────────────────────────────────────────────╯') + console.error('') + console.error(` ${stale.length} baseline entr(y/ies) no longer have a max-lines suppression.`) + console.error( + ' The baseline may only shrink, so these must be removed to keep re-adding blocked:' + ) + console.error('') + for (const entry of stale) { + console.error(` • ${entry}`) + } + console.error('') + console.error(` ✅ Fix it (one command): pnpm check:max-lines-ratchet --prune`) + console.error('') +} + +export function main(root = process.cwd()) { + const baselineFile = path.join(root, BASELINE_PATH) + if (!fs.existsSync(baselineFile)) { + console.error( + `::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-max-lines-ratchet.mjs --init` + ) + return 1 + } + const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8')) + const current = collectCurrentSuppressions(root) + const { added, stale } = diffBaseline(current, baseline) + + if (added.length > 0) { + printAddedFailure(added) + if (stale.length > 0) { + console.error( + ` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)` + ) + printStaleFailure(stale) + } + return 1 + } + if (stale.length > 0) { + printStaleFailure(stale) + return 1 + } + console.log( + `max-lines ratchet OK — ${current.length} grandfathered suppression(s), no new bypasses.` + ) + return 0 +} + +function writeBaseline(root, entries) { + const header = [ + '# Files/globs currently allowed to exceed the oxlint `max-lines` budget.', + '# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —', + '# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").', + '# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)', + '' + ].join('\n') + fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const root = process.cwd() + const arg = process.argv[2] + if (arg === '--init') { + // One-time bootstrap: capture the current suppression set as the baseline. + const entries = collectCurrentSuppressions(root) + writeBaseline(root, entries) + console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`) + process.exit(0) + } + if (arg === '--prune') { + // Remove baseline entries whose suppression is gone (shrink only; never adds). + const current = new Set(collectCurrentSuppressions(root)) + const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8')) + const kept = [...baseline].filter((e) => current.has(e)).sort() + const newlyAdded = [...current].filter((e) => !baseline.has(e)) + writeBaseline(root, kept) + console.log( + `Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).` + ) + if (newlyAdded.length > 0) { + console.error( + `::error::--prune does not add entries; ${newlyAdded.length} new bypass(es) remain — split those files.` + ) + process.exit(1) + } + process.exit(0) + } + process.exit(main(root)) +} diff --git a/config/scripts/check-max-lines-ratchet.test.mjs b/config/scripts/check-max-lines-ratchet.test.mjs new file mode 100644 index 00000000000..f1eb4183454 --- /dev/null +++ b/config/scripts/check-max-lines-ratchet.test.mjs @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' + +import { + collectMobileBumps, + defaultLimitForPath, + diffBaseline, + hasMaxLinesDisable, + parseBaseline +} from './check-max-lines-ratchet.mjs' + +describe('hasMaxLinesDisable', () => { + it('detects a bare block disable', () => { + expect(hasMaxLinesDisable('/* eslint-disable max-lines */\nexport const a = 1\n')).toBe(true) + }) + + it('detects the oxlint spelling', () => { + expect(hasMaxLinesDisable('/* oxlint-disable max-lines */\n')).toBe(true) + }) + + it('detects a disable with a -- Why reason', () => { + expect(hasMaxLinesDisable('/* eslint-disable max-lines -- Why: one owner. */\n')).toBe(true) + }) + + it('detects a multi-line block where the reason wraps', () => { + const src = + '/* eslint-disable max-lines -- Why: this contract is\n * intentionally centralized. */\nimport x from "y"\n' + expect(hasMaxLinesDisable(src)).toBe(true) + }) + + it('detects max-lines inside a compound rule list', () => { + expect(hasMaxLinesDisable('/* eslint-disable no-control-regex, max-lines -- Why: x */\n')).toBe( + true + ) + expect(hasMaxLinesDisable('/* eslint-disable max-lines, no-control-regex */\n')).toBe(true) + }) + + it('detects a line-scoped disable', () => { + expect(hasMaxLinesDisable('const a = 1 // eslint-disable-line max-lines\n')).toBe(true) + }) + + it('ignores a disable for an unrelated rule', () => { + expect(hasMaxLinesDisable('/* eslint-disable no-console */\n')).toBe(false) + }) + + it('does not treat "max-lines" appearing only in the reason text as a suppression', () => { + // max-lines is after the `--`, so it is prose, not a suppressed rule. + expect( + hasMaxLinesDisable('/* eslint-disable no-console -- we could hit max-lines later */\n') + ).toBe(false) + }) + + it('returns false for ordinary source', () => { + expect(hasMaxLinesDisable('export function f() {\n return 42\n}\n')).toBe(false) + }) +}) + +describe('defaultLimitForPath', () => { + it('uses 800 for tests, 400 for tsx, 600 for mjs, 300 otherwise', () => { + expect(defaultLimitForPath('a/b.test.ts')).toBe(800) + expect(defaultLimitForPath('a/b.spec.tsx')).toBe(800) + expect(defaultLimitForPath('a/b.tsx')).toBe(400) + expect(defaultLimitForPath('a/b.mjs')).toBe(600) + expect(defaultLimitForPath('a/b.ts')).toBe(300) + }) +}) + +describe('collectMobileBumps', () => { + it('captures only overrides whose max exceeds the default for the glob', () => { + const cfg = JSON.stringify({ + overrides: [ + { files: ['app/h/*/tasks.tsx'], rules: { 'max-lines': ['error', { max: 14682 }] } }, // bump (>400) + { + files: ['src/terminal/TerminalWebView.tsx'], + rules: { 'max-lines': ['error', { max: 379 }] } + }, // stricter (<400), skip + { files: ['scripts/mock-server.ts'], rules: { 'max-lines': ['error', { max: 407 }] } } // bump (>300) + ] + }) + expect(collectMobileBumps(cfg)).toEqual([ + 'mobile-config app/h/*/tasks.tsx', + 'mobile-config scripts/mock-server.ts' + ]) + }) + + it('ignores overrides without a max-lines rule', () => { + const cfg = JSON.stringify({ + overrides: [{ files: ['a.tsx'], rules: { 'no-console': 'off' } }] + }) + expect(collectMobileBumps(cfg)).toEqual([]) + }) +}) + +describe('parseBaseline', () => { + it('drops comments and blank lines', () => { + const b = parseBaseline('# header\n\ninline a.ts\nmobile-config x/*.tsx\n') + expect(b).toEqual(new Set(['inline a.ts', 'mobile-config x/*.tsx'])) + }) +}) + +describe('diffBaseline', () => { + it('reports added and stale entries', () => { + const { added, stale } = diffBaseline( + ['inline b.ts', 'inline c.ts'], + new Set(['inline a.ts', 'inline b.ts']) + ) + expect(added).toEqual(['inline c.ts']) // new bypass + expect(stale).toEqual(['inline a.ts']) // suppression removed + }) + + it('is clean when current matches baseline', () => { + const { added, stale } = diffBaseline(['inline a.ts'], new Set(['inline a.ts'])) + expect(added).toEqual([]) + expect(stale).toEqual([]) + }) +}) 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..850b8810f9b 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') }) 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..6ad91285ca3 --- /dev/null +++ b/config/scripts/verify-packaged-daemon-entry.cjs @@ -0,0 +1,46 @@ +const { existsSync } = require('node:fs') +const { spawnSync } = require('node:child_process') +const { join } = require('node:path') + +// 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 = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') + if (!existsSync(entryPath)) { + // Why: some targets/layouts do not unpack here; skip rather than fail so + // the hook stays safe across platforms it has not verified. + console.log(`[verify-packaged-daemon-entry] skipped — no unpacked entry at ${entryPath}`) + return + } + + 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 = { verifyPackagedDaemonEntryBoots } diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index a7c4761a0ec..0fd87a36f7c 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -13,9 +13,12 @@ "../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/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/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index abc2d902e9a..b712f768dce 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.6m + + downloads: 4.1m @@ -15,7 +15,7 @@ downloads downloads - 3.6m - 3.6m + 4.1m + 4.1m 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/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/reference/ssh-typing-latency-under-relay-load.md b/docs/reference/ssh-typing-latency-under-relay-load.md new file mode 100644 index 00000000000..f7ae8e0b3a7 --- /dev/null +++ b/docs/reference/ssh-typing-latency-under-relay-load.md @@ -0,0 +1,115 @@ +# SSH Typing Latency Under Relay Load + +Date: 2026-07-06 + +## Symptom + +Typing in an SSH-host terminal can become extremely slow (hundreds of ms to +seconds per echoed keystroke) while other SSH work is active — file previews, +source-control refresh, search. A quiet SSH shell stays snappy, which made the +reports look unreproducible. + +This is distinct from the local warm-switch lag documented in +`terminal-switch-typing-lag-investigation.md` (daemon `listSessions()` +snapshot cost — already fixed; `TerminalHost.listSessions()` is now +metadata-only via `getAppliedSize()`). + +## Root Cause + +The relay and the Electron client share ONE ordered SSH channel for all +JSON-RPC traffic: PTY input/output, file streams, git responses, search, port +scans. Two mechanisms turned bulk traffic into typing latency: + +1. **Relay-side head-of-line blocking (primary).** The `fs.readFileStream` + pump (`src/relay/fs-handler-file-read.ts`) wrote every 256KB chunk (~340KB + framed) into the relay's stdout as fast as local disk reads completed, + ignoring the `write() === false` backpressure signal. A 10MB preview + enqueued ~13.6MB into the pipe at once; a `pty.data` echo emitted + mid-stream queued behind ALL of it. At 2MB/s WAN that is multiple seconds + of echo delay per open file. The reproduced measurement: 4,195,592 bytes + (the entire remaining 3MB test file, framed) queued ahead of one echo + frame. + +2. **Client-side O(n²) frame buffering (secondary).** `FrameDecoder.feed()` + in both `src/main/ssh/relay-protocol.ts` and `src/relay/protocol.ts` did + `Buffer.concat([buffered, chunk])` per data event, re-copying the whole + backlog for every ~32KB TCP chunk — ~2MB of memcpy per 340KB frame, + ~80MB per 10MB file — on the Electron main thread, delaying `pty:write` + IPC dispatch and echo delivery to the renderer. + +## Fix + +- **Bulk lane with sink backpressure** (`RelayDispatcher.notifyBulk`): + `fs.streamChunk` frames are serialized per client and each send waits for + the sink's `drain` when `write()` returns `false`. Interactive frames + (`pty.data`) still use plain `notify()` and are admitted immediately — + because the bulk lane keeps the outbound buffer at ~1 frame, an echo jumps + ahead of every not-yet-admitted chunk. Sinks (`relay.ts` stdout + Unix + socket clients) surface `write()`'s boolean and a one-shot + `waitWriteDrain`; every death path (EPIPE, stdin end, detach, dispose) + flushes parked waiters so a pump can never hang on a dead pipe. + +- **Credit-based flow control** (`fs.streamAck`): the client requests + streams with `flowControl: 'ack'` and acks each processed chunk; the relay + caps unacked chunks at `STREAM_ACK_WINDOW_CHUNKS = 4` (~1MB raw). This + bounds in-flight bulk bytes even past the relay's own pipe (sshd/TCP + buffers) and paces the relay's base64/JSON encode loop so incoming + keystroke frames get event-loop turns. A parked pump wakes on ack, cancel, + release, client detach, and a 1s staleness recheck. + +- **Cross-version compatibility**: old client + new relay → no + `flowControl` param → legacy unpaced pump (still drain-bounded, which is + transparent). New client + old relay → `fs.streamAck` is an unknown + notification and is silently ignored. `STREAM_CHUNK_SIZE` (256KB) is + intentionally unchanged — both sides bake it into chunk offset math, so + changing it would corrupt cross-version streams. + +- **FrameDecoder rewrite** (both mirrored protocol files): chunk-list + buffering; each frame is assembled exactly once (one copy) instead of + re-concatenating the backlog per feed. + +## What Stays Fast / Unchanged + +- Relay PTY output batching (interactive echo fast path in + `src/relay/pty-handler.ts`) is untouched. +- No `listSessions()` / provider inventory was added anywhere near typing, + focus, switch, resume, resize, or render paths. +- Remote file streaming correctness: per-chunk length checks, chunk-count and + byte-count invariants, cancel paths, and the 12MB round-trip integration + test all pass unchanged. + +## Regression Coverage + +- `src/relay/fs-stream-pty-echo-backpressure.integration.test.ts` — real + mux ↔ dispatcher ↔ FsHandler over a congestible in-memory pipe; asserts + deterministic BYTE bounds (not wall-clock): echo queues behind < 2 framed + chunks when congested (pre-fix: whole file), ack window caps in-flight + chunks, legacy no-ack clients still get complete streams. +- `src/relay/fs-handler-stream.test.ts` — pump parks at the ack window, + resumes per ack, and releases its file handle when cancelled while parked. +- `src/relay/dispatcher.test.ts` — notifyBulk semantics: drain gating, + per-client targeting, dispose releases parked senders, interactive + notify() not gated behind a stalled bulk lane. +- `src/main/ssh/relay-protocol.test.ts` — decoder byte-at-a-time boundary + straddling, oversized-frame resync across odd chunk sizes, and a + no-`Buffer.concat`-during-feed guard that locks out the O(n²) shape. +- `tests/e2e/ssh-docker-relay-perf.spec.ts` — new "busy relay" scenario: + types while two 8MB remote file-read loops and a git.status loop run; + budgets median < 500ms, worst < 2000ms. (On loopback Docker the pre-fix + HOL is only tens of ms — the in-process byte-bound test is the + authoritative red/green; the e2e guards the end-to-end wiring.) + +## Residual Gaps + +- **Large single-frame responses**: `git.*` responses (stdout capped at + `MAX_GIT_BUFFER` = 10MB → ~13MB framed) and other request/response results + are one atomic frame on the wire; a huge diff can still delay an echo by + its own transfer time. Fixing this requires response chunking (a protocol + change); bounded by the 16MB `MAX_MESSAGE_SIZE`. +- **Outbound direction**: a large `fs.writeFile` request frame (client → + relay) can queue ahead of keystroke `pty.data` frames on `channel.stdin`. + Same shape, opposite direction, rarer trigger (saving a large remote file + while typing). +- `pty.ackData` flow control for PTY output remains unenforced + (`src/relay/pty-handler.ts` — "not yet enforced"); PTY output floods are + already paced by the relay's 8ms batch/16KB slice scheduler. 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 27d3b7d88c6..d5844ff527a 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.22", + "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": 4 + "versionCode": 6 }, "plugins": [ "expo-router", diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index ad94114bd66..f47ec591057 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() diff --git a/mobile/app/h/[hostId]/history/[worktreeId].tsx b/mobile/app/h/[hostId]/history/[worktreeId].tsx index 8ac0eb8a89b..ce7309eff0f 100644 --- a/mobile/app/h/[hostId]/history/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/history/[worktreeId].tsx @@ -3,14 +3,15 @@ import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from ' 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 { useForceReconnect, useHostClient } from '../../../../src/transport/client-context' import type { RpcSuccess } from '../../../../src/transport/types' -import { colors, spacing, typography } from '../../../../src/theme/mobile-theme' +import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme' import { fetchMobileGitHistory, mapMobileCommitRows, type MobileCommitRow } from '../../../../src/source-control/mobile-git-history' +import { resolveMobileHistoryScreenView } from '../../../../src/source-control/mobile-history-screen-state' import type { GitBranchChangeEntry } from '../../../../../src/shared/types' function firstParam(value: string | string[] | undefined): string { @@ -27,9 +28,11 @@ export default function HistoryScreen() { const router = useRouter() const insets = useSafeAreaInsets() const { client, state: connState } = useHostClient(hostId) + 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>({}) @@ -57,7 +60,20 @@ export default function HistoryScreen() { return () => { active = false } - }, [client, connState, worktreeId]) + }, [client, connState, reloadNonce, 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) => { @@ -130,6 +146,12 @@ export default function HistoryScreen() { [expanded, filesById, toggleCommit] ) + const view = resolveMobileHistoryScreenView({ + connected: client !== null && connState === 'connected', + rows, + error + }) + return ( @@ -138,21 +160,26 @@ export default function HistoryScreen() { Commit History - {error ? ( + {view.kind === 'error' || view.kind === 'waiting' ? ( - {error} + + {view.kind === 'waiting' ? 'Waiting for desktop...' : view.message} + + + Retry + - ) : rows === null ? ( + ) : view.kind === 'loading' ? ( - ) : rows.length === 0 ? ( + ) : view.kind === 'empty' ? ( No commits. ) : ( row.id} contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }} @@ -175,6 +202,14 @@ const styles = StyleSheet.create({ 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 }, + 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', diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 994798712f4..c4701a302db 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, @@ -100,6 +101,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' @@ -1006,6 +1008,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 { + dismissTerminalKeyboard({ + clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef), + commandInput: commandInputRef.current, + dismissKeyboard: () => Keyboard.dismiss(), + liveInput: liveInputRef.current + }) + }, []) + const handleTerminalTap = useCallback( (handle: string) => { if (handle !== activeHandleRef.current) { @@ -4780,10 +4792,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" 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/scripts/mock-server-file-preview-data.ts b/mobile/scripts/mock-server-file-preview-data.ts index a64c76eda3b..9bf43471df6 100644 --- a/mobile/scripts/mock-server-file-preview-data.ts +++ b/mobile/scripts/mock-server-file-preview-data.ts @@ -21,6 +21,31 @@ const MOCK_FILE_LIST = [ { relativePath: 'dist/archive.zip', basename: 'archive.zip', kind: 'binary' } ] +function readMockDirectory(relativePath: string): Array<{ name: string; isDirectory: boolean }> { + const prefix = relativePath ? `${relativePath}/` : '' + const children = new Map() + for (const file of MOCK_FILE_LIST) { + if (!file.relativePath.startsWith(prefix)) { + continue + } + const rest = file.relativePath.slice(prefix.length) + if (!rest) { + continue + } + const [name, ...descendants] = rest.split('/') + if (!name) { + continue + } + children.set(name, children.get(name) === true || descendants.length > 0) + } + return Array.from(children, ([name, isDirectory]) => ({ name, isDirectory })).sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) +} + export function handleMockFilePreviewRequest( request: RpcRequest, respond: Respond, @@ -40,6 +65,10 @@ export function handleMockFilePreviewRequest( ) return true + case 'files.readDir': + respond(success(request.id, readMockDirectory(String(request.params?.relativePath ?? '')))) + return true + case 'files.read': { const relativePath = String(request.params?.relativePath ?? '') const content = MOCK_FILE_CONTENT[relativePath] diff --git a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx index e1b19263f54..2b9a9c78bca 100644 --- a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx +++ b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx @@ -2,13 +2,14 @@ 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 { ChevronLeft, ExternalLink, X } from 'lucide-react-native' import { colors, radii, spacing, typography } 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 { MobilePRSidebar } from '../MobilePRSidebar' +import { openMobilePrUrl } from '../MobilePrComposeSheet' type Props = { client: RpcClient | null @@ -73,6 +74,27 @@ export function MobilePrViewPanel({ message: 'Current branch unavailable.' } as const) : controller.prSidebarState + // Why: open-on-host lives in the chrome so the PR URL is always flush-right of + // the screen title, even when the body is scrolled past the PR header. + const prUrl = + sidebarState.kind === 'ready' && sidebarState.data.pr.url ? sidebarState.data.pr.url : null + const prNumber = sidebarState.kind === 'ready' ? sidebarState.data.pr.number : null + const openPr = prUrl ? () => openMobilePrUrl(prUrl) : undefined + const openPrControl = openPr ? ( + [styles.iconButton, pressed && styles.iconButtonPressed]} + onPress={openPr} + hitSlop={8} + accessibilityRole="link" + accessibilityLabel={ + prNumber != null + ? `Open pull request #${prNumber} on the web` + : 'Open pull request on the web' + } + > + + + ) : null const sidebar = ( Pull Request + {openPrControl} {sidebar} @@ -126,6 +149,7 @@ export function MobilePrViewPanel({ Pull Request + {openPrControl} {sidebar} diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx index e2f31f3cc41..d974089c326 100644 --- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx +++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx @@ -29,8 +29,8 @@ 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. + // Badge, #number, and the flush-right external-link control all open pr.url + // (canonical host web URL) in the phone browser. const openPr = pr.url ? () => openMobilePrUrl(pr.url) : undefined return ( diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts new file mode 100644 index 00000000000..0d003e9f456 --- /dev/null +++ b/mobile/src/files/MobileFileExplorerPanel.test.ts @@ -0,0 +1,398 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileFileExplorerPanel } from './MobileFileExplorerPanel' +import type { MobileDirEntry } from './file-tree' +import type { RpcResponse } from '../transport/types' + +type MockClient = { + sendRequest: ReturnType +} + +const mockTransport = vi.hoisted(() => ({ + client: null as MockClient | null, + connectionState: 'connected', + forceReconnect: vi.fn() +})) + +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + FlatList: (props: { + data: unknown[] + keyExtractor: (item: unknown, index: number) => string + renderItem: (info: { + item: unknown + index: number + separators: Record + }) => unknown + }) => + React.createElement( + 'FlatList', + props, + props.data.map((item, index) => + React.createElement( + 'FlatListItem', + { key: props.keyExtractor(item, index) }, + props.renderItem({ item, index, separators: {} }) + ) + ) + ), + Pressable: 'Pressable', + StyleSheet: { + create: (styles: unknown) => styles, + hairlineWidth: 1 + }, + Text: 'Text', + View: 'View' + } +}) + +vi.mock('react-native-safe-area-context', () => ({ + SafeAreaView: 'SafeAreaView' +})) + +vi.mock('expo-router', () => ({ + useRouter: () => ({ + back: vi.fn(), + push: vi.fn() + }) +})) + +vi.mock('lucide-react-native', () => ({ + ChevronDown: 'ChevronDown', + ChevronLeft: 'ChevronLeft', + ChevronRight: 'ChevronRight', + File: 'File', + FileText: 'FileText', + Folder: 'Folder', + Image: 'Image', + X: 'X' +})) + +vi.mock('../platform/haptics', () => ({ + triggerSelection: vi.fn() +})) + +vi.mock('../transport/client-context', () => ({ + useForceReconnect: () => mockTransport.forceReconnect, + useHostClient: () => ({ + client: mockTransport.client, + state: mockTransport.connectionState + }) +})) + +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = 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 () => consoleErrorSpy.mockRestore() +} + +function entry(name: string, isDirectory = false): MobileDirEntry { + return { name, isDirectory } +} + +function ok(result: MobileDirEntry[]): RpcResponse { + return { id: 'response-id', ok: true, result, _meta: { runtimeId: 'runtime-id' } } +} + +function createMockClient(entriesByPath: Record): MockClient { + return { + sendRequest: vi.fn(async (_method: string, params: { relativePath: string }) => { + return ok(entriesByPath[params.relativePath] ?? []) + }) + } +} + +async function renderExplorer(): Promise { + let renderer: ReactTestRenderer | null = null + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileFileExplorerPanel, { + hostId: 'host-a', + worktreeId: 'worktree-a', + name: 'Example Worktree', + embedded: true + }) + ) + }) + } finally { + restoreConsoleError() + } + if (!renderer) { + throw new Error('MobileFileExplorerPanel did not render') + } + return renderer +} + +async function updateExplorer(renderer: ReactTestRenderer): Promise { + await act(async () => { + renderer.update( + createElement(MobileFileExplorerPanel, { + hostId: 'host-a', + worktreeId: 'worktree-a', + name: 'Example Worktree', + embedded: true + }) + ) + }) +} + +async function pressByLabel( + renderer: ReactTestRenderer, + accessibilityLabel: string +): Promise { + const pressable = renderer.root + .findAllByType('Pressable') + .find((node) => node.props.accessibilityLabel === accessibilityLabel) + if (!pressable) { + throw new Error(`Unable to find pressable: ${accessibilityLabel}`) + } + await act(async () => { + pressable.props.onPress() + }) +} + +function renderedText(renderer: ReactTestRenderer): string { + return renderer.root + .findAllByType('Text') + .flatMap((node) => node.props.children) + .join(' ') +} + +describe('MobileFileExplorerPanel', () => { + afterEach(() => { + vi.restoreAllMocks() + mockTransport.client = null + mockTransport.connectionState = 'connected' + mockTransport.forceReconnect = vi.fn() + }) + + it('loads directories lazily and reuses cached children when reopened', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')], + src: [entry('app.ts'), entry('components', true)] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + + expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', { + worktree: 'id:worktree-a', + relativePath: '' + }) + + await pressByLabel(renderer, 'Open folder src') + await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledTimes(2)) + expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', { + worktree: 'id:worktree-a', + relativePath: 'src' + }) + expect(renderedText(renderer)).toContain('app.ts') + + await pressByLabel(renderer, 'Open folder src') + await pressByLabel(renderer, 'Open folder src') + + expect(client.sendRequest).toHaveBeenCalledTimes(2) + }) + + it('keeps the loaded tree visible during a transient disconnect', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + expect(renderedText(renderer)).toContain('src') + + mockTransport.client = null + mockTransport.connectionState = 'disconnected' + await updateExplorer(renderer) + + expect(client.sendRequest).toHaveBeenCalledTimes(1) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + expect(renderedText(renderer)).not.toContain('Waiting for desktop...') + }) + + it('refreshes the root in the background after reconnect without blanking the tree', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + expect(renderedText(renderer)).toContain('README.md') + + mockTransport.client = null + mockTransport.connectionState = 'disconnected' + await updateExplorer(renderer) + + let resolveReload: ((response: RpcResponse) => void) | undefined + const reconnectedClient: MockClient = { + sendRequest: vi.fn( + () => + new Promise((resolve) => { + resolveReload = resolve + }) + ) + } + mockTransport.client = reconnectedClient + mockTransport.connectionState = 'connected' + await updateExplorer(renderer) + + expect(reconnectedClient.sendRequest).toHaveBeenCalledTimes(1) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + + await act(async () => { + resolveReload?.(ok([entry('src', true), entry('README.md'), entry('CHANGELOG.md')])) + }) + expect(renderedText(renderer)).toContain('CHANGELOG.md') + }) + + it('keeps the cached tree when a post-reconnect root refresh fails', async () => { + const client = createMockClient({ + '': [entry('src', true), entry('README.md')] + }) + mockTransport.client = client + + const renderer = await renderExplorer() + expect(renderedText(renderer)).toContain('README.md') + + mockTransport.client = null + mockTransport.connectionState = 'disconnected' + await updateExplorer(renderer) + + const failingClient: MockClient = { + sendRequest: vi.fn(async () => { + throw new Error('refresh failed') + }) + } + mockTransport.client = failingClient + mockTransport.connectionState = 'connected' + await updateExplorer(renderer) + + expect(failingClient.sendRequest).toHaveBeenCalledTimes(1) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + expect(renderedText(renderer)).not.toContain('refresh failed') + }) + + it('falls back to the capped files.list against desktops without files.readDir', async () => { + const legacyClient: MockClient = { + sendRequest: vi.fn(async (method: string): Promise => { + if (method === 'files.readDir') { + return { + id: 'response-id', + ok: false, + error: { + code: 'forbidden', + message: "Method 'files.readDir' is not available to mobile clients" + }, + _meta: { runtimeId: 'runtime-id' } + } + } + return { + id: 'response-id', + ok: true, + result: { + files: [ + { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }, + { relativePath: 'README.md', basename: 'README.md', kind: 'text' } + ], + totalCount: 2, + truncated: false + }, + _meta: { runtimeId: 'runtime-id' } + } + }) + } + mockTransport.client = legacyClient + + const renderer = await renderExplorer() + + expect(legacyClient.sendRequest).toHaveBeenCalledWith('files.list', { + worktree: 'id:worktree-a' + }) + expect(renderedText(renderer)).toContain('src') + expect(renderedText(renderer)).toContain('README.md') + + await pressByLabel(renderer, 'Open folder src') + expect(renderedText(renderer)).toContain('app.ts') + // Every directory comes from the synthesized cache: one readDir attempt + // plus one files.list call total, no per-directory RPCs afterwards. + expect(legacyClient.sendRequest).toHaveBeenCalledTimes(2) + }) + + it('surfaces the legacy cap note when the files.list fallback is truncated', async () => { + const legacyClient: MockClient = { + sendRequest: vi.fn(async (method: string): Promise => { + if (method === 'files.readDir') { + return { + id: 'response-id', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime-id' } + } + } + return { + id: 'response-id', + ok: true, + result: { + files: [{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }], + totalCount: 6000, + truncated: true + }, + _meta: { runtimeId: 'runtime-id' } + } + }) + } + mockTransport.client = legacyClient + + const renderer = await renderExplorer() + + expect(renderedText(renderer)).toContain('README.md') + expect(renderedText(renderer)).toContain('Showing first 5000') + }) + + it('reports the files.list failure when the fallback itself fails', async () => { + const legacyClient: MockClient = { + sendRequest: vi.fn(async (method: string): Promise => { + if (method === 'files.readDir') { + return { + id: 'response-id', + ok: false, + error: { + code: 'forbidden', + message: "Method 'files.readDir' is not available to mobile clients" + }, + _meta: { runtimeId: 'runtime-id' } + } + } + return { + id: 'response-id', + ok: false, + error: { code: 'internal', message: 'legacy list failed' }, + _meta: { runtimeId: 'runtime-id' } + } + }) + } + mockTransport.client = legacyClient + + const renderer = await renderExplorer() + + expect(renderedText(renderer)).toContain('legacy list failed') + expect(renderedText(renderer)).not.toContain('not available to mobile clients') + }) +}) diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index 8192eb90020..7e67bf81b5f 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, FlatList, @@ -9,34 +9,33 @@ import { } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' -import { - ChevronDown, - ChevronLeft, - ChevronRight, - File, - FileText, - Folder, - Image as ImageIcon, - X -} from 'lucide-react-native' +import { ChevronLeft, X } from 'lucide-react-native' import { useHostClient, useForceReconnect } from '../transport/client-context' import { getWorktreeLabel } from '../session/worktree-label' import { - buildTree, - flattenTree, - isMarkdownPath, - type FilesListResult, - type MobileFileEntry, - type TreeNode + flattenDirectoryCache, + getDirectoryCacheState, + type DirectoryCache, + type FileExplorerRow, + type MobileDirEntry } from './file-tree' import type { RpcSuccess } from '../transport/types' -import { triggerSelection } from '../platform/haptics' -import { colors, spacing } from '../theme/mobile-theme' -import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' +import { colors } from '../theme/mobile-theme' import { - canPreviewMobileFileRow, - navigateToMobileFilePreview -} from './mobile-file-preview-navigation' + beginDirectoryLoad, + createDirectoryLoadRevisions, + isCurrentDirectoryLoad, + resetDirectoryLoadRevisions, + type DirectoryLoadRevisions +} from './directory-load-revisions' +import { + directoryCacheFromFileList, + isMobileMethodUnavailableError, + type LegacyFilesListResult +} from './file-list-fallback' +import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' +import { MobileFileExplorerRow } from './mobile-file-explorer-row' +import { navigateToMobileFilePreview } from './mobile-file-preview-navigation' export function MobileFileExplorerPanel(props: { hostId: string @@ -49,58 +48,217 @@ export function MobileFileExplorerPanel(props: { const router = useRouter() const { client, state: connState } = useHostClient(hostId) const forceReconnect = useForceReconnect() - const [files, setFiles] = useState([]) + const scopeRef = useRef('') + const scope = `${hostId}:${worktreeId}` + scopeRef.current = scope + const directoryLoadRevisionsRef = useRef(createDirectoryLoadRevisions()) + const pendingDirectoryRetriesRef = useRef>(new Set()) + const directoryCacheRef = useRef({}) + const [directoryCache, setDirectoryCache] = useState({}) const [expanded, setExpanded] = useState>(() => new Set()) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [truncated, setTruncated] = useState(false) + const [legacyListTruncated, setLegacyListTruncated] = useState(false) const worktreeLabel = getWorktreeLabel(name, worktreeId) - const loadFiles = useCallback(async () => { - if (!client || connState !== 'connected') { - setLoading(false) - setError(connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...') - return - } - setLoading(true) - setError(null) - try { - const response = await client.sendRequest('files.list', { worktree: `id:${worktreeId}` }) - if (!response.ok) { - throw new Error(response.error?.message || 'Unable to load files') + const loadDirectory = useCallback( + async (relativePath: string) => { + const scope = scopeRef.current + const loadToken = beginDirectoryLoad(directoryLoadRevisionsRef.current, scope, relativePath) + const rootLoad = relativePath === '' + + if (!client || connState !== 'connected') { + const message = + connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...' + if (rootLoad) { + const hasLoadedRoot = + (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0 + setLoading(false) + // Why: transient reconnects should not blank an already browsable tree. + setError(hasLoadedRoot ? null : message) + } else { + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + error: message + } + })) + } + return } - const result = (response as RpcSuccess).result as FilesListResult - setFiles(result.files) - setTruncated(result.truncated) - } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to load files') - } finally { - setLoading(false) - } - }, [client, connState, worktreeId]) + + const hadLoadedRoot = + rootLoad && (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0 + if (rootLoad) { + // Why: a reconnect refresh must not blank an already browsable tree — + // the full-screen spinner unmounts the list and resets scroll. + if (!hadLoadedRoot) { + setLoading(true) + } + setError(null) + } + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + loading: true + } + })) + + try { + const response = await client.sendRequest('files.readDir', { + worktree: `id:${worktreeId}`, + relativePath + }) + if (!response.ok) { + // Why: desktops that predate the files.readDir mobile allowlist + // entry still serve the capped files.list; fall back so the Files + // tab keeps working until the desktop updates. + if ( + rootLoad && + isMobileMethodUnavailableError(response.error?.code, response.error?.message) + ) { + const legacy = await client.sendRequest('files.list', { + worktree: `id:${worktreeId}` + }) + if (legacy.ok) { + if ( + !isCurrentDirectoryLoad( + directoryLoadRevisionsRef.current, + scopeRef.current, + loadToken + ) + ) { + return + } + const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult + setDirectoryCache(directoryCacheFromFileList(legacyResult.files)) + // Why: the capped list silently omits files past the cap — keep + // the legacy explorer's "Showing first 5000" note. + setLegacyListTruncated(legacyResult.truncated) + return + } + throw new Error( + legacy.error?.message || response.error?.message || 'Unable to load files' + ) + } + throw new Error(response.error?.message || 'Unable to load files') + } + if ( + !isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken) + ) { + return + } + const entries = (response as RpcSuccess).result as MobileDirEntry[] + if (rootLoad) { + setLegacyListTruncated(false) + } + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { entries } + })) + } catch (err) { + if ( + !isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken) + ) { + return + } + const message = err instanceof Error ? err.message : 'Unable to load files' + if (rootLoad) { + // Why: a failed background refresh keeps the cached tree browsable; + // only a cold load surfaces the full-screen error. + setError(hadLoadedRoot ? null : message) + } else { + setDirectoryCache((prev) => ({ + ...prev, + [relativePath]: { + entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [], + error: message + } + })) + } + } finally { + if ( + rootLoad && + isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken) + ) { + setLoading(false) + } + } + }, + [client, connState, worktreeId] + ) useEffect(() => { - void loadFiles() - }, [loadFiles]) + scopeRef.current = scope + resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current) + pendingDirectoryRetriesRef.current.clear() + directoryCacheRef.current = {} + setDirectoryCache({}) + setExpanded(new Set()) + setLoading(true) + setError(null) + setLegacyListTruncated(false) + }, [scope]) - const rows = useMemo(() => flattenTree(buildTree(files), expanded), [expanded, files]) + useEffect(() => { + directoryCacheRef.current = directoryCache + }, [directoryCache]) - const toggleDirectory = useCallback((relativePath: string) => { - triggerSelection() - setExpanded((prev) => { - const next = new Set(prev) - if (next.has(relativePath)) { - next.delete(relativePath) - } else { - next.add(relativePath) + useEffect(() => { + void loadDirectory('') + }, [hostId, loadDirectory]) + + useEffect(() => { + if (connState !== 'connected' || pendingDirectoryRetriesRef.current.size === 0) { + return + } + const pending = [...pendingDirectoryRetriesRef.current] + pendingDirectoryRetriesRef.current.clear() + for (const relativePath of pending) { + void loadDirectory(relativePath) + } + }, [connState, loadDirectory]) + + const rows = useMemo( + () => flattenDirectoryCache(directoryCache, expanded), + [directoryCache, expanded] + ) + + const toggleDirectory = useCallback( + (relativePath: string) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(relativePath)) { + next.delete(relativePath) + } else { + next.add(relativePath) + } + return next + }) + const state = getDirectoryCacheState(directoryCache, relativePath) + if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) { + void loadDirectory(relativePath) } - return next - }) - }, []) + }, + [directoryCache, expanded, loadDirectory] + ) + + const retryDirectory = useCallback( + (relativePath: string) => { + if (connState !== 'connected' && hostId) { + pendingDirectoryRetriesRef.current.add(relativePath) + void forceReconnect(hostId) + return + } + void loadDirectory(relativePath) + }, + [connState, forceReconnect, hostId, loadDirectory] + ) const previewFile = useCallback( (relativePath: string, displayName: string) => { - triggerSelection() navigateToMobileFilePreview( router, { @@ -116,66 +274,15 @@ export function MobileFileExplorerPanel(props: { [embedded, hostId, name, onRequestClose, router, worktreeId] ) - const renderItem: ListRenderItem = ({ item }) => { - const isDirectory = item.kind === 'directory' - const isExpanded = expanded.has(item.relativePath) - // Images render in the mobile viewer (via files.readPreview), so a binary - // image is openable; only non-previewable binaries are unavailable. - const previewable = - item.kind !== 'directory' && - canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath }) - const isImage = item.kind === 'binary' && previewable - const disabled = item.kind === 'binary' && !previewable - const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath) + const renderItem: ListRenderItem = ({ item }) => { return ( - [ - styles.row, - { paddingLeft: spacing.lg + item.depth * 18 }, - pressed && !disabled && styles.rowPressed, - disabled && styles.rowDisabled - ]} - disabled={disabled} - onPress={() => { - if (isDirectory) { - toggleDirectory(item.relativePath) - } else if (!disabled) { - previewFile(item.relativePath, item.name) - } - }} - accessibilityLabel={ - isDirectory - ? `Open folder ${item.name}` - : disabled - ? `${item.name} unavailable on mobile` - : `Preview file ${item.name}` - } - > - {isDirectory ? ( - isExpanded ? ( - - ) : ( - - ) - ) : ( - - )} - {isDirectory ? ( - - ) : markdown ? ( - - ) : isImage ? ( - - ) : ( - - )} - - - {item.name} - - {disabled ? Unavailable on mobile : null} - - + ) } @@ -206,7 +313,7 @@ export function MobileFileExplorerPanel(props: { {worktreeLabel} - {truncated ? ' - Showing first 5000' : ''} + {legacyListTruncated ? ' - Showing first 5000' : ''} @@ -220,12 +327,12 @@ export function MobileFileExplorerPanel(props: { {error} {/* Why: while disconnected, re-sending the request is useless — revive - the parked transport instead (issue #5049); loadFiles re-runs via + the parked transport instead (issue #5049); loadDirectory re-runs via its effect once the new client connects. */} - connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles() + connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadDirectory('') } > Retry diff --git a/mobile/src/files/directory-load-revisions.test.ts b/mobile/src/files/directory-load-revisions.test.ts new file mode 100644 index 00000000000..86ebfc3c0e8 --- /dev/null +++ b/mobile/src/files/directory-load-revisions.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + beginDirectoryLoad, + createDirectoryLoadRevisions, + isCurrentDirectoryLoad, + resetDirectoryLoadRevisions, + type DirectoryLoadRevisions +} from './directory-load-revisions' + +describe('directory-load-revisions', () => { + it('ignores stale duplicate loads for the same directory and scope', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const older = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src') + const newer = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src') + + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newer)).toBe(true) + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', older)).toBe(false) + }) + + it('ignores loads from an old host/worktree scope', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const oldScopeLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '') + + expect(isCurrentDirectoryLoad(revisions, 'host-b:worktree-b', oldScopeLoad)).toBe(false) + }) + + it('ignores loads from an old reset generation in the same scope', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const oldLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '') + + resetDirectoryLoadRevisions(revisions) + const newLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '') + + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newLoad)).toBe(true) + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', oldLoad)).toBe(false) + }) + + it('tracks directory names that overlap object prototype keys', () => { + const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions() + const load = beginDirectoryLoad(revisions, 'host-a:worktree-a', '__proto__') + + expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true) + }) +}) diff --git a/mobile/src/files/directory-load-revisions.ts b/mobile/src/files/directory-load-revisions.ts new file mode 100644 index 00000000000..04153092bbe --- /dev/null +++ b/mobile/src/files/directory-load-revisions.ts @@ -0,0 +1,42 @@ +export type DirectoryLoadRevisions = { + generation: number + revisionsByPath: Map +} + +export type DirectoryLoadToken = { + generation: number + relativePath: string + revision: number + scope: string +} + +export function createDirectoryLoadRevisions(): DirectoryLoadRevisions { + return { generation: 0, revisionsByPath: new Map() } +} + +export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions): void { + revisions.generation += 1 + revisions.revisionsByPath.clear() +} + +export function beginDirectoryLoad( + revisions: DirectoryLoadRevisions, + scope: string, + relativePath: string +): DirectoryLoadToken { + const revision = (revisions.revisionsByPath.get(relativePath) ?? 0) + 1 + revisions.revisionsByPath.set(relativePath, revision) + return { generation: revisions.generation, relativePath, revision, scope } +} + +export function isCurrentDirectoryLoad( + revisions: DirectoryLoadRevisions, + currentScope: string, + token: DirectoryLoadToken +): boolean { + return ( + currentScope === token.scope && + revisions.generation === token.generation && + revisions.revisionsByPath.get(token.relativePath) === token.revision + ) +} diff --git a/mobile/src/files/file-list-fallback.test.ts b/mobile/src/files/file-list-fallback.test.ts new file mode 100644 index 00000000000..a69affc5fe8 --- /dev/null +++ b/mobile/src/files/file-list-fallback.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback' +import { getDirectoryCacheState } from './file-tree' + +describe('isMobileMethodUnavailableError', () => { + it('detects old-desktop allowlist and missing-method failures', () => { + expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true) + expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true) + expect( + isMobileMethodUnavailableError( + 'some_code', + "Method 'files.readDir' is not available to mobile clients" + ) + ).toBe(true) + expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false) + expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false) + }) +}) + +describe('directoryCacheFromFileList', () => { + it('synthesizes every ancestor directory from flat paths', () => { + const cache = directoryCacheFromFileList([ + { relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' }, + { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }, + { relativePath: 'README.md', basename: 'README.md', kind: 'text' } + ]) + expect(cache['']?.entries).toEqual( + expect.arrayContaining([ + { name: 'src', isDirectory: true }, + { name: 'README.md', isDirectory: false } + ]) + ) + expect(cache['src']?.entries).toEqual( + expect.arrayContaining([ + { name: 'lib', isDirectory: true }, + { name: 'app.ts', isDirectory: false } + ]) + ) + expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }]) + }) + + it('keeps a name a directory when it appears as both file and dir prefix', () => { + const cache = directoryCacheFromFileList([ + { relativePath: 'src', basename: 'src', kind: 'text' }, + { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' } + ]) + expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }]) + }) + + it('returns an empty root for an empty list', () => { + const cache = directoryCacheFromFileList([]) + expect(cache['']?.entries).toEqual([]) + }) + + it('stores a __proto__ directory as an own key instead of mutating the prototype', () => { + const cache = directoryCacheFromFileList([ + { relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' } + ]) + expect(Object.getPrototypeOf(cache)).toBe(Object.prototype) + expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }]) + expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([ + { name: 'pollute.js', isDirectory: false } + ]) + }) +}) diff --git a/mobile/src/files/file-list-fallback.ts b/mobile/src/files/file-list-fallback.ts new file mode 100644 index 00000000000..ef3041029af --- /dev/null +++ b/mobile/src/files/file-list-fallback.ts @@ -0,0 +1,73 @@ +// Fallback for desktops that predate files.readDir in the mobile RPC +// allowlist: synthesize the lazy directory cache from the flat, capped +// files.list result so the Files tab stays browsable against old desktops. +import type { DirectoryCache, MobileDirEntry } from './file-tree' + +export type LegacyMobileFileEntry = { + relativePath: string + basename: string + kind: 'text' | 'binary' +} + +export type LegacyFilesListResult = { + files: LegacyMobileFileEntry[] + totalCount: number + truncated: boolean +} + +// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts: +// 'forbidden' = method exists but is not mobile-allowlisted on the old +// desktop; 'method_not_found' = desktop predates the method entirely. +export function isMobileMethodUnavailableError( + code: string | undefined, + message: string | undefined +): boolean { + return ( + code === 'forbidden' || + code === 'method_not_found' || + message?.includes('not available to mobile clients') === true + ) +} + +export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache { + const childrenByDir = new Map>() + const ensureDir = (path: string): Map => { + let children = childrenByDir.get(path) + if (!children) { + children = new Map() + childrenByDir.set(path, children) + } + return children + } + ensureDir('') + for (const file of files) { + const parts = file.relativePath.split('/').filter(Boolean) + let parentPath = '' + parts.forEach((name, index) => { + const isDirectory = index < parts.length - 1 + const children = ensureDir(parentPath) + children.set(name, children.get(name) === true || isDirectory) + parentPath = parentPath ? `${parentPath}/${name}` : name + if (isDirectory) { + ensureDir(parentPath) + } + }) + } + // Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the + // object's prototype instead of storing the directory; fromEntries always + // creates own keys. + return Object.fromEntries( + Array.from(childrenByDir, ([path, children]) => [ + path, + { + entries: Array.from( + children, + ([name, isDirectory]): MobileDirEntry => ({ + name, + isDirectory + }) + ) + } + ]) + ) +} diff --git a/mobile/src/files/file-tree.test.ts b/mobile/src/files/file-tree.test.ts index c4777ca2188..bdd9e9225cb 100644 --- a/mobile/src/files/file-tree.test.ts +++ b/mobile/src/files/file-tree.test.ts @@ -1,32 +1,109 @@ import { describe, expect, it } from 'vitest' -import { buildTree, flattenTree, isMarkdownPath, type MobileFileEntry } from './file-tree' +import { + flattenDirectoryCache, + getMobileFileKind, + isMarkdownPath, + shouldIncludeMobileFileExplorerEntry, + type DirectoryCache, + type MobileDirEntry +} from './file-tree' -function entry(relativePath: string, kind: 'text' | 'binary' = 'text'): MobileFileEntry { - return { relativePath, basename: relativePath.split('/').pop() ?? relativePath, kind } +function entry(name: string, isDirectory = false, isSymlink = false): MobileDirEntry { + return { name, isDirectory, isSymlink } } describe('file-tree', () => { - it('nests files under their directories', () => { - const root = buildTree([entry('src/app.ts'), entry('src/lib/util.ts'), entry('readme.md')]) - expect(root.files.map((f) => f.relativePath)).toEqual(['readme.md']) - expect(root.directories.get('src')?.directories.get('lib')?.files[0]?.relativePath).toBe( - 'src/lib/util.ts' - ) + it('flattens cached directories lazily with directories before files', () => { + const cache: DirectoryCache = { + '': { entries: [entry('zeta.txt'), entry('src', true), entry('readme.md')] }, + src: { entries: [entry('app.ts'), entry('lib', true)] }, + 'src/lib': { entries: [entry('util.ts')] } + } + + const collapsed = flattenDirectoryCache(cache, new Set()) + expect(collapsed.map((row) => row.id)).toEqual(['dir:src', 'file:readme.md', 'file:zeta.txt']) + + const expanded = flattenDirectoryCache(cache, new Set(['src', 'src/lib'])) + expect(expanded.map((row) => row.id)).toEqual([ + 'dir:src', + 'dir:src/lib', + 'file:src/lib/util.ts', + 'file:src/app.ts', + 'file:readme.md', + 'file:zeta.txt' + ]) }) - it('flattens with directories before files and only expands open dirs', () => { - const root = buildTree([entry('src/app.ts'), entry('zeta.txt')]) - const collapsed = flattenTree(root, new Set()) - expect(collapsed.map((r) => r.id)).toEqual(['dir:src', 'file:zeta.txt']) + it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => { + const cache: DirectoryCache = { + '': { + entries: [ + entry('.git', true), + entry('.env'), + entry('node_modules', true), + entry('src', true) + ] + } + } - const expanded = flattenTree(root, new Set(['src'])) - expect(expanded.map((r) => r.id)).toEqual(['dir:src', 'file:src/app.ts', 'file:zeta.txt']) + expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([ + 'dir:src', + 'file:.env' + ]) + expect(shouldIncludeMobileFileExplorerEntry(entry('.config', true))).toBe(true) }) - it('preserves the binary kind on flattened rows', () => { - const root = buildTree([entry('assets/logo.png', 'binary')]) - const rows = flattenTree(root, new Set(['assets'])) - expect(rows.find((r) => r.id === 'file:assets/logo.png')?.kind).toBe('binary') + it('renders inline loading and error rows under expanded directories', () => { + const cache: DirectoryCache = { + '': { entries: [entry('loading-dir', true), entry('error-dir', true)] }, + 'loading-dir': { entries: [], loading: true }, + 'error-dir': { entries: [], error: 'permission denied' } + } + + const rows = flattenDirectoryCache(cache, new Set(['loading-dir', 'error-dir'])) + expect(rows.map((row) => row.id)).toEqual([ + 'dir:error-dir', + 'error:error-dir', + 'dir:loading-dir', + 'loading:loading-dir' + ]) + expect(rows.find((row) => row.id === 'error:error-dir')).toMatchObject({ + kind: 'error', + message: 'permission denied' + }) + }) + + it('preserves symlink metadata for user-initiated activation', () => { + const cache: DirectoryCache = { + '': { entries: [entry('linked-docs', false, true)] } + } + + expect(flattenDirectoryCache(cache, new Set())).toEqual([ + { + id: 'file:linked-docs', + name: 'linked-docs', + relativePath: 'linked-docs', + depth: 0, + kind: 'text', + isSymlink: true + } + ]) + }) + + it('does not treat inherited object keys as loaded directories', () => { + const cache: DirectoryCache = { + '': { entries: [entry('constructor', true), entry('__proto__', true)] }, + ['__proto__']: { entries: [entry('file.txt')] } + } + + expect( + flattenDirectoryCache(cache, new Set(['constructor', '__proto__'])).map((row) => row.id) + ).toEqual(['dir:__proto__', 'file:__proto__/file.txt', 'dir:constructor']) + }) + + it('classifies binary file paths for existing mobile preview behavior', () => { + expect(getMobileFileKind('assets/logo.png')).toBe('binary') + expect(getMobileFileKind('src/app.ts')).toBe('text') }) it('detects markdown paths', () => { diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index e453cc152e4..a9283cfa457 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -1,91 +1,152 @@ -// Pure tree model for the mobile file explorer: turns the flat files.list -// result into a nested directory structure and flattens it into renderable -// rows. Kept out of the screen component so the screen stays under its line cap. +// Pure tree projection for the mobile file explorer. Mobile mirrors desktop +// browse semantics by flattening cached files.readDir results as folders open. -export type MobileFileEntry = { - relativePath: string - basename: string - kind: 'text' | 'binary' +export type MobileDirEntry = { + name: string + isDirectory: boolean + isSymlink?: boolean } -export type FilesListResult = { - files: MobileFileEntry[] - totalCount: number - truncated: boolean -} +export type MobileFileKind = 'text' | 'binary' export type TreeNode = { id: string name: string relativePath: string depth: number - kind: 'directory' | 'text' | 'binary' + kind: 'directory' | MobileFileKind + isSymlink?: boolean } -export type DirectoryNode = { - name: string +export type DirectoryState = { + entries: MobileDirEntry[] + loading?: boolean + error?: string +} + +export type DirectoryCache = Record + +export type InlineStatusNode = { + id: string relativePath: string - directories: Map - files: MobileFileEntry[] + depth: number + kind: 'loading' | 'error' + message?: string } -function createDirectoryNode(name: string, relativePath: string): DirectoryNode { - return { name, relativePath, directories: new Map(), files: [] } -} +export type FileExplorerRow = TreeNode | InlineStatusNode -export function buildTree(files: MobileFileEntry[]): DirectoryNode { - const root = createDirectoryNode('', '') - for (const file of files) { - const parts = file.relativePath.split('/').filter(Boolean) - let current = root - for (let index = 0; index < parts.length - 1; index += 1) { - const name = parts[index]! - const relativePath = parts.slice(0, index + 1).join('/') - let child = current.directories.get(name) - if (!child) { - child = createDirectoryNode(name, relativePath) - current.directories.set(name, child) - } - current = child - } - current.files.push(file) - } - return root -} +const DESKTOP_EXCLUDED_NAMES = new Set(['.git', 'node_modules']) +const BINARY_EXTENSIONS = new Set([ + '.avif', + '.bmp', + '.gif', + '.heic', + '.ico', + '.jpeg', + '.jpg', + '.mov', + '.mp3', + '.mp4', + '.pdf', + '.png', + '.webp', + '.zip' +]) -export function flattenTree(root: DirectoryNode, expanded: ReadonlySet): TreeNode[] { - const rows: TreeNode[] = [] - const visit = (directory: DirectoryNode, depth: number): void => { - const dirs = Array.from(directory.directories.values()).sort((a, b) => - a.name.localeCompare(b.name) - ) - for (const child of dirs) { - rows.push({ - id: `dir:${child.relativePath}`, - name: child.name, - relativePath: child.relativePath, - depth, - kind: 'directory' - }) - if (expanded.has(child.relativePath)) { - visit(child, depth + 1) - } - } - const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename)) - for (const file of files) { - rows.push({ - id: `file:${file.relativePath}`, - name: file.basename, - relativePath: file.relativePath, - depth, - kind: file.kind - }) - } - } - visit(root, 0) +export function flattenDirectoryCache( + cache: DirectoryCache, + expanded: ReadonlySet +): FileExplorerRow[] { + const rows: FileExplorerRow[] = [] + visitDirectory('', 0, cache, expanded, rows) return rows } +function visitDirectory( + relativePath: string, + depth: number, + cache: DirectoryCache, + expanded: ReadonlySet, + rows: FileExplorerRow[] +): void { + const state = getDirectoryCacheState(cache, relativePath) + const entries = state?.entries ?? [] + const visibleEntries = entries + .filter(shouldIncludeMobileFileExplorerEntry) + .sort(compareDirectoryEntries) + + for (const entry of visibleEntries) { + const childPath = joinRelativePath(relativePath, entry.name) + rows.push(toTreeNode(entry, childPath, depth)) + if (entry.isDirectory && expanded.has(childPath)) { + const childState = getDirectoryCacheState(cache, childPath) + if (childState?.loading) { + rows.push({ + id: `loading:${childPath}`, + relativePath: childPath, + depth: depth + 1, + kind: 'loading' + }) + } else if (childState?.error) { + rows.push({ + id: `error:${childPath}`, + relativePath: childPath, + depth: depth + 1, + kind: 'error', + message: childState.error + }) + } else { + visitDirectory(childPath, depth + 1, cache, expanded, rows) + } + } + } +} + +function toTreeNode(entry: MobileDirEntry, relativePath: string, depth: number): TreeNode { + return { + id: `${entry.isDirectory ? 'dir' : 'file'}:${relativePath}`, + name: entry.name, + relativePath, + depth, + kind: entry.isDirectory ? 'directory' : getMobileFileKind(relativePath), + isSymlink: entry.isSymlink + } +} + +function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) +} + +export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean { + return !DESKTOP_EXCLUDED_NAMES.has(entry.name) +} + +export function getDirectoryCacheState( + cache: DirectoryCache, + relativePath: string +): DirectoryState | undefined { + // Why: repository paths are arbitrary object keys; inherited keys like + // "constructor" must not masquerade as loaded directory state. + return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined +} + +export function joinRelativePath(parentPath: string, name: string): string { + return parentPath ? `${parentPath}/${name}` : name +} + +export function getMobileFileKind(relativePath: string): MobileFileKind { + const basename = relativePath.split('/').pop() ?? relativePath + const dotIndex = basename.lastIndexOf('.') + if (dotIndex <= 0) { + return 'text' + } + return BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) ? 'binary' : 'text' +} + export function isMarkdownPath(relativePath: string): boolean { return /\.(md|mdx|markdown)$/i.test(relativePath) } diff --git a/mobile/src/files/mobile-file-explorer-row.tsx b/mobile/src/files/mobile-file-explorer-row.tsx new file mode 100644 index 00000000000..36f74ad3b53 --- /dev/null +++ b/mobile/src/files/mobile-file-explorer-row.tsx @@ -0,0 +1,145 @@ +import { ActivityIndicator, Pressable, Text, View } from 'react-native' +import { + ChevronDown, + ChevronRight, + File, + FileText, + Folder, + Image as ImageIcon +} from 'lucide-react-native' +import { triggerSelection } from '../platform/haptics' +import { colors, spacing } from '../theme/mobile-theme' +import { type FileExplorerRow, isMarkdownPath, type TreeNode } from './file-tree' +import { fileExplorerStyles as styles } from './mobile-file-explorer-styles' +import { canPreviewMobileFileRow } from './mobile-file-preview-navigation' + +type Props = { + item: FileExplorerRow + expanded: ReadonlySet + onPreviewFile: (relativePath: string, displayName: string) => void + onRetryDirectory: (relativePath: string) => void + onToggleDirectory: (relativePath: string) => void +} + +export function MobileFileExplorerRow(props: Props) { + const { item, expanded, onPreviewFile, onRetryDirectory, onToggleDirectory } = props + + if (item.kind === 'loading') { + return ( + + + + Loading... + + ) + } + + if (item.kind === 'error') { + return ( + + + + {item.message || 'Unable to load folder'} + + [styles.inlineRetryButton, pressed && styles.rowPressed]} + onPress={() => { + triggerSelection() + onRetryDirectory(item.relativePath) + }} + accessibilityLabel={`Retry loading ${item.relativePath}`} + > + Retry + + + ) + } + + if (isTreeNode(item)) { + return ( + + ) + } + + return null +} + +function isTreeNode(item: FileExplorerRow): item is TreeNode { + return item.kind === 'directory' || item.kind === 'text' || item.kind === 'binary' +} + +function TreeRow(props: { + item: TreeNode + expanded: ReadonlySet + onPreviewFile: (relativePath: string, displayName: string) => void + onToggleDirectory: (relativePath: string) => void +}) { + const { item, expanded, onPreviewFile, onToggleDirectory } = props + const isDirectory = item.kind === 'directory' + const isExpanded = expanded.has(item.relativePath) + // Images render in the mobile viewer (via files.readPreview), so a binary + // image is openable; only non-previewable binaries are unavailable. + const previewable = + item.kind !== 'directory' && + canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath }) + const isImage = item.kind === 'binary' && previewable + const disabled = item.kind === 'binary' && !previewable + const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath) + + return ( + [ + styles.row, + { paddingLeft: spacing.lg + item.depth * 18 }, + pressed && !disabled && styles.rowPressed, + disabled && styles.rowDisabled + ]} + disabled={disabled} + onPress={() => { + triggerSelection() + if (isDirectory) { + onToggleDirectory(item.relativePath) + } else if (!disabled) { + onPreviewFile(item.relativePath, item.name) + } + }} + accessibilityLabel={ + isDirectory + ? `Open folder ${item.name}` + : disabled + ? `${item.name} unavailable on mobile` + : `Preview file ${item.name}` + } + > + {isDirectory ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + {isDirectory ? ( + + ) : markdown ? ( + + ) : isImage ? ( + + ) : ( + + )} + + + {item.name} + + {disabled ? Unavailable on mobile : null} + + + ) +} diff --git a/mobile/src/files/mobile-file-explorer-styles.ts b/mobile/src/files/mobile-file-explorer-styles.ts index 339dd5519de..5df9710be10 100644 --- a/mobile/src/files/mobile-file-explorer-styles.ts +++ b/mobile/src/files/mobile-file-explorer-styles.ts @@ -78,6 +78,37 @@ export const fileExplorerStyles = StyleSheet.create({ color: colors.textMuted, fontSize: 11 }, + inlineStatusRow: { + minHeight: 36, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingRight: spacing.md + }, + inlineStatusText: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + inlineErrorText: { + flex: 1, + minWidth: 0, + color: colors.statusRed, + fontSize: typography.metaSize + }, + inlineRetryButton: { + minHeight: 28, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md + }, + inlineRetryText: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '600' + }, state: { flex: 1, alignItems: 'center', diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index 5f43ffcf0e0..a1028cf9205 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -1,6 +1,3 @@ -/* oxlint-disable max-lines -- Why: mobile dictation keeps permission, recording, - * chunk upload, completion, and cancellation in one hook so native audio state - * cannot drift from the runtime RPC lifecycle. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' // Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro // can't resolve Node's builtin in a React Native bundle. 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/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index 1b50db62479..3f1309fad97 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -16,6 +16,7 @@ type Props = { export function MobileSourceControlContent({ state }: Props) { const { insets, + connState, busyAction, commitMessage, setCommitMessage, @@ -56,6 +57,15 @@ export function MobileSourceControlContent({ state }: Props) { return ( <> + {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} 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-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/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/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..2b8461c582f 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -133,6 +133,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 } } @@ -406,6 +411,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/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 8faad00f187..e6ad02b2d04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.124-rc.9", + "version": "1.4.131-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -19,6 +19,7 @@ "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", + "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs", "vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs", @@ -41,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", @@ -80,12 +82,14 @@ "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", "bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs", "bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs", "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs", + "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs", "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs" }, "dependencies": { 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/src/cli/browser.test.ts b/src/cli/browser.test.ts index 7ca2c509a57..83ca43d4859 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: this file groups every CLI browser-command test (page targeting, profiles, waits, viewport) so test-fixture imports and the runtime-client mock stay shared in one place. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() diff --git a/src/cli/handlers/file.test.ts b/src/cli/handlers/file.test.ts index fa03e286ce2..4e36ddbfff2 100644 --- a/src/cli/handlers/file.test.ts +++ b/src/cli/handlers/file.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: file CLI coverage shares one mocked runtime setup across command contracts. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() 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 3cd61db5556..550af50bc42 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: this fixture verifies the shared remote hook installer fake across every managed agent so SSH regressions are caught together. */ import { describe, expect, it, vi } from 'vitest' import type { SFTPWrapper } from 'ssh2' @@ -9,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' @@ -21,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 @@ -167,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') } ] @@ -583,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/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 46ff181a73d..91b0dc66051 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -13,7 +13,12 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { AgentHookServer, agentHookServer, _internals } from './server' +import { + AgentHookServer, + agentHookServer, + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + _internals +} from './server' import { AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_STATUS_STALE_AFTER_MS, @@ -6848,3 +6853,23 @@ describe('AgentHookServer ingestTerminalStatus', () => { expect(server.getStatusSnapshot()).toEqual([]) }) }) + +describe('AgentHookServer closed-tab suppression bound', () => { + it('bounds closedAgentStatusTabIds with LRU eviction as tabs close', () => { + const server = new AgentHookServer() + const internals = server as unknown as { + markTabClosedForAgentStatus: (tabId: string) => void + closedAgentStatusTabIds: Set + } + + const total = CLOSED_AGENT_STATUS_TAB_IDS_MAX + 200 + for (let i = 0; i < total; i += 1) { + internals.markTabClosedForAgentStatus(`closed-tab-${i}`) + } + + // Set stays bounded; oldest ids are evicted, most-recent are retained. + expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX) + expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false) + expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index d4cc443d8ab..167331765cf 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -119,6 +119,12 @@ const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) // not resurrect on hydrate. const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 +// Why: closed-tab suppression only needs to cover recently closed tabs — a +// status event for a long-closed tab cannot arrive once its process/hooks are +// gone. Bound the set so it can't grow one entry per tab close for the whole +// session (it is otherwise only cleared at app quit). +export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 + type LastStatusFile = { version: number entries: Record @@ -596,7 +602,17 @@ export class AgentHookServer { } private markTabClosedForAgentStatus(tabId: string): void { + // Delete-then-add keeps recently closed tabs most-recent so eviction only + // sheds the oldest ids, which can no longer receive status events. + this.closedAgentStatusTabIds.delete(tabId) this.closedAgentStatusTabIds.add(tabId) + while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + const oldest = this.closedAgentStatusTabIds.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusTabIds.delete(oldest) + } } private shouldSuppressClosedTabStatus(paneKey: string): boolean { 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 85b33a962d6..5d7d1df939e 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -7,7 +7,11 @@ import { type AiVaultSessionPreviewMessage } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' -import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' import { extractPreviewContentText, extractString, @@ -37,10 +41,37 @@ export function createAccumulator(args: { messageCount: 0, totalTokens: 0, previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, latestTimestampMs: 0 } } +export function cloneSessionAccumulator(accumulator: SessionAccumulator): SessionAccumulator { + return { ...accumulator, previewMessages: [...accumulator.previewMessages] } +} + +// Resumable fold for parsers whose only parse state is the accumulator itself +// (cursor, copilot, droid, openclaw/pi, gemini-jsonl). Parsers with extra +// closure state (claude, codex) build their own ResumableSessionParseState. +export function accumulatorFoldResumeState( + accumulator: SessionAccumulator, + consumeRecordLine: (accumulator: SessionAccumulator, line: string) => void +): ResumableSessionParseState { + return { + consumeLine: (line) => consumeRecordLine(accumulator, line), + clone: () => + accumulatorFoldResumeState(cloneSessionAccumulator(accumulator), consumeRecordLine), + touchFile: (file) => { + accumulator.modifiedAt = file.modifiedAt + }, + // Finalize a snapshot: the live accumulator (and its preview array) keeps + // accumulating appended lines after this session object is handed out. + finalize: (platform, options) => + finalizeSession(cloneSessionAccumulator(accumulator), platform, options) + } +} + export function finalizeSession( accumulator: SessionAccumulator, platform: NodeJS.Platform, @@ -81,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-fixtures.ts b/src/main/ai-vault/session-scanner-codex-fixtures.ts new file mode 100644 index 00000000000..ffdab7f1dd1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-fixtures.ts @@ -0,0 +1,92 @@ +import type { IncrementalAgentFixture } from './session-scanner-incremental-fixtures' + +// Codex fixture lines for the incremental-parse differential tests, split out +// of session-scanner-incremental-fixtures.ts to respect the max-lines budget. + +const CODEX_SESSION_ID = '019f0000-1111-7222-8333-444444444444' + +function codexLine(record: Record): string { + return JSON.stringify(record) +} + +export function codexFixture(): IncrementalAgentFixture { + return { + agent: 'codex', + fileName: `rollout-2026-05-01T10-00-00-${CODEX_SESSION_ID}.jsonl`, + seedLines: [ + codexLine({ + timestamp: '2026-05-01T10:00:00.000Z', + type: 'session_meta', + payload: { id: CODEX_SESSION_ID, cwd: '/repo/app', git: { branch: 'feature/vault' } } + }), + codexLine({ + timestamp: '2026-05-01T10:00:05.000Z', + type: 'response_item', + payload: { type: 'message', role: 'user', content: 'codex seed question' } + }), + codexLine({ + timestamp: '2026-05-01T10:00:10.000Z', + type: 'response_item', + payload: { type: 'message', role: 'assistant', content: 'codex seed answer' } + }), + codexLine({ + timestamp: '2026-05-01T10:00:11.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { total_token_usage: { input_tokens: 100, output_tokens: 40, total_tokens: 140 } }, + model: 'gpt-5.1-codex' + } + }) + ], + appendLines: [ + codexLine({ + timestamp: '2026-05-01T10:05:00.000Z', + type: 'event_msg', + payload: { type: 'user_message', message: 'codex follow-up' } + }), + codexLine({ + timestamp: '2026-05-01T10:05:20.000Z', + type: 'event_msg', + payload: { type: 'agent_message', message: 'codex incremental answer' } + }), + codexLine({ + timestamp: '2026-05-01T10:05:21.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { total_token_usage: { input_tokens: 220, output_tokens: 90, total_tokens: 310 } } + } + }) + ], + truncatedLines: [ + codexLine({ + timestamp: '2026-05-01T10:00:00.000Z', + type: 'session_meta', + payload: { id: CODEX_SESSION_ID, cwd: '/repo/app' } + }), + codexLine({ + timestamp: '2026-05-01T10:00:05.000Z', + type: 'event_msg', + payload: { type: 'user_message', message: 'rewritten only turn' } + }) + ] + } +} + +export function codexWorkerFixtureLines(): string[] { + return [ + codexLine({ + timestamp: '2026-05-01T10:00:00.000Z', + type: 'session_meta', + payload: { id: CODEX_SESSION_ID, cwd: '/repo/app', thread_source: 'subagent' } + }), + codexLine({ + timestamp: '2026-05-01T10:00:05.000Z', + type: 'event_msg', + payload: { type: 'user_message', message: 'worker turn' } + }) + ] +} + +export const CODEX_FIXTURE_SESSION_ID = CODEX_SESSION_ID diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index b18721a8296..5127720b141 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -1,18 +1,25 @@ import { createReadStream } from 'node:fs' -import { stat } from 'node:fs/promises' -import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' +import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index' import type { ExecutionHostId } from '../../shared/execution-host' import { addPreviewContent, + cloneSessionAccumulator, createAccumulator, finalizeSession, sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' -import type { CodexUsageSnapshot, FileWithMtime } from './session-scanner-types' +import type { + CodexUsageSnapshot, + FileWithMtime, + ResumableParseFinalizeOptions, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' import { + addCodexUsage, asRecord, extractContentText, extractGitBranch, @@ -24,15 +31,6 @@ import { subtractCodexUsage } from './session-scanner-values' -const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' - -type CodexSessionIndexTitleCacheEntry = { - signature: string - titles: Map -} - -const codexSessionIndexTitleCache = new Map>() - export async function parseCodexSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform, @@ -74,6 +72,213 @@ export async function parseCodexSessionContent(args: { }) } +type CodexSessionParseState = { + accumulator: SessionAccumulator + previousTotals: CodexUsageSnapshot | null + rejectedWorkerSession: boolean + sawSessionMeta: boolean + // Which source set the current title; an index-file title outranks the raw + // first user prompt, so finalize must know whether 'meta' already won. + titleSource: 'meta' | 'user' | null +} + +function createCodexParseState(file: FileWithMtime): CodexSessionParseState { + return { + accumulator: createAccumulator({ + agent: 'codex', + file, + sessionId: sessionIdFromFileName(file.path) + }), + previousTotals: null, + rejectedWorkerSession: false, + sawSessionMeta: false, + titleSource: null + } +} + +function cloneCodexParseState(state: CodexSessionParseState): CodexSessionParseState { + return { + // previousTotals snapshots are replaced, never mutated, so sharing is safe. + ...state, + accumulator: cloneSessionAccumulator(state.accumulator) + } +} + +function consumeCodexRecordLine(state: CodexSessionParseState, line: string): void { + if (state.rejectedWorkerSession) { + return + } + const record = parseJsonObject(line) + if (!record) { + return + } + const { accumulator } = state + + updateTimeline(accumulator, extractString(record.timestamp)) + + const payload = asRecord(record.payload) + if (record.type === 'session_meta' && payload) { + if (isCodexWorkerSession(payload)) { + // Why: Codex writes internal worker/sub-agent transcripts into the same + // history tree; AI Vault should show user-started sessions only. + state.rejectedWorkerSession = true + return + } + state.sawSessionMeta = true + const sessionId = extractString(payload.id) + if (sessionId) { + accumulator.sessionId = sessionId + } + const metadataTitle = extractCodexSessionMetadataTitle(payload) + if (metadataTitle) { + accumulator.title = metadataTitle + state.titleSource = 'meta' + } + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch + return + } + + if (record.type === 'turn_context' && payload) { + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + return + } + + if (!payload) { + return + } + + if (record.type === 'response_item' && payload.type === 'message') { + accumulator.messageCount++ + if (payload.role === 'user' && !accumulator.title) { + accumulator.title = extractContentText(payload.content) + state.titleSource = accumulator.title ? 'user' : state.titleSource + } + addPreviewContent( + accumulator, + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', + payload.content, + record.timestamp + ) + return + } + + if (record.type !== 'event_msg') { + return + } + + if (payload.type === 'user_message') { + accumulator.messageCount++ + if (!accumulator.title) { + accumulator.title = extractContentText(payload.message) + state.titleSource = accumulator.title ? 'user' : state.titleSource + } + addPreviewContent(accumulator, 'user', payload.message, record.timestamp) + return + } + + if (payload.type === 'agent_message') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) + return + } + + if (payload.type !== 'token_count') { + return + } + + const info = asRecord(payload.info) + if (!info) { + return + } + const totalUsage = normalizeCodexUsage(info.total_token_usage) + const lastUsage = normalizeCodexUsage(info.last_token_usage) + let delta: CodexUsageSnapshot | null = null + if (totalUsage) { + delta = subtractCodexUsage(totalUsage, state.previousTotals) + state.previousTotals = totalUsage + } else if (lastUsage) { + delta = lastUsage + state.previousTotals = state.previousTotals + ? addCodexUsage(state.previousTotals, lastUsage) + : lastUsage + } + if (delta) { + accumulator.totalTokens += delta.totalTokens + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } +} + +async function finalizeCodexParseState( + state: CodexSessionParseState, + platform: NodeJS.Platform, + args: { + codexHome: string | null + titleReader?: (sessionId: string) => Promise + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null + } +): Promise { + if (state.rejectedWorkerSession) { + return null + } + // Finalize a snapshot: the live state keeps accumulating appended lines. + const snapshot = cloneCodexParseState(state) + // Why: Codex names threads lazily in session_index.jsonl, so the lookup runs + // per finalize (the index read is signature-cached) — a title that appears + // after the transcript was first parsed must still replace the raw prompt. + if (snapshot.sawSessionMeta && snapshot.titleSource !== 'meta') { + const indexedTitle = await args.titleReader?.(snapshot.accumulator.sessionId) + if (indexedTitle) { + snapshot.accumulator.title = indexedTitle + } + } + return finalizeSession(snapshot.accumulator, platform, { + codexHome: args.codexHome, + executionHostId: args.executionHostId, + executionHostPlatform: args.executionHostPlatform + }) +} + +export function createCodexSessionResumeState( + file: FileWithMtime, + codexHome: string | null +): ResumableSessionParseState { + return codexResumeStateFromParseState(createCodexParseState(file), codexHome, (sessionId) => + readCodexSessionIndexTitle(file.path, codexHome, sessionId) + ) +} + +function codexResumeStateFromParseState( + state: CodexSessionParseState, + codexHome: string | null, + titleReader: (sessionId: string) => Promise +): ResumableSessionParseState { + return { + consumeLine: (line) => consumeCodexRecordLine(state, line), + clone: () => + codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader), + touchFile: (file) => { + state.accumulator.modifiedAt = file.modifiedAt + }, + finalize: (platform, options?: ResumableParseFinalizeOptions) => + finalizeCodexParseState(state, platform, { codexHome, titleReader, ...options }) + } +} + async function parseCodexSessionLines(args: { file: FileWithMtime lines: AsyncIterable | Iterable @@ -83,142 +288,22 @@ async function parseCodexSessionLines(args: { executionHostPlatform?: NodeJS.Platform | null titleReader?: (sessionId: string) => Promise }): Promise { - const accumulator = createAccumulator({ - agent: 'codex', - file: args.file, - sessionId: sessionIdFromFileName(args.file.path) - }) - let previousTotals: CodexUsageSnapshot | null = null - + const state = createCodexParseState(args.file) for await (const line of args.lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - - updateTimeline(accumulator, extractString(record.timestamp)) - - const payload = asRecord(record.payload) - if (record.type === 'session_meta' && payload) { - if (isCodexWorkerSession(payload)) { - // Why: Codex writes internal worker/sub-agent transcripts into the same - // history tree; AI Vault should show user-started sessions only. - return null - } - const sessionId = extractString(payload.id) - if (sessionId) { - accumulator.sessionId = sessionId - } - const indexedTitle = - extractCodexSessionMetadataTitle(payload) ?? - (await args.titleReader?.(accumulator.sessionId)) - if (indexedTitle) { - accumulator.title = indexedTitle - } - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch - continue - } - - if (record.type === 'turn_context' && payload) { - const cwd = extractString(payload.cwd) - if (cwd) { - accumulator.cwd = cwd - } - const model = extractModel(payload) - if (model) { - accumulator.model = model - } - continue - } - - if (!payload) { - continue - } - - if (record.type === 'response_item' && payload.type === 'message') { - accumulator.messageCount++ - if (payload.role === 'user' && !accumulator.title) { - accumulator.title = extractContentText(payload.content) - } - addPreviewContent( - accumulator, - payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', - payload.content, - record.timestamp - ) - continue - } - - if (record.type !== 'event_msg') { - continue - } - - if (payload.type === 'user_message') { - accumulator.messageCount++ - if (!accumulator.title) { - accumulator.title = extractContentText(payload.message) - } - addPreviewContent(accumulator, 'user', payload.message, record.timestamp) - continue - } - - if (payload.type === 'agent_message') { - accumulator.messageCount++ - addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) - continue - } - - if (payload.type !== 'token_count') { - continue - } - - const info = asRecord(payload.info) - if (!info) { - continue - } - const totalUsage = normalizeCodexUsage(info.total_token_usage) - const lastUsage = normalizeCodexUsage(info.last_token_usage) - let delta: CodexUsageSnapshot | null = null - if (totalUsage) { - delta = subtractCodexUsage(totalUsage, previousTotals) - previousTotals = totalUsage - } else if (lastUsage) { - delta = lastUsage - previousTotals = previousTotals ? addCodexUsage(previousTotals, lastUsage) : lastUsage - } - if (delta) { - accumulator.totalTokens += delta.totalTokens - } - const model = extractModel(payload) - if (model) { - accumulator.model = model + consumeCodexRecordLine(state, line) + if (state.rejectedWorkerSession) { + // Worker transcripts are excluded outright; stop reading early. + return null } } - - return finalizeSession(accumulator, args.platform, { + return finalizeCodexParseState(state, args.platform, { codexHome: args.codexHome, + titleReader: args.titleReader, executionHostId: args.executionHostId, executionHostPlatform: args.executionHostPlatform }) } -function addCodexUsage( - base: CodexUsageSnapshot, - increment: CodexUsageSnapshot -): CodexUsageSnapshot { - return { - inputTokens: base.inputTokens + increment.inputTokens, - cachedInputTokens: base.cachedInputTokens + increment.cachedInputTokens, - outputTokens: base.outputTokens + increment.outputTokens, - reasoningOutputTokens: base.reasoningOutputTokens + increment.reasoningOutputTokens, - totalTokens: base.totalTokens + increment.totalTokens - } -} - function extractCodexThreadSource(payload: Record): string | null { return extractString(payload.thread_source) ?? extractString(payload.threadSource) } @@ -240,79 +325,3 @@ function extractCodexSessionMetadataTitle(payload: Record): str normalizeTitleText(extractString(payload.threadName) ?? '') ) } - -async function readCodexSessionIndexTitle( - sessionFilePath: string, - codexHome: string | null, - sessionId: string -): Promise { - const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath) - if (!resolvedCodexHome) { - return null - } - const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome) - return titleBySessionId.get(sessionId) ?? null -} - -function codexHomeFromSessionFilePath(sessionFilePath: string): string | null { - let currentDir = dirname(sessionFilePath) - while (currentDir && dirname(currentDir) !== currentDir) { - if (basename(currentDir) === 'sessions') { - return dirname(currentDir) - } - currentDir = dirname(currentDir) - } - return null -} - -async function readCodexSessionIndexTitles(codexHome: string): Promise> { - const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE) - let signature: string - try { - const indexStat = await stat(indexPath) - signature = `${indexStat.size}:${indexStat.mtimeMs}` - } catch { - return new Map() - } - - const cached = codexSessionIndexTitleCache.get(codexHome) - if (cached) { - const entry = await cached - if (entry.signature === signature) { - return entry.titles - } - } - - const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({ - signature, - titles - })) - codexSessionIndexTitleCache.set(codexHome, pending) - return (await pending).titles -} - -async function readCodexSessionIndexTitlesFromDisk( - indexPath: string -): Promise> { - const titleBySessionId = new Map() - try { - const lines = createInterface({ - input: createReadStream(indexPath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - for await (const line of lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - const sessionId = extractString(record.id) - const title = normalizeTitleText(extractString(record.thread_name) ?? '') - if (sessionId && title) { - titleBySessionId.set(sessionId, title) - } - } - } catch { - // Codex creates the index opportunistically; older homes may only have raw transcripts. - } - return titleBySessionId -} diff --git a/src/main/ai-vault/session-scanner-codex-title-index.ts b/src/main/ai-vault/session-scanner-codex-title-index.ts new file mode 100644 index 00000000000..d22f964f538 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-title-index.ts @@ -0,0 +1,93 @@ +import { createReadStream } from 'node:fs' +import { stat } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { createInterface } from 'node:readline' +import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' + +// Codex names threads lazily in /session_index.jsonl; transcripts +// carry no title of their own, so parsers look the thread name up here. + +const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' + +type CodexSessionIndexTitleCacheEntry = { + signature: string + titles: Map +} + +const codexSessionIndexTitleCache = new Map>() + +export async function readCodexSessionIndexTitle( + sessionFilePath: string, + codexHome: string | null, + sessionId: string +): Promise { + const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath) + if (!resolvedCodexHome) { + return null + } + const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome) + return titleBySessionId.get(sessionId) ?? null +} + +function codexHomeFromSessionFilePath(sessionFilePath: string): string | null { + let currentDir = dirname(sessionFilePath) + while (currentDir && dirname(currentDir) !== currentDir) { + if (basename(currentDir) === 'sessions') { + return dirname(currentDir) + } + currentDir = dirname(currentDir) + } + return null +} + +async function readCodexSessionIndexTitles(codexHome: string): Promise> { + const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE) + let signature: string + try { + const indexStat = await stat(indexPath) + signature = `${indexStat.size}:${indexStat.mtimeMs}` + } catch { + return new Map() + } + + const cached = codexSessionIndexTitleCache.get(codexHome) + if (cached) { + const entry = await cached + if (entry.signature === signature) { + return entry.titles + } + } + + const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({ + signature, + titles + })) + codexSessionIndexTitleCache.set(codexHome, pending) + return (await pending).titles +} + +async function readCodexSessionIndexTitlesFromDisk( + indexPath: string +): Promise> { + const titleBySessionId = new Map() + try { + const lines = createInterface({ + input: createReadStream(indexPath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + const sessionId = extractString(record.id) + const title = normalizeTitleText(extractString(record.thread_name) ?? '') + if (sessionId && title) { + titleBySessionId.set(sessionId, title) + } + } + } catch { + // Codex creates the index opportunistically; older homes may only have raw transcripts. + } + return titleBySessionId +} 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-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index f5541ca39c2..9bfade6e665 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -25,7 +25,8 @@ export async function discoverFiles(args: { files.push({ path, mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString() + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size }) } catch (err) { args.issues.push({ agent: args.agent, path, message: errorMessage(err) }) diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts index d99795bbbd3..2abaa7b8193 100644 --- a/src/main/ai-vault/session-scanner-droid-parser.ts +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -2,11 +2,15 @@ import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' import { + accumulatorFoldResumeState, addPreviewMessage, createAccumulator, - finalizeSession, sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' @@ -50,51 +54,57 @@ export async function parseDroidSessionContent( }) } +function consumeDroidRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + updateTimeline(accumulator, record.timestamp) + if (record.type === 'session_start') { + accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId + accumulator.title = normalizeTitleText(extractString(record.title) ?? '') + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + return + } + if (record.type === 'system') { + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + accumulator.model = extractString(record.model) ?? accumulator.model + } + const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId) + if (streamSessionId) { + accumulator.sessionId = streamSessionId + } + if (record.type === 'message') { + consumeDroidMessage(accumulator, record) + } else if (record.type === 'completion') { + accumulator.messageCount++ + accumulator.totalTokens += tokenTotal(record.usage) + addPreviewMessage(accumulator, { + role: 'assistant', + text: extractString(record.finalText), + timestamp: record.timestamp + }) + } +} + +export function createDroidSessionResumeState(file: FileWithMtime): ResumableSessionParseState { + return accumulatorFoldResumeState( + createAccumulator({ agent: 'droid', file, sessionId: sessionIdFromFileName(file.path) }), + consumeDroidRecordLine + ) +} + async function parseDroidSessionLines(args: { file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform options?: ParserSessionOptions }): Promise { - const accumulator = createAccumulator({ - agent: 'droid', - file: args.file, - sessionId: sessionIdFromFileName(args.file.path) - }) - + const state = createDroidSessionResumeState(args.file) for await (const line of args.lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - updateTimeline(accumulator, record.timestamp) - if (record.type === 'session_start') { - accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId - accumulator.title = normalizeTitleText(extractString(record.title) ?? '') - accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd - continue - } - if (record.type === 'system') { - accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd - accumulator.model = extractString(record.model) ?? accumulator.model - } - const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId) - if (streamSessionId) { - accumulator.sessionId = streamSessionId - } - if (record.type === 'message') { - consumeDroidMessage(accumulator, record) - } else if (record.type === 'completion') { - accumulator.messageCount++ - accumulator.totalTokens += tokenTotal(record.usage) - addPreviewMessage(accumulator, { - role: 'assistant', - text: extractString(record.finalText), - timestamp: record.timestamp - }) - } + state.consumeLine(line) } - return finalizeSession(accumulator, args.platform, args.options) + return state.finalize(args.platform, args.options) } function consumeDroidMessage( 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 a52c83b3213..04601eacde5 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -4,8 +4,13 @@ import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' import { + accumulatorFoldResumeState, addPreviewContent, addPreviewMessage, createAccumulator, @@ -158,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 { @@ -171,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, @@ -186,53 +195,64 @@ export async function parseMessageGraphSessionContent( }) } +function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + updateTimeline(accumulator, extractString(record.timestamp)) + if (record.type === 'session') { + const sessionId = extractString(record.id) + if (sessionId) { + accumulator.sessionId = sessionId + } + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + return + } + if (record.type === 'model_change') { + // 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') { + return + } + const message = asRecord(record.message) + const role = extractString(message?.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= extractMessageText(message) + } else { + accumulator.model = extractString(message?.model) ?? accumulator.model + accumulator.totalTokens += tokenTotal(message?.usage) + } + addPreviewContent(accumulator, role, message?.content, record.timestamp) + } +} + +export function createMessageGraphSessionResumeState( + agent: MessageGraphAgent, + file: FileWithMtime +): ResumableSessionParseState { + return accumulatorFoldResumeState( + createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }), + consumeMessageGraphRecordLine + ) +} + async function parseMessageGraphSessionLines(args: { - agent: 'openclaw' | 'pi' + agent: MessageGraphAgent file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform options?: ParserSessionOptions }): Promise { - const accumulator = createAccumulator({ - agent: args.agent, - file: args.file, - sessionId: sessionIdFromFileName(args.file.path) - }) - + const state = createMessageGraphSessionResumeState(args.agent, args.file) for await (const line of args.lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - updateTimeline(accumulator, extractString(record.timestamp)) - if (record.type === 'session') { - const sessionId = extractString(record.id) - if (sessionId) { - accumulator.sessionId = sessionId - } - accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd - continue - } - if (record.type === 'model_change') { - accumulator.model = extractString(record.modelId) ?? accumulator.model - continue - } - if (record.type !== 'message') { - continue - } - const message = asRecord(record.message) - const role = extractString(message?.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - if (role === 'user') { - accumulator.title ??= extractMessageText(message) - } else { - accumulator.model = extractString(message?.model) ?? accumulator.model - accumulator.totalTokens += tokenTotal(message?.usage) - } - addPreviewContent(accumulator, role, message?.content, record.timestamp) - } + state.consumeLine(line) } - - return finalizeSession(accumulator, args.platform, args.options) + return state.finalize(args.platform, args.options) } diff --git a/src/main/ai-vault/session-scanner-incremental-fixtures.ts b/src/main/ai-vault/session-scanner-incremental-fixtures.ts new file mode 100644 index 00000000000..1945193e6c7 --- /dev/null +++ b/src/main/ai-vault/session-scanner-incremental-fixtures.ts @@ -0,0 +1,291 @@ +import type { AiVaultAgent } from '../../shared/ai-vault-types' + +import { codexFixture } from './session-scanner-codex-fixtures' + +// Line builders for the incremental-parse differential tests: each agent gets +// a seed transcript, an appended continuation, and a truncated rewrite, all in +// that agent's real on-disk JSONL record shapes. + +export type IncrementalAgentFixture = { + agent: AiVaultAgent + fileName: string + seedLines: string[] + appendLines: string[] + truncatedLines: string[] +} + +export function cursorFixture(): IncrementalAgentFixture { + const line = (role: string, text: string, at: string) => + JSON.stringify({ role, message: { content: text }, timestamp: at }) + return { + agent: 'cursor', + fileName: 'agent-transcripts-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl', + seedLines: [ + line('user', 'cursor seed question', '2026-05-01T10:00:00.000Z'), + line('assistant', 'cursor seed answer', '2026-05-01T10:01:00.000Z') + ], + appendLines: [ + line('user', 'cursor follow-up', '2026-05-01T10:02:00.000Z'), + line('assistant', 'cursor incremental answer', '2026-05-01T10:03:00.000Z') + ], + truncatedLines: [line('user', 'cursor rewritten', '2026-05-01T10:00:00.000Z')] + } +} + +export function copilotFixture(): IncrementalAgentFixture { + const line = (type: string, data: Record, at: string) => + JSON.stringify({ type, data, timestamp: at }) + return { + agent: 'copilot', + fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl', + seedLines: [ + line( + 'session.start', + { sessionId: 'copilot-session-1', startTime: '2026-05-01T10:00:00.000Z' }, + '2026-05-01T10:00:00.000Z' + ), + line('user.message', { content: 'copilot seed question' }, '2026-05-01T10:00:05.000Z'), + line('assistant.message', { content: 'copilot seed answer' }, '2026-05-01T10:00:30.000Z') + ], + appendLines: [ + line('user.message', { content: 'copilot follow-up' }, '2026-05-01T10:05:00.000Z'), + line( + 'assistant.message', + { content: 'copilot incremental answer' }, + '2026-05-01T10:05:30.000Z' + ), + line( + 'session.shutdown', + { currentModel: 'gpt-5.1', currentTokens: 340 }, + '2026-05-01T10:06:00.000Z' + ) + ], + truncatedLines: [ + line( + 'session.start', + { sessionId: 'copilot-session-1', startTime: '2026-05-01T10:00:00.000Z' }, + '2026-05-01T10:00:00.000Z' + ) + ] + } +} + +export function droidFixture(): IncrementalAgentFixture { + return { + agent: 'droid', + fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl', + seedLines: [ + JSON.stringify({ + type: 'session_start', + id: 'droid-session-1', + title: 'Droid seed task', + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }), + JSON.stringify({ + type: 'message', + role: 'user', + text: 'droid seed question', + timestamp: '2026-05-01T10:00:05.000Z' + }) + ], + appendLines: [ + JSON.stringify({ + type: 'completion', + finalText: 'droid incremental answer', + usage: { input_tokens: 50, output_tokens: 25 }, + timestamp: '2026-05-01T10:01:00.000Z' + }) + ], + truncatedLines: [ + JSON.stringify({ + type: 'session_start', + id: 'droid-session-1', + title: 'Droid rewritten', + timestamp: '2026-05-01T10:00:00.000Z' + }) + ] + } +} + +export function openclawFixture(): IncrementalAgentFixture { + return { + agent: 'openclaw', + fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl', + seedLines: [ + JSON.stringify({ + type: 'session', + id: 'openclaw-session-1', + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }), + JSON.stringify({ + type: 'message', + message: { role: 'user', content: 'openclaw seed question' }, + timestamp: '2026-05-01T10:00:05.000Z' + }) + ], + appendLines: [ + JSON.stringify({ + type: 'message', + message: { + role: 'assistant', + content: 'openclaw incremental answer', + model: 'claw-1', + usage: { input_tokens: 40, output_tokens: 20 } + }, + timestamp: '2026-05-01T10:01:00.000Z' + }) + ], + truncatedLines: [ + JSON.stringify({ + type: 'session', + id: 'openclaw-session-1', + timestamp: '2026-05-01T10:00:00.000Z' + }) + ] + } +} + +// Pi shares OpenClaw's message-graph format and factory, but gets its own +// fixture so the registry's 'pi' branch is exercised explicitly. +export function piFixture(): IncrementalAgentFixture { + return { + agent: 'pi', + fileName: 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff.jsonl', + seedLines: [ + JSON.stringify({ + type: 'session', + id: 'pi-session-1', + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }), + JSON.stringify({ + type: 'message', + message: { role: 'user', content: 'pi seed question' }, + timestamp: '2026-05-01T10:00:05.000Z' + }) + ], + appendLines: [ + JSON.stringify({ + type: 'model_change', + modelId: 'pi-2', + timestamp: '2026-05-01T10:00:30.000Z' + }), + JSON.stringify({ + type: 'message', + message: { + role: 'assistant', + content: 'pi incremental answer', + usage: { input_tokens: 30, output_tokens: 10 } + }, + timestamp: '2026-05-01T10:01:00.000Z' + }) + ], + truncatedLines: [ + JSON.stringify({ type: 'session', id: 'pi-session-1', timestamp: '2026-05-01T10:00:00.000Z' }) + ] + } +} + +// 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', + fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl', + seedLines: [ + JSON.stringify({ + sessionId: 'gemini-session-1', + startTime: '2026-05-01T10:00:00.000Z', + type: 'user', + content: 'gemini seed question', + timestamp: '2026-05-01T10:00:00.000Z' + }), + JSON.stringify({ + type: 'gemini', + content: 'gemini seed answer', + model: 'gemini-3-pro', + tokens: { input: 80, output: 30 }, + timestamp: '2026-05-01T10:00:30.000Z' + }) + ], + appendLines: [ + JSON.stringify({ + type: 'user', + content: 'gemini follow-up', + timestamp: '2026-05-01T10:01:00.000Z' + }), + JSON.stringify({ $set: { lastUpdated: '2026-05-01T10:01:05.000Z' } }) + ], + truncatedLines: [ + JSON.stringify({ + sessionId: 'gemini-session-1', + type: 'user', + content: 'gemini rewritten', + timestamp: '2026-05-01T10:00:00.000Z' + }) + ] + } +} + +export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] { + return [ + codexFixture(), + cursorFixture(), + copilotFixture(), + 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-agents.test.ts b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts new file mode 100644 index 00000000000..61348ad7cec --- /dev/null +++ b/src/main/ai-vault/session-scanner-parse-cache-agents.test.ts @@ -0,0 +1,250 @@ +import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { parseAgentSessionFile } from './session-scanner-agent-parser' +import { + CODEX_FIXTURE_SESSION_ID, + codexFixture, + codexWorkerFixtureLines +} from './session-scanner-codex-fixtures' +import { allIncrementalAgentFixtures } from './session-scanner-incremental-fixtures' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests +} from './session-scanner-parse-cache' +import type { SessionFileCandidate } from './session-scanner-types' + +let tempRoots: string[] = [] + +beforeEach(() => { + resetSessionParseCacheForTests() +}) + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeTempDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-parse-cache-agents-')) + tempRoots.push(root) + return root +} + +async function candidateFor( + agent: SessionFileCandidate['agent'], + path: string, + codexHome: string | null = null +): Promise { + const fileStat = await stat(path) + return { + agent, + file: { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + }, + codexHome + } +} + +describe.each(allIncrementalAgentFixtures())('incremental parse parity: $agent', (fixture) => { + it('reuses unchanged files, resumes appends, and matches cold parses exactly', async () => { + const root = await makeTempDir() + const path = join(root, fixture.fileName) + await writeFile(path, `${fixture.seedLines.join('\n')}\n`) + + const stats = createSessionParseStats() + const seedCandidate = await candidateFor(fixture.agent, path) + const seeded = await parseAgentSessionFileCached(seedCandidate, process.platform, stats) + expect(stats.fullParses).toBe(1) + expect(seeded).toEqual(await parseAgentSessionFile(seedCandidate, process.platform)) + + // Unchanged rescan returns the identical cached object. + const reused = await parseAgentSessionFileCached(seedCandidate, process.platform, stats) + expect(reused).toBe(seeded) + expect(stats.reused).toBe(1) + + // Appended lines resume from the stored byte offset and must equal a + // cold parse of the grown file. + await appendFile(path, `${fixture.appendLines.join('\n')}\n`) + const grownCandidate = await candidateFor(fixture.agent, path) + const incremental = await parseAgentSessionFileCached(grownCandidate, process.platform, stats) + expect(stats.incremental).toBe(1) + expect(incremental).toEqual(await parseAgentSessionFile(grownCandidate, process.platform)) + + // A truncated rewrite falls back to a full parse. + await writeFile(path, `${fixture.truncatedLines.join('\n')}\n`) + const truncatedCandidate = await candidateFor(fixture.agent, path) + const reparsed = await parseAgentSessionFileCached(truncatedCandidate, process.platform, stats) + expect(stats.fullParses).toBe(2) + expect(reparsed).toEqual(await parseAgentSessionFile(truncatedCandidate, process.platform)) + }) + + it('includes a trailing unterminated line without double-counting it later', async () => { + const root = await makeTempDir() + const path = join(root, fixture.fileName) + const lastSeedLine = fixture.seedLines.at(-1) + const headLines = fixture.seedLines.slice(0, -1) + await writeFile(path, `${[...headLines, ''].join('\n')}${lastSeedLine}`) + + const partialCandidate = await candidateFor(fixture.agent, path) + const shown = await parseAgentSessionFileCached(partialCandidate, process.platform) + expect(shown).toEqual(await parseAgentSessionFile(partialCandidate, process.platform)) + + await appendFile(path, `\n${fixture.appendLines.join('\n')}\n`) + const grownCandidate = await candidateFor(fixture.agent, path) + const stats = createSessionParseStats() + const completed = await parseAgentSessionFileCached(grownCandidate, process.platform, stats) + expect(stats.incremental).toBe(1) + expect(completed).toEqual(await parseAgentSessionFile(grownCandidate, process.platform)) + }) + + it('tolerates a mid-write truncated trailing line and never double-counts it', async () => { + const root = await makeTempDir() + const path = join(root, fixture.fileName) + // A writer caught mid-record: the trailing line is invalid JSON. + await writeFile(path, `${fixture.seedLines.join('\n')}\n{"type":"user","mess`) + + const shown = await parseAgentSessionFileCached( + await candidateFor(fixture.agent, path), + process.platform + ) + expect(shown).toEqual( + await parseAgentSessionFile(await candidateFor(fixture.agent, path), process.platform) + ) + + // The writer "finishes" the interrupted record as unparseable junk (both + // the fold and a cold parse must skip it identically) and appends more. + await appendFile(path, `age": }\n${fixture.appendLines.join('\n')}\n`) + const stats = createSessionParseStats() + const completed = await parseAgentSessionFileCached( + await candidateFor(fixture.agent, path), + process.platform, + stats + ) + expect(stats.incremental).toBe(1) + expect(completed).toEqual( + await parseAgentSessionFile(await candidateFor(fixture.agent, path), process.platform) + ) + }) +}) + +describe('codex-specific resume behavior', () => { + it('keeps rejecting worker sessions across incremental appends', async () => { + const root = await makeTempDir() + const path = join(root, codexFixture().fileName) + await writeFile(path, `${codexWorkerFixtureLines().join('\n')}\n`) + + const stats = createSessionParseStats() + const seeded = await parseAgentSessionFileCached( + await candidateFor('codex', path), + process.platform, + stats + ) + expect(seeded).toBeNull() + + await appendFile( + path, + `${JSON.stringify({ + timestamp: '2026-05-01T10:10:00.000Z', + type: 'event_msg', + payload: { type: 'agent_message', message: 'worker keeps writing' } + })}\n` + ) + const grown = await parseAgentSessionFileCached( + await candidateFor('codex', path), + process.platform, + stats + ) + expect(stats.incremental).toBe(1) + expect(grown).toBeNull() + }) + + it('picks up a session_index title that appears after the transcript was cached', async () => { + const root = await makeTempDir() + const codexHome = join(root, 'codex-home') + const sessionsDir = join(codexHome, 'sessions', '2026', '05', '01') + await mkdir(sessionsDir, { recursive: true }) + const fixture = codexFixture() + const path = join(sessionsDir, fixture.fileName) + await writeFile(path, `${fixture.seedLines.join('\n')}\n`) + + // No index yet: the title falls back to the first user prompt. + const seeded = await parseAgentSessionFileCached( + await candidateFor('codex', path, codexHome), + process.platform + ) + expect(seeded?.title).toBe('codex seed question') + + // Codex names the thread lazily; the next (incremental) parse must adopt it. + await writeFile( + join(codexHome, 'session_index.jsonl'), + `${JSON.stringify({ id: CODEX_FIXTURE_SESSION_ID, thread_name: 'Indexed thread title' })}\n` + ) + await appendFile(path, `${fixture.appendLines.join('\n')}\n`) + const stats = createSessionParseStats() + const renamed = await parseAgentSessionFileCached( + await candidateFor('codex', path, codexHome), + process.platform, + stats + ) + expect(stats.incremental).toBe(1) + expect(renamed?.title).toBe('Indexed thread title') + expect(renamed).toEqual( + await parseAgentSessionFile(await candidateFor('codex', path, codexHome), process.platform) + ) + }) +}) + +describe('non-resumable formats keep reuse-only caching', () => { + it('re-parses a changed grok summary fully and reuses it when unchanged', async () => { + const root = await makeTempDir() + const sessionDir = join(root, 'session-1') + await mkdir(sessionDir, { recursive: true }) + const path = join(sessionDir, 'summary.json') + await writeFile( + path, + JSON.stringify({ + session_id: 'grok-1', + title: 'Grok seed', + updated_at: '2026-05-01T10:00:00Z' + }) + ) + + const stats = createSessionParseStats() + const seeded = await parseAgentSessionFileCached( + await candidateFor('grok', path), + process.platform, + stats + ) + const reused = await parseAgentSessionFileCached( + await candidateFor('grok', path), + process.platform, + stats + ) + expect(reused).toBe(seeded) + expect(stats).toMatchObject({ fullParses: 1, reused: 1, incremental: 0 }) + + await writeFile( + path, + JSON.stringify({ + session_id: 'grok-1', + title: 'Grok rewritten with a longer title', + updated_at: '2026-05-01T11:00:00Z' + }) + ) + const rewritten = await parseAgentSessionFileCached( + await candidateFor('grok', path), + process.platform, + stats + ) + expect(stats).toMatchObject({ fullParses: 2, incremental: 0 }) + expect(rewritten).toEqual( + await parseAgentSessionFile(await candidateFor('grok', path), process.platform) + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-parse-cache.test.ts b/src/main/ai-vault/session-scanner-parse-cache.test.ts new file mode 100644 index 00000000000..6ba5e3266d6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-parse-cache.test.ts @@ -0,0 +1,249 @@ +import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + parseAgentSessionFileCached, + resetSessionParseCacheForTests, + createSessionParseStats +} from './session-scanner-parse-cache' +import { parseClaudeSessionFile } from './session-scanner-primary-parsers' +import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' + +let tempRoots: string[] = [] + +beforeEach(() => { + resetSessionParseCacheForTests() +}) + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeTempDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-parse-cache-')) + tempRoots.push(root) + return root +} + +async function claudeCandidate(path: string): Promise { + const fileStat = await stat(path) + const file: FileWithMtime = { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + } + return { agent: 'claude', file, codexHome: null } +} + +function userRecord(index: number, text: string): string { + return JSON.stringify({ + type: 'user', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + cwd: '/repo/app', + gitBranch: 'main', + message: { role: 'user', content: text } + }) +} + +function assistantRecord(index: number, text: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text }], + usage: { input_tokens: 100, output_tokens: 40 } + } + }) +} + +// Ground truth: what a cold one-shot parse of the current file contents yields. +async function freshParse(path: string) { + const candidate = await claudeCandidate(path) + return parseClaudeSessionFile(candidate.file) +} + +async function cachedParse(path: string) { + const candidate = await claudeCandidate(path) + return parseAgentSessionFileCached(candidate, process.platform) +} + +describe('parseAgentSessionFileCached', () => { + it('returns the identical cached session for an unchanged file', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile(path, `${userRecord(0, 'first question')}\n${assistantRecord(1, 'answer')}\n`) + + const stats = createSessionParseStats() + const candidate = await claudeCandidate(path) + const first = await parseAgentSessionFileCached(candidate, process.platform, stats) + const second = await parseAgentSessionFileCached(candidate, process.platform, stats) + + expect(first).not.toBeNull() + expect(second).toBe(first) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(1) + expect(stats.incremental).toBe(0) + }) + + it('incrementally parses appended lines and matches a cold parse exactly', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile( + path, + `${[ + userRecord(0, 'first question'), + assistantRecord(1, 'first answer'), + JSON.stringify({ type: 'ai-title', aiTitle: 'Original title' }) + ].join('\n')}\n` + ) + const stats = createSessionParseStats() + await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats) + + await appendFile( + path, + `${[ + userRecord(2, 'second question'), + assistantRecord(3, 'second answer'), + JSON.stringify({ type: 'ai-title', aiTitle: 'Revised title' }), + assistantRecord(4, 'third answer') + ].join('\n')}\n` + ) + + const incremental = await parseAgentSessionFileCached( + await claudeCandidate(path), + process.platform, + stats + ) + expect(stats.incremental).toBe(1) + expect(incremental).toEqual(await freshParse(path)) + expect(incremental?.messageCount).toBe(5) + expect(incremental?.title).toBe('Revised title') + expect(incremental?.totalTokens).toBe(420) + }) + + it('shows a trailing unterminated line without double-counting it later', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + // Final record has no trailing newline (mid-write file). + await writeFile(path, `${userRecord(0, 'question')}\n${assistantRecord(1, 'partial answer')}`) + + const first = await cachedParse(path) + expect(first?.messageCount).toBe(2) + expect(first).toEqual(await freshParse(path)) + + // The writer finishes the line and appends one more record. + await appendFile(path, `\n${userRecord(2, 'follow-up')}\n`) + const second = await cachedParse(path) + expect(second?.messageCount).toBe(3) + expect(second).toEqual(await freshParse(path)) + }) + + it('falls back to a full parse when the file is truncated', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile( + path, + `${[userRecord(0, 'one'), assistantRecord(1, 'two'), userRecord(2, 'three')].join('\n')}\n` + ) + await cachedParse(path) + + await writeFile(path, `${userRecord(0, 'rewritten only line')}\n`) + const stats = createSessionParseStats() + const reparsed = await parseAgentSessionFileCached( + await claudeCandidate(path), + process.platform, + stats + ) + expect(stats.fullParses).toBe(1) + expect(stats.incremental).toBe(0) + expect(reparsed).toEqual(await freshParse(path)) + expect(reparsed?.messageCount).toBe(1) + }) + + it('detects a grown rewrite via the resume-point newline guard', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + const original = `${userRecord(0, 'aa')}\n` + await writeFile(path, original) + await cachedParse(path) + + // Larger file whose byte at the old resume point is mid-line, not '\n'. + const rewritten = `${userRecord(0, 'a much longer rewritten question than before')}\n${assistantRecord(1, 'answer')}\n` + expect(rewritten.length).toBeGreaterThan(original.length) + expect(rewritten[original.length - 1]).not.toBe('\n') + await writeFile(path, rewritten) + + const stats = createSessionParseStats() + const reparsed = await parseAgentSessionFileCached( + await claudeCandidate(path), + process.platform, + stats + ) + expect(stats.fullParses).toBe(1) + expect(stats.incremental).toBe(0) + expect(reparsed).toEqual(await freshParse(path)) + expect(reparsed?.messageCount).toBe(2) + }) + + it('parses CRLF transcripts identically to the streaming parser', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile( + path, + `${userRecord(0, 'windows question')}\r\n${assistantRecord(1, 'answer')}\r\n` + ) + + const first = await cachedParse(path) + expect(first).toEqual(await freshParse(path)) + expect(first?.messageCount).toBe(2) + + await appendFile(path, `${userRecord(2, 'more')}\r\n`) + const second = await cachedParse(path) + expect(second?.messageCount).toBe(3) + expect(second).toEqual(await freshParse(path)) + }) + + it('caches non-Claude sessions by mtime and size', async () => { + const root = await makeTempDir() + await mkdir(join(root, '2026', '05', '01'), { recursive: true }) + const path = join( + root, + '2026', + '05', + '01', + 'rollout-2026-05-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' + ) + await writeFile( + path, + `${JSON.stringify({ + timestamp: '2026-05-01T11:00:00.000Z', + type: 'session_meta', + payload: { id: '019f0000-1111-7222-8333-444444444444', cwd: '/repo/app' } + })}\n` + ) + const fileStat = await stat(path) + const candidate: SessionFileCandidate = { + agent: 'codex', + file: { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + }, + codexHome: null + } + const stats = createSessionParseStats() + const first = await parseAgentSessionFileCached(candidate, process.platform, stats) + const second = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(second).toBe(first) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(1) + }) +}) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts new file mode 100644 index 00000000000..70d0d311d6b --- /dev/null +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -0,0 +1,292 @@ +import { createReadStream } from 'node:fs' +import { open } from 'node:fs/promises' +import type { AiVaultSession } from '../../shared/ai-vault-types' +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 } 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 +// full steady-state result set stays resident between forced rescans. +const MAX_CACHE_ENTRIES = 4096 + +const NEWLINE_BYTE = 0x0a +const CARRIAGE_RETURN_BYTE = 0x0d + +type ResumePoint = { + state: ResumableSessionParseState + // Byte offset just past the last complete ('\n'-terminated) line consumed; + // a trailing unterminated line is deliberately left before this point. + byteOffset: number +} + +type SessionParseCacheEntry = { + mtimeMs: number + sizeBytes: number | null + platform: NodeJS.Platform + session: AiVaultSession | null + resume: ResumePoint | null +} + +// Incremental append-parsing applies only to transcripts that are append-only +// JSONL line-folds. Whole-JSON documents (grok/rovo/devin/hermes/gemini-json) +// are rewritten in place, Kimi reads a state doc plus a sibling wire file, and +// OpenCode reads SQLite rows or a doc plus a message dir — those formats keep +// unchanged-file reuse only and re-parse whole when they change. +// Returns a factory (not a state) so steady-state resumes, which clone the +// cached state instead, never pay for a throwaway accumulator. +function resumableStateFactoryFor( + candidate: SessionFileCandidate +): (() => ResumableSessionParseState) | null { + switch (candidate.agent) { + case 'claude': + return () => createClaudeSessionResumeState(candidate.file) + case 'codex': + return () => createCodexSessionResumeState(candidate.file, candidate.codexHome) + case 'cursor': + return () => createCursorSessionResumeState(candidate.file) + case 'copilot': + return () => createCopilotSessionResumeState(candidate.file) + case 'droid': + return () => createDroidSessionResumeState(candidate.file) + case 'openclaw': + case 'pi': + case 'omp': { + const agent = candidate.agent + return () => createMessageGraphSessionResumeState(agent, candidate.file) + } + case 'gemini': + return candidate.file.path.endsWith('.jsonl') + ? () => createGeminiJsonlSessionResumeState(candidate.file) + : null + case 'devin': + case 'grok': + case 'hermes': + case 'kimi': + case 'opencode': + case 'rovo': + return null + } +} + +export type SessionParseStats = { + reused: number + incremental: number + fullParses: number + bytesRead: number +} + +export function createSessionParseStats(): SessionParseStats { + return { reused: 0, incremental: 0, fullParses: 0, bytesRead: 0 } +} + +const cache = new Map() + +export function resetSessionParseCacheForTests(): void { + cache.clear() +} + +function storeEntry(path: string, entry: SessionParseCacheEntry): void { + cache.delete(path) + cache.set(path, entry) + if (cache.size > MAX_CACHE_ENTRIES) { + const oldest = cache.keys().next() + if (!oldest.done) { + cache.delete(oldest.value) + } + } +} + +/** + * 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/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 + * multi-agent workloads). + */ +export async function parseAgentSessionFileCached( + candidate: SessionFileCandidate, + platform: NodeJS.Platform, + stats?: SessionParseStats +): Promise { + const { file } = candidate + const entry = cache.get(file.path) + + const unchanged = + entry !== undefined && + entry.platform === platform && + entry.mtimeMs === file.mtimeMs && + (entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes) + if (unchanged) { + 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 + } + + const stateFactory = resumableStateFactoryFor(candidate) + if (stateFactory) { + const parsed = await parseResumableCandidate({ + candidate, + platform, + entry, + stats, + stateFactory + }) + storeEntry(file.path, parsed) + return parsed.session + } + + if (stats) { + stats.fullParses++ + stats.bytesRead += file.sizeBytes ?? 0 + } + const session = await parseAgentSessionFile(candidate, platform) + storeEntry(file.path, { + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes ?? null, + platform, + session, + resume: null + }) + return session +} + +async function parseResumableCandidate(args: { + candidate: SessionFileCandidate + platform: NodeJS.Platform + entry: SessionParseCacheEntry | undefined + stats?: SessionParseStats + stateFactory: () => ResumableSessionParseState +}): Promise { + const { file } = args.candidate + const resume = args.entry?.platform === args.platform ? args.entry.resume : null + const canResume = + resume !== null && + resume !== undefined && + typeof file.sizeBytes === 'number' && + file.sizeBytes >= resume.byteOffset && + (resume.byteOffset === 0 || (await endsWithNewlineAt(file.path, resume.byteOffset))) + + // Clone before consuming: a failed read must not corrupt the cached state, + // or the next resume would double-count the lines applied before the error. + const state = canResume ? resume.state.clone() : args.stateFactory() + const startOffset = canResume ? resume.byteOffset : 0 + if (args.stats) { + if (canResume) { + args.stats.incremental++ + } else { + args.stats.fullParses++ + } + } + + const readResult = await consumeCompleteJsonlLines({ + path: file.path, + start: startOffset, + onLine: (line) => state.consumeLine(line) + }) + if (args.stats) { + args.stats.bytesRead += readResult.bytesRead + } + + // The stat this scan displays is current even when nothing new was consumed. + state.touchFile(file) + + // Keep parity with the one-shot parser: a final unterminated line is shown, + // but stays out of the resumable state so the (possibly still-growing) line + // is re-read once complete instead of being half-counted. + let displayState = state + if (readResult.trailingPartialLine !== null) { + displayState = state.clone() + displayState.consumeLine(readResult.trailingPartialLine) + } + + return { + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes ?? null, + platform: args.platform, + session: await displayState.finalize(args.platform), + resume: { state, byteOffset: readResult.consumedThrough } + } +} + +// A resume point is only valid if it still sits just past a line break; +// anything else means the file was rewritten, not appended. Heuristic: a +// grown rewrite keeping '\n' at exactly this byte would slip through, but +// agent transcripts are append-only so that trade is accepted (worst case is +// a stale vault row until the file is next truncated or the app restarts). +async function endsWithNewlineAt(path: string, offset: number): Promise { + const handle = await open(path, 'r') + try { + const { bytesRead, buffer } = await handle.read(Buffer.alloc(1), 0, 1, offset - 1) + return bytesRead === 1 && buffer[0] === NEWLINE_BYTE + } finally { + await handle.close() + } +} + +type JsonlReadResult = { + consumedThrough: number + trailingPartialLine: string | null + bytesRead: number +} + +// Byte-accurate replacement for readline: offsets must count bytes (not +// UTF-8-decoded characters) so a resumed read starts exactly where the last +// complete line ended. +async function consumeCompleteJsonlLines(args: { + path: string + start: number + onLine: (line: string) => void +}): Promise { + let consumedThrough = args.start + let bytesRead = 0 + let remainder: Buffer | null = null + + const stream = createReadStream(args.path, { start: args.start }) + for await (const chunk of stream as AsyncIterable) { + bytesRead += chunk.length + const data = remainder ? Buffer.concat([remainder, chunk]) : chunk + let lineStart = 0 + let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) + while (newlineIndex !== -1) { + let lineEnd = newlineIndex + if (lineEnd > lineStart && data[lineEnd - 1] === CARRIAGE_RETURN_BYTE) { + lineEnd-- + } + args.onLine(data.toString('utf-8', lineStart, lineEnd)) + lineStart = newlineIndex + 1 + newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) + } + consumedThrough += lineStart + // Copy the tail so retaining it doesn't pin the whole chunk buffer. + remainder = lineStart < data.length ? Buffer.from(data.subarray(lineStart)) : null + } + + return { + consumedThrough, + trailingPartialLine: remainder && remainder.length > 0 ? remainder.toString('utf-8') : null, + bytesRead + } +} diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index b574cd3124e..c72d0da8277 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -1,9 +1,12 @@ 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 type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' import { addPreviewContent, createAccumulator, @@ -12,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 = { @@ -29,6 +30,160 @@ type ParserSessionOptions = { executionHostPlatform?: NodeJS.Platform | null } +// Parse state kept resumable so the scan cache can append newly written +// transcript lines without re-reading the whole (potentially huge) file. +export type ClaudeSessionParseState = { + accumulator: SessionAccumulator + metaTitle: string | null + generatedTitle: string | null + firstUserTitle: string | null +} + +export function createClaudeSessionParseState(file: FileWithMtime): ClaudeSessionParseState { + return { + accumulator: createAccumulator({ + agent: 'claude', + file, + sessionId: sessionIdFromFileName(file.path) + }), + metaTitle: null, + generatedTitle: null, + firstUserTitle: null + } +} + +export function cloneClaudeSessionParseState( + state: ClaudeSessionParseState +): ClaudeSessionParseState { + return { + accumulator: { + ...state.accumulator, + previewMessages: [...state.accumulator.previewMessages] + }, + metaTitle: state.metaTitle, + generatedTitle: state.generatedTitle, + firstUserTitle: state.firstUserTitle + } +} + +export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: string): void { + const { accumulator } = state + const record = parseJsonObject(line) + if (!record) { + return + } + + if (typeof record.sessionId === 'string' && record.sessionId.trim()) { + accumulator.sessionId = record.sessionId.trim() + } + updateTimeline(accumulator, extractString(record.timestamp)) + updateLatestLocation(accumulator, record) + + if (record.type === 'custom-title') { + accumulator.title = normalizeTitleText(extractString(record.customTitle) ?? '') + return + } + + if (record.type === 'ai-title') { + const title = normalizeTitleText(extractString(record.aiTitle) ?? '') + if (title) { + // Claude can revise generated names; AI Vault should mirror the current one. + state.generatedTitle = title + } + return + } + + if (record.type === 'agent-name' && !state.generatedTitle) { + state.metaTitle ??= normalizeTitleText(extractString(record.agentName) ?? '') + 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) + addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp) + if (title) { + // Meta prompts (injected context) only seed the last-resort title. + if (record.isMeta === true) { + state.metaTitle ??= title + } else { + state.firstUserTitle ??= title + } + } + return + } + + if (record.type === 'assistant') { + accumulator.messageCount++ + const message = asRecord(record.message) + addPreviewContent(accumulator, 'assistant', message?.content, record.timestamp) + const model = extractString(message?.model) + if (model) { + accumulator.model = model + } + accumulator.totalTokens += claudeUsageTotal(message?.usage) + } +} + +export async function finalizeClaudeSessionParseState( + state: ClaudeSessionParseState, + platform: NodeJS.Platform, + options: ParserSessionOptions = {} +): 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) + // Why: a user-set custom-title (accumulator.title) wins, but Claude's generated + // 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) +} + +export function createClaudeSessionResumeState(file: FileWithMtime): ResumableSessionParseState { + return claudeResumeStateFromParseState(createClaudeSessionParseState(file)) +} + +function claudeResumeStateFromParseState( + state: ClaudeSessionParseState +): ResumableSessionParseState { + return { + consumeLine: (line) => consumeClaudeSessionLine(state, line), + clone: () => claudeResumeStateFromParseState(cloneClaudeSessionParseState(state)), + touchFile: (file) => { + state.accumulator.modifiedAt = file.modifiedAt + }, + finalize: (platform, options) => finalizeClaudeSessionParseState(state, platform, options) + } +} + export async function parseClaudeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform @@ -60,196 +215,9 @@ async function parseClaudeSessionLines(args: { platform: NodeJS.Platform options?: ParserSessionOptions }): Promise { - const accumulator = createAccumulator({ - agent: 'claude', - file: args.file, - sessionId: sessionIdFromFileName(args.file.path) - }) - let metaTitle: string | null = null - let generatedTitle: string | null = null - let firstUserTitle: string | null = null - + const state = createClaudeSessionParseState(args.file) for await (const line of args.lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - - if (typeof record.sessionId === 'string' && record.sessionId.trim()) { - accumulator.sessionId = record.sessionId.trim() - } - updateTimeline(accumulator, extractString(record.timestamp)) - updateLatestLocation(accumulator, record) - - if (record.type === 'custom-title') { - accumulator.title = normalizeTitleText(extractString(record.customTitle) ?? '') - continue - } - - if (record.type === 'ai-title') { - const title = normalizeTitleText(extractString(record.aiTitle) ?? '') - if (title) { - // Claude can revise generated names; AI Vault should mirror the current one. - generatedTitle = title - } - continue - } - - if (record.type === 'agent-name' && !generatedTitle) { - metaTitle ??= normalizeTitleText(extractString(record.agentName) ?? '') - continue - } - - if (record.type === 'user') { - accumulator.messageCount++ - const title = extractMessageText(record.message) - addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp) - if (title) { - // Meta prompts (injected context) only seed the last-resort title. - if (record.isMeta === true) { - metaTitle ??= title - } else { - firstUserTitle ??= title - } - } - continue - } - - if (record.type === 'assistant') { - accumulator.messageCount++ - const message = asRecord(record.message) - addPreviewContent(accumulator, 'assistant', message?.content, record.timestamp) - const model = extractString(message?.model) - if (model) { - accumulator.model = model - } - accumulator.totalTokens += claudeUsageTotal(message?.usage) - } - } - - // Why: a user-set custom-title (accumulator.title) wins, but Claude's generated - // session name (ai-title) should outrank the raw first prompt when present. - accumulator.fallbackTitle = generatedTitle ?? firstUserTitle ?? metaTitle - return finalizeSession(accumulator, 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 }) -} - -async function parseGeminiJsonlSessionLines(args: { - file: FileWithMtime - lines: AsyncIterable | Iterable - platform: NodeJS.Platform - options?: ParserSessionOptions -}): Promise { - const accumulator = createAccumulator({ - agent: 'gemini', - file: args.file, - sessionId: sessionIdFromFileName(args.file.path) - }) - - for await (const line of args.lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - const setRecord = asRecord(record.$set) - if (setRecord) { - updateTimeline(accumulator, extractString(setRecord.lastUpdated)) - continue - } - const sessionId = extractString(record.sessionId) - if (sessionId) { - accumulator.sessionId = sessionId - } - updateTimeline(accumulator, extractString(record.startTime)) - updateTimeline(accumulator, extractString(record.lastUpdated)) - consumeGeminiMessage(accumulator, record) - } - - return finalizeSession(accumulator, 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) + consumeClaudeSessionLine(state, line) } + return finalizeClaudeSessionParseState(state, args.platform, args.options) } 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-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts index 4900e3e2348..41aac5f2700 100644 --- a/src/main/ai-vault/session-scanner-scope-discovery.ts +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -17,6 +17,36 @@ const REPRESENTATIVE_CWD_LINE_LIMIT = 200 const REPRESENTATIVE_FILE_LIMIT = 3 const CLAUDE_EXTENSIONS = new Set(['.jsonl']) +// A Claude project dir encodes exactly one cwd, so a resolved cwd never +// changes; caching it spares each rescan the transcript-head reads. +const PROJECT_DIR_CWD_CACHE_MAX = 2048 +const projectDirCwdCache = new Map() + +export function resetProjectDirCwdCacheForTests(): void { + projectDirCwdCache.clear() +} + +async function cachedProjectDirCwd(projectDir: string): Promise { + const cached = projectDirCwdCache.get(projectDir) + if (cached !== undefined) { + // Refresh recency so hot in-scope dirs outlive one-off ones at the cap. + projectDirCwdCache.delete(projectDir) + projectDirCwdCache.set(projectDir, cached) + return cached + } + const cwd = await readProjectDirCwd(projectDir) + if (cwd) { + if (projectDirCwdCache.size >= PROJECT_DIR_CWD_CACHE_MAX) { + const oldest = projectDirCwdCache.keys().next() + if (!oldest.done) { + projectDirCwdCache.delete(oldest.value) + } + } + projectDirCwdCache.set(projectDir, cwd) + } + return cwd +} + /** * Fully include the transcripts of Claude project directories whose cwd falls * inside the active workspace/project paths. @@ -41,7 +71,7 @@ export async function discoverInScopeClaudeFiles(args: { const collected = new Map() for (const rootDir of args.rootDirs) { for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) { - const cwd = await readProjectDirCwd(projectDir) + const cwd = await cachedProjectDirCwd(projectDir) if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) { continue } @@ -205,7 +235,8 @@ async function collectClaudeFiles(args: { addBoundedFile(args.collected, args.limit, { path, mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString() + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size }) } catch (err) { args.issues.push({ agent: 'claude', path, message: errorMessage(err) }) diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts index 1edcf6292f0..364c25b5d85 100644 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -4,8 +4,13 @@ import { join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' -import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import type { + FileWithMtime, + ResumableSessionParseState, + SessionAccumulator +} from './session-scanner-types' import { + accumulatorFoldResumeState, addPreviewContent, addPreviewMessage, createAccumulator, @@ -60,70 +65,75 @@ export async function parseCopilotSessionContent( }) } +function consumeCopilotRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + updateTimeline(accumulator, extractString(record.timestamp)) + const data = asRecord(record.data) + if (record.type === 'session.start' && data) { + const sessionId = extractString(data.sessionId) + if (sessionId) { + accumulator.sessionId = sessionId + } + updateTimeline(accumulator, extractString(data.startTime)) + return + } + if (record.type === 'session.model_change' && data) { + accumulator.model = extractString(data.newModel) ?? accumulator.model + return + } + if (record.type === 'session.info' && data) { + accumulator.cwd = extractTrustedFolder(data.message) ?? accumulator.cwd + return + } + if (record.type === 'user.message' && data) { + accumulator.messageCount++ + accumulator.title ??= normalizeTitleText( + extractString(data.transformedContent) ?? extractString(data.content) ?? '' + ) + addPreviewMessage(accumulator, { + role: 'user', + text: extractString(data.transformedContent) ?? extractString(data.content), + timestamp: record.timestamp + }) + return + } + if (record.type === 'assistant.message' && data) { + accumulator.messageCount++ + addPreviewMessage(accumulator, { + role: 'assistant', + text: extractString(data.content), + timestamp: record.timestamp + }) + return + } + if (record.type === 'session.shutdown' && data) { + accumulator.model = extractString(data.currentModel) ?? accumulator.model + accumulator.totalTokens += numberValue(data.currentTokens) + accumulator.totalTokens += copilotModelMetricsTotal(data.modelMetrics) + } +} + +export function createCopilotSessionResumeState(file: FileWithMtime): ResumableSessionParseState { + return accumulatorFoldResumeState( + createAccumulator({ agent: 'copilot', file, sessionId: sessionIdFromFileName(file.path) }), + consumeCopilotRecordLine + ) +} + async function parseCopilotSessionLines(args: { file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform options?: ParserSessionOptions }): Promise { - const accumulator = createAccumulator({ - agent: 'copilot', - file: args.file, - sessionId: sessionIdFromFileName(args.file.path) - }) - + const state = createCopilotSessionResumeState(args.file) for await (const line of args.lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - updateTimeline(accumulator, extractString(record.timestamp)) - const data = asRecord(record.data) - if (record.type === 'session.start' && data) { - const sessionId = extractString(data.sessionId) - if (sessionId) { - accumulator.sessionId = sessionId - } - updateTimeline(accumulator, extractString(data.startTime)) - continue - } - if (record.type === 'session.model_change' && data) { - accumulator.model = extractString(data.newModel) ?? accumulator.model - continue - } - if (record.type === 'session.info' && data) { - accumulator.cwd = extractTrustedFolder(data.message) ?? accumulator.cwd - continue - } - if (record.type === 'user.message' && data) { - accumulator.messageCount++ - accumulator.title ??= normalizeTitleText( - extractString(data.transformedContent) ?? extractString(data.content) ?? '' - ) - addPreviewMessage(accumulator, { - role: 'user', - text: extractString(data.transformedContent) ?? extractString(data.content), - timestamp: record.timestamp - }) - continue - } - if (record.type === 'assistant.message' && data) { - accumulator.messageCount++ - addPreviewMessage(accumulator, { - role: 'assistant', - text: extractString(data.content), - timestamp: record.timestamp - }) - continue - } - if (record.type === 'session.shutdown' && data) { - accumulator.model = extractString(data.currentModel) ?? accumulator.model - accumulator.totalTokens += numberValue(data.currentTokens) - accumulator.totalTokens += copilotModelMetricsTotal(data.modelMetrics) - } + state.consumeLine(line) } - - return finalizeSession(accumulator, args.platform, args.options) + return state.finalize(args.platform, args.options) } export async function parseCursorSessionFile( @@ -151,40 +161,45 @@ export async function parseCursorSessionContent( }) } +function consumeCursorRecordLine(accumulator: SessionAccumulator, line: string): void { + const record = parseJsonObject(line) + if (!record) { + return + } + updateTimeline(accumulator, extractString(record.timestamp)) + const role = extractString(record.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= extractMessageText(record.message) ?? extractContentText(record.content) + } + addPreviewContent( + accumulator, + role, + asRecord(record.message)?.content ?? record.content, + record.timestamp + ) + } +} + +export function createCursorSessionResumeState(file: FileWithMtime): ResumableSessionParseState { + return accumulatorFoldResumeState( + createAccumulator({ agent: 'cursor', file, sessionId: sessionIdFromFileName(file.path) }), + consumeCursorRecordLine + ) +} + async function parseCursorSessionLines(args: { file: FileWithMtime lines: AsyncIterable | Iterable platform: NodeJS.Platform options?: ParserSessionOptions }): Promise { - const accumulator = createAccumulator({ - agent: 'cursor', - file: args.file, - sessionId: sessionIdFromFileName(args.file.path) - }) - + const state = createCursorSessionResumeState(args.file) for await (const line of args.lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - updateTimeline(accumulator, extractString(record.timestamp)) - const role = extractString(record.role) - if (role === 'user' || role === 'assistant') { - accumulator.messageCount++ - if (role === 'user') { - accumulator.title ??= - extractMessageText(record.message) ?? extractContentText(record.content) - } - addPreviewContent( - accumulator, - role, - asRecord(record.message)?.content ?? record.content, - record.timestamp - ) - } + state.consumeLine(line) } - return finalizeSession(accumulator, args.platform, args.options) + return state.finalize(args.platform, args.options) } export async function parseOpenCodeSessionFile( 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-token-values.ts b/src/main/ai-vault/session-scanner-token-values.ts index 83b288140ec..e2621b9f491 100644 --- a/src/main/ai-vault/session-scanner-token-values.ts +++ b/src/main/ai-vault/session-scanner-token-values.ts @@ -104,3 +104,16 @@ export function subtractCodexUsage( export function numberValue(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0 } + +export function addCodexUsage( + base: CodexUsageSnapshot, + increment: CodexUsageSnapshot +): CodexUsageSnapshot { + return { + inputTokens: base.inputTokens + increment.inputTokens, + cachedInputTokens: base.cachedInputTokens + increment.cachedInputTokens, + outputTokens: base.outputTokens + increment.outputTokens, + reasoningOutputTokens: base.reasoningOutputTokens + increment.reasoningOutputTokens, + totalTokens: base.totalTokens + increment.totalTokens + } +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 5d2208b7b60..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 @@ -41,6 +42,10 @@ export type FileWithMtime = { path: string mtimeMs: number modifiedAt: string + // Present when discovery statted the file; lets the parse cache detect + // unchanged/truncated files without a second stat. Synthetic candidates + // (OpenCode SQLite rows, remote files) omit it. + sizeBytes?: number } export type SessionFileCandidate = { @@ -60,6 +65,26 @@ export type SessionParseResult = { issue: AiVaultScanIssue | null } +export type ResumableParseFinalizeOptions = { + executionHostId?: ExecutionHostId + executionHostPlatform?: NodeJS.Platform | null +} + +// One in-progress parse of an append-only transcript, resumable across scans. +// The parse cache stores a state per file and feeds it only newly appended +// lines; `clone` must deep-copy anything `consumeLine` mutates so a failed +// read or a display-only trailing line can never corrupt the cached fold. +export type ResumableSessionParseState = { + consumeLine(line: string): void + clone(): ResumableSessionParseState + // Refresh per-scan file metadata (mtime display string) without re-parsing. + touchFile(file: FileWithMtime): void + finalize( + platform: NodeJS.Platform, + options?: ResumableParseFinalizeOptions + ): Promise | AiVaultSession | null +} + export type SessionAccumulator = { agent: AiVaultAgent sessionId: string @@ -75,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 8a5a76306b7..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 @@ -154,6 +159,7 @@ export function errorMessage(err: unknown): string { } export { + addCodexUsage, claudeUsageTotal, copilotModelMetricsTotal, normalizeCodexUsage, 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/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 59f92dafc7a..5ba2a95d503 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -4,9 +4,14 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' -import { parseAgentSessionFile } from './session-scanner-agent-parser' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' +import { + createSessionParseStats, + parseAgentSessionFileCached, + type SessionParseStats +} from './session-scanner-parse-cache' import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery' import { DEFAULT_CODEX_HOME_DIR, @@ -39,54 +44,69 @@ const SCOPE_PARSE_LIMIT = 2000 export async function scanAiVaultSessions( options: AiVaultScanOptions = {} ): Promise { - const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT) - const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT) - const platform = options.platform ?? process.platform - const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID - const issues: AiVaultScanIssue[] = [] - const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) + // The span makes scan cost visible in the local trace file: STA-1278-style + // "one core pegged" reports need to show whether transcript scanning is the + // subsystem burning CPU, and how much of each scan the cache absorbed. + return withSpan('aiVault.scan', async (span) => { + const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT) + const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT) + const platform = options.platform ?? process.platform + const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID + const issues: AiVaultScanIssue[] = [] + const parseStats = createSessionParseStats() + const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) - const candidates = discoveries - .flatMap((discovery) => - discovery.files.map( - (file): SessionFileCandidate => ({ - agent: discovery.agent, - file, - codexHome: - discovery.agent === 'codex' - ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) - : null - }) + const candidates = discoveries + .flatMap((discovery) => + discovery.files.map( + (file): SessionFileCandidate => ({ + agent: discovery.agent, + file, + codexHome: + discovery.agent === 'codex' + ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) + : null + }) + ) ) - ) - .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) + .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) - const parsedSessions = await parseSessionCandidates({ - candidates, - limit, - platform, - executionHostId, - issues + const parsedSessions = await parseSessionCandidates({ + candidates, + limit, + platform, + executionHostId, + issues, + parseStats + }) + + const cappedSessions = parsedSessions + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit) + + const scopeSessions = await scanInScopeSessions({ + discoveries, + scopePaths: options.scopePaths ?? [], + alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), + platform, + executionHostId, + issues, + parseStats + }) + + span.setAttribute('candidates', candidates.length) + span.setAttribute('reused', parseStats.reused) + span.setAttribute('incremental', parseStats.incremental) + span.setAttribute('fullParses', parseStats.fullParses) + span.setAttribute('bytesRead', parseStats.bytesRead) + span.setAttribute('issues', issues.length) + + return { + sessions: mergeSessions(cappedSessions, scopeSessions), + issues: issues.map((issue) => ({ executionHostId, ...issue })), + scannedAt: new Date().toISOString() + } }) - - const cappedSessions = parsedSessions - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - .slice(0, limit) - - const scopeSessions = await scanInScopeSessions({ - discoveries, - scopePaths: options.scopePaths ?? [], - alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), - platform, - executionHostId, - issues - }) - - return { - sessions: mergeSessions(cappedSessions, scopeSessions), - issues: issues.map((issue) => ({ executionHostId, ...issue })), - scannedAt: new Date().toISOString() - } } // In-scope sessions are guaranteed regardless of the recency cap, so the global @@ -116,6 +136,7 @@ async function scanInScopeSessions(args: { platform: NodeJS.Platform executionHostId: ExecutionHostId issues: AiVaultScanIssue[] + parseStats: SessionParseStats }): Promise { if (args.scopePaths.length === 0) { return [] @@ -142,7 +163,8 @@ async function scanInScopeSessions(args: { limit: candidates.length, platform: args.platform, executionHostId: args.executionHostId, - issues: args.issues + issues: args.issues, + parseStats: args.parseStats }) } @@ -152,6 +174,7 @@ async function parseSessionCandidates(args: { platform: NodeJS.Platform executionHostId: ExecutionHostId issues: AiVaultScanIssue[] + parseStats: SessionParseStats }): Promise { const sessions: AiVaultSession[] = [] let index = 0 @@ -167,7 +190,7 @@ async function parseSessionCandidates(args: { const batch = args.candidates.slice(index, index + batchSize) const results = await Promise.all( batch.map((candidate) => - parseSessionCandidate(candidate, args.platform, args.executionHostId) + parseSessionCandidate(candidate, args.platform, args.executionHostId, args.parseStats) ) ) @@ -189,10 +212,11 @@ async function parseSessionCandidates(args: { async function parseSessionCandidate( candidate: SessionFileCandidate, platform: NodeJS.Platform, - executionHostId: ExecutionHostId + executionHostId: ExecutionHostId, + parseStats: SessionParseStats ): Promise { try { - const session = await parseAgentSessionFile(candidate, platform) + const session = await parseAgentSessionFileCached(candidate, platform, parseStats) return { session: session ? withSessionExecutionHost(session, executionHostId) : null, issue: null diff --git a/src/main/attribution/terminal-attribution.test.ts b/src/main/attribution/terminal-attribution.test.ts index a051d04ca7d..86096168f0c 100644 --- a/src/main/attribution/terminal-attribution.test.ts +++ b/src/main/attribution/terminal-attribution.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: these tests exercise generated shell wrapper -scripts end-to-end, and keeping the regression fixtures adjacent makes the -attribution safety cases easier to audit. */ import { execFileSync } from 'node:child_process' import { chmodSync, diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index a44ad186f37..dc96b9b964a 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: external automation mapping and lifecycle IPC share fixtures. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createExternalAutomation, diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 767f35931f0..c2965e7f17f 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: cookie import tests share import-time Electron mocks plus - browser-specific cookie fixtures; splitting would duplicate brittle setup. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ diff --git a/src/main/browser/browser-screencast-stream.test.ts b/src/main/browser/browser-screencast-stream.test.ts index 6c6be1cbc5c..d1fc838807d 100644 --- a/src/main/browser/browser-screencast-stream.test.ts +++ b/src/main/browser/browser-screencast-stream.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: one fixture-backed file covers screencast frame, viewport, dialog, and cleanup behavior together. */ import { Buffer } from 'node:buffer' import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' 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-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index e1992fe2360..daaae60540e 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: integration test covering the full browser automation pipeline end-to-end. */ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 1bd4502d246..08a76e4696c 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: scan gating, pricing, and automation -usage attribution share one stateful Claude usage store fixture. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ClaudeUsagePersistedState } from './types' diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 4698281ecd0..b9228fcb6a2 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- WSL CLI tests cover one installer state machine with shared - runner fixtures; splitting would duplicate the fake WSL filesystem setup. */ import type { CliInstallStatus } from '../../shared/cli-install-types' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 0f6d968cc52..7a1facf1420 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -484,6 +484,184 @@ describe('CodexRuntimeHomeService', () => { } }) + it('starts WSL session bridging after materializing the WSL launch home', 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 } } + }) + ) + + 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.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ + distro: 'Ubuntu', + systemCodexHomePath: join(wslHome, '.codex'), + managedCodexHomePath: wslRuntimeHomePath + }) + } 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' }) + const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground + })) + const wslHome = join(testState.userDataDir, 'debian-wsl-home') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => null, + getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null) + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (candidate: string) => + candidate === wslRuntimeHomePath + ? { + distro: 'Debian', + linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' + } + : null + })) + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'debian-account', + '{"account":"debian"}\n' + ) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'debian-account', + email: 'debian@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Debian', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/debian/home', + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Debian: 'debian-account' } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe( + wslRuntimeHomePath + ) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ + distro: 'Debian', + systemCodexHomePath: join(wslHome, '.codex'), + managedCodexHomePath: wslRuntimeHomePath + }) + } finally { + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('../wsl') + vi.doUnmock('../../shared/wsl-paths') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('restores the system-default snapshot when no managed account is selected', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 4d5d8e4c645..67120f15e53 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -40,6 +40,11 @@ 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, syncSystemConfigIntoManagedCodexHome @@ -134,20 +139,53 @@ export class CodexRuntimeHomeService { prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - return ( + const runtimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) ?? this.getWslSystemCodexHomePath(wslTarget) - ) + this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) + return runtimeHomePath } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() 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() } + private startWslSessionBridgeForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (process.platform !== 'win32' || !runtimeHomePath) { + return + } + const runtimeHomeWsl = parseWslUncPath(runtimeHomePath) + const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro() + if (!distro) { + return + } + // 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 + } + // Why: WSL history must be hardlinked inside the distro; host-side links + // cannot bridge Windows and WSL filesystems in a resume-visible way. + void startWslCodexSessionBridgeInBackground({ + distro, + systemCodexHomePath, + managedCodexHomePath: runtimeHomePath + }) + } + getHostRuntimeHomePath(): string { return this.getRuntimeHomePath() } diff --git a/src/main/codex-usage/scanner-paths.test.ts b/src/main/codex-usage/scanner-paths.test.ts index 3f3eec4426a..46c10be0c8f 100644 --- a/src/main/codex-usage/scanner-paths.test.ts +++ b/src/main/codex-usage/scanner-paths.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: path discovery and legacy bridge scan preference cases need shared mocked homes to keep filesystem behavior realistic. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { linkSync, lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index ce9208e9b05..605f2cd5f24 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -3,6 +3,10 @@ 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 { + promoteCodexRuntimeSettingsToSystem, + snapshotCodexRuntimeSettingsBaseline +} from './config-settings-promotion' import { createTomlLineScanState, getTomlTableHeader, @@ -19,11 +23,19 @@ function getSystemCodexConfigTomlPath(): string { } export function syncSystemConfigIntoManagedCodexHome(): 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. + promoteCodexRuntimeSettingsToSystem() try { syncSystemConfigIntoManagedCodexHomeUnsafe() } 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() } function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { 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/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts new file mode 100644 index 00000000000..f81866d8751 --- /dev/null +++ b/src/main/codex/config-settings-promotion.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +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) + // 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', () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + 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') + }) +}) + +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') + }) +}) diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts new file mode 100644 index 00000000000..934d57ed39e --- /dev/null +++ b/src/main/codex/config-settings-promotion.ts @@ -0,0 +1,242 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +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(): void { + try { + const runtimeHomePath = getOrcaManagedCodexHomePath() + 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 } + writeFileSync(getSettingsBaselinePath(runtimeHomePath), `${JSON.stringify(file, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) + } catch (error) { + console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error) + } +} + +/** + * 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. + */ +export function promoteCodexRuntimeSettingsToSystem(): void { + try { + promoteCodexRuntimeSettingsToSystemUnsafe() + } catch (error) { + // Why: promotion is best-effort launch prep; a malformed runtime file + // must not block the config mirror or the Codex launch itself. + console.warn('[codex-settings-promotion] failed to promote runtime settings', error) + } +} + +function promoteCodexRuntimeSettingsToSystemUnsafe(): void { + const runtimeHomePath = getOrcaManagedCodexHomePath() + const systemHomePath = getSystemCodexHomePath() + 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 + } + const systemContent = existsSync(systemTomlPath) ? readFileSync(systemTomlPath, 'utf-8') : '' + writeFileAtomically(systemTomlPath, upsertTopLevelSettingsInContent(systemContent, updates)) +} + +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) + } + + const insertions: string[] = [] + for (const [key, raw] of updates) { + const existingIndex = keyLineIndexes.get(key) + const rendered = `${key} = ${raw}` + if (existingIndex !== undefined) { + // Why: CRLF configs keep a trailing \r after the split; preserve it so + // the rewritten line matches the file's existing endings. + lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered + } else { + insertions.push(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('') + } + lines.splice(insertAt, 0, ...insertions) + } + const result = lines.join('\n') + return result.endsWith('\n') || result.length === 0 ? result : `${result}\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.ts b/src/main/codex/hook-service.ts index 0b01d65554f..ef812ab7ff0 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,16 @@ import { } from './config-toml-trust' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +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 +82,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 +113,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 { @@ -151,7 +148,7 @@ function collectManagedTrustEntries( if (!isManagedCommand(hook.command)) { return } - const entry = createHookTrustEntry( + const entry = createCodexHookTrustEntry( sourcePath, eventName, groupIndex, @@ -167,32 +164,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 +189,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 +209,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 +276,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 +307,7 @@ function getTrustedSystemUserHookSignatures( if (isManagedCommand(hook.command)) { return } - const entry = createHookTrustEntry( + const entry = createCodexHookTrustEntry( systemConfigPath, eventName, groupIndex, @@ -353,20 +318,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 +334,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 +391,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 +409,7 @@ function collectMirroredRuntimeUserHookTrustEntries( if (isManagedCommand(hook.command)) { return } - const entry = createHookTrustEntry( + const entry = createCodexHookTrustEntry( runtimeConfigPath, eventName, groupIndex, @@ -435,10 +420,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 +521,7 @@ function cleanupLegacySystemManagedHooks(): void { return } - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) + const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName()) const nextHooks = { ...config.hooks } const trustEntries: CodexTrustEntry[] = [] let removedManagedHook = false @@ -853,6 +841,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 +861,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 +946,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 +1054,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 +1071,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 +1096,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 +1122,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) 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/codex/wsl-codex-session-bridge.test.ts b/src/main/codex/wsl-codex-session-bridge.test.ts new file mode 100644 index 00000000000..c4218913a5b --- /dev/null +++ b/src/main/codex/wsl-codex-session-bridge.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChildProcess } from 'node:child_process' + +const { execFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock +})) + +import { + buildWslCodexSessionBridgeShellCommand, + resolveWslCodexSessionBridgeLinuxPaths, + startWslCodexSessionBridgeInBackground, + syncWslCodexSessionsIntoManagedHome +} from './wsl-codex-session-bridge' + +function mockExecFileSuccess(stdout = '{"scannedFiles":2,"linkedFiles":1}\n'): void { + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void + ): ChildProcess => { + callback(null, stdout, '') + return {} as ChildProcess + } + ) +} + +beforeEach(() => { + execFileMock.mockReset() +}) + +describe('syncWslCodexSessionsIntoManagedHome', () => { + it('runs a WSL hardlink bridge from the WSL system sessions into the managed home', async () => { + mockExecFileSuccess() + + const summary = await syncWslCodexSessionsIntoManagedHome({ + distro: 'Ubuntu', + systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', + managedCodexHomePath: + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + }) + + expect(summary).toEqual({ scannedFiles: 2, linkedFiles: 1 }) + expect(execFileMock).toHaveBeenCalledTimes(1) + const firstCall = execFileMock.mock.calls[0] + expect(firstCall).toBeDefined() + const [command, args, options] = firstCall as [ + string, + string[], + { timeout?: number; windowsHide?: boolean } + ] + expect(command).toBe('wsl.exe') + expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'bash', '-lc']) + expect(args).toHaveLength(6) + expect(options.timeout).toBe(30_000) + expect(options.windowsHide).toBe(true) + + const shellCommand = args[5] + expect(shellCommand).toContain("source_sessions_root='/home/alice/.codex/sessions'") + expect(shellCommand).toContain( + "managed_sessions_root='/home/alice/.local/share/orca/codex-runtime-home/home/sessions'" + ) + expect(shellCommand).toContain(`find "\\$source_sessions_root" -type f -name '*.jsonl' -print0`) + expect(shellCommand).toContain('ln -- "\\$source_file" "\\$target_file"') + expect(shellCommand).toContain('if [ -e "\\$target_file" ] || [ -L "\\$target_file" ]; then') + expect(shellCommand).not.toContain('ln -s') + expect(shellCommand).not.toContain('cp ') + expect(shellCommand).not.toContain('sqlite') + }) + + it('does not invoke WSL when paths are not resolvable inside the distro', async () => { + const summary = await syncWslCodexSessionsIntoManagedHome({ + distro: 'Ubuntu', + systemCodexHomePath: 'C:\\Users\\alice\\.codex', + managedCodexHomePath: 'C:\\Users\\alice\\AppData\\Roaming\\orca\\codex-runtime-home\\home' + }) + + expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0 }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('parses the summary after WSL profile stdout', async () => { + mockExecFileSuccess('Welcome to Ubuntu\nprofile output\n{"scannedFiles":4,"linkedFiles":3}\n') + + const summary = await syncWslCodexSessionsIntoManagedHome({ + distro: 'Ubuntu', + systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', + managedCodexHomePath: + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + }) + + expect(summary).toEqual({ scannedFiles: 4, linkedFiles: 3 }) + }) + + it('coalesces duplicate background bridges for the same WSL target', async () => { + mockExecFileSuccess() + const target = { + distro: 'Ubuntu', + systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', + managedCodexHomePath: + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + } + + const firstTask = startWslCodexSessionBridgeInBackground(target) + const secondTask = startWslCodexSessionBridgeInBackground(target) + + expect(firstTask).toBe(secondTask) + await firstTask + expect(execFileMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('resolveWslCodexSessionBridgeLinuxPaths', () => { + it('requires both homes to belong to the requested distro', () => { + expect( + resolveWslCodexSessionBridgeLinuxPaths({ + distro: 'Ubuntu', + systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', + managedCodexHomePath: + '\\\\wsl.localhost\\Debian\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + }) + ).toBeNull() + }) + + it('accepts Linux paths for direct script construction tests', () => { + expect( + resolveWslCodexSessionBridgeLinuxPaths({ + distro: 'Ubuntu', + systemCodexHomePath: '/home/alice/.codex', + managedCodexHomePath: '/home/alice/.local/share/orca/codex-runtime-home/home' + }) + ).toEqual({ + systemSessionsRoot: '/home/alice/.codex/sessions', + managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions' + }) + }) +}) + +describe('buildWslCodexSessionBridgeShellCommand', () => { + it('only targets JSONL session files under sessions', () => { + const shellCommand = buildWslCodexSessionBridgeShellCommand({ + systemSessionsRoot: "/home/alice/.codex/sessions with 'quote'", + managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions' + }) + + expect(shellCommand).toContain( + `source_sessions_root='/home/alice/.codex/sessions with '\\''quote'\\'''` + ) + expect(shellCommand).toContain(`-name '*.jsonl'`) + expect(shellCommand).not.toContain('.sqlite') + }) + + it('escapes Linux-side shell variable expansion for wsl.exe argv', () => { + const shellCommand = buildWslCodexSessionBridgeShellCommand({ + systemSessionsRoot: '/home/alice/.codex/sessions', + managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions' + }) + + expect(shellCommand).toContain('\\$source_sessions_root') + expect(shellCommand).toContain('\\$source_file') + expect(shellCommand).toContain('\\$((scanned_files + 1))') + }) +}) diff --git a/src/main/codex/wsl-codex-session-bridge.ts b/src/main/codex/wsl-codex-session-bridge.ts new file mode 100644 index 00000000000..e83abfadd95 --- /dev/null +++ b/src/main/codex/wsl-codex-session-bridge.ts @@ -0,0 +1,185 @@ +import { execFile } from 'node:child_process' +import { posix as pathPosix } from 'node:path' +import { escapeWslShCommandForWindows } from '../../shared/wsl-login-shell-command' +import { parseWslUncPath } from '../../shared/wsl-paths' + +export type WslCodexSessionBridgeTarget = { + distro: string + systemCodexHomePath: string + managedCodexHomePath: string +} + +export type WslCodexSessionBridgeLinuxPaths = { + systemSessionsRoot: string + managedSessionsRoot: string +} + +export type WslCodexSessionBridgeSummary = { + scannedFiles: number + linkedFiles: number +} + +const emptySummary: WslCodexSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 } +const backgroundWslSessionBridgeTasks = new Map>() +const WSL_SESSION_BRIDGE_TIMEOUT_MS = 30_000 + +export function startWslCodexSessionBridgeInBackground( + target: WslCodexSessionBridgeTarget +): Promise { + const taskKey = getWslSessionBridgeTaskKey(target) + const existingTask = backgroundWslSessionBridgeTasks.get(taskKey) + if (existingTask) { + return existingTask + } + + const task = syncWslCodexSessionsIntoManagedHome(target) + .catch((error: unknown) => { + console.warn('[codex-session-bridge] Background WSL session bridge failed:', error) + }) + .then(() => undefined) + backgroundWslSessionBridgeTasks.set(taskKey, task) + void task.finally(() => { + if (backgroundWslSessionBridgeTasks.get(taskKey) === task) { + backgroundWslSessionBridgeTasks.delete(taskKey) + } + }) + return task +} + +export async function syncWslCodexSessionsIntoManagedHome( + target: WslCodexSessionBridgeTarget +): Promise { + const paths = resolveWslCodexSessionBridgeLinuxPaths(target) + if (!paths) { + return emptySummary + } + + const stdout = await execFileUtf8('wsl.exe', [ + '-d', + target.distro, + '--', + 'bash', + '-lc', + buildWslCodexSessionBridgeShellCommand(paths) + ]) + return parseWslSessionBridgeSummary(stdout) +} + +export function resolveWslCodexSessionBridgeLinuxPaths( + target: WslCodexSessionBridgeTarget +): WslCodexSessionBridgeLinuxPaths | null { + const systemHomePath = getLinuxPathForWslDistro(target.systemCodexHomePath, target.distro) + const managedHomePath = getLinuxPathForWslDistro(target.managedCodexHomePath, target.distro) + if (!systemHomePath || !managedHomePath) { + return null + } + + return { + systemSessionsRoot: joinLinuxPath(systemHomePath, 'sessions'), + managedSessionsRoot: joinLinuxPath(managedHomePath, 'sessions') + } +} + +export function buildWslCodexSessionBridgeShellCommand( + paths: WslCodexSessionBridgeLinuxPaths +): string { + const shellCommand = [ + 'set -u', + `source_sessions_root=${quoteBashString(paths.systemSessionsRoot)}`, + `managed_sessions_root=${quoteBashString(paths.managedSessionsRoot)}`, + 'scanned_files=0', + 'linked_files=0', + 'if [ ! -d "$source_sessions_root" ]; then', + ` printf '{"scannedFiles":0,"linkedFiles":0}\\n'`, + ' exit 0', + 'fi', + "while IFS= read -r -d '' source_file; do", + ' scanned_files=$((scanned_files + 1))', + ' relative_path=${source_file#"$source_sessions_root"/}', + ' target_file="$managed_sessions_root/$relative_path"', + ' if [ -e "$target_file" ] || [ -L "$target_file" ]; then', + ' continue', + ' fi', + ' target_dir=${target_file%/*}', + ' mkdir -p -- "$target_dir" || continue', + // Why: Codex resume ignores symlinked JSONL, so WSL links must be + // Linux hardlinks created inside the distro filesystem. + ' if ln -- "$source_file" "$target_file"; then', + ' linked_files=$((linked_files + 1))', + ' fi', + `done < <(find "$source_sessions_root" -type f -name '*.jsonl' -print0 2>/dev/null)`, + `printf '{"scannedFiles":%s,"linkedFiles":%s}\\n' "$scanned_files" "$linked_files"` + ].join('\n') + return escapeWslShCommandForWindows(shellCommand) +} + +function getWslSessionBridgeTaskKey(target: WslCodexSessionBridgeTarget): string { + return [target.distro, target.systemCodexHomePath, target.managedCodexHomePath].join('\0') +} + +function getLinuxPathForWslDistro(path: string, distro: string): string | null { + const wslPath = parseWslUncPath(path) + if (wslPath) { + return wslDistroNamesMatch(wslPath.distro, distro) ? wslPath.linuxPath : null + } + return path.startsWith('/') ? path : null +} + +function wslDistroNamesMatch(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase() +} + +function joinLinuxPath(basePath: string, ...segments: string[]): string { + return pathPosix.join(basePath, ...segments) +} + +function quoteBashString(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function execFileUtf8(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + command, + args, + { + encoding: 'utf-8', + maxBuffer: 1024 * 1024, + timeout: WSL_SESSION_BRIDGE_TIMEOUT_MS, + windowsHide: true + }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + } + ) + }) +} + +function parseWslSessionBridgeSummary(stdout: string): WslCodexSessionBridgeSummary { + try { + // Why: login/profile scripts may write stdout before the bridge summary. + const summaryLine = + stdout + .split(/\r?\n/) + .findLast((line) => line.trim().length > 0) + ?.trim() ?? '' + const parsed: unknown = JSON.parse(summaryLine) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return emptySummary + } + const summary = parsed as Record + if (typeof summary.scannedFiles !== 'number' || typeof summary.linkedFiles !== 'number') { + return emptySummary + } + return { + scannedFiles: summary.scannedFiles, + linkedFiles: summary.linkedFiles + } + } catch { + return emptySummary + } +} diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 7d2bea915d2..25aaebe164f 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: daemon connection, RPC, event, and disconnect behavior share one socket test harness. */ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createServer, type Server, type Socket } from 'node:net' 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/reattach-snapshot.test.ts b/src/main/daemon/reattach-snapshot.test.ts index 8735b76be53..a8bdf252442 100644 --- a/src/main/daemon/reattach-snapshot.test.ts +++ b/src/main/daemon/reattach-snapshot.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines */ import { afterEach, describe, expect, it, vi } from 'vitest' import { TerminalHost } from './terminal-host' import { HeadlessEmulator } from './headless-emulator' 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/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index af3ced5ade5..b741ef057d0 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: shell-ready wrapper coverage keeps zsh, - bash, marker scanning, and env restoration cases in one suite so the - generated wrapper contract is reviewed as a unit. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index ecc9fd1161d..4f92d4c1e1f 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Session, type SubprocessHandle } from './session' import { TerminalHost } from './terminal-host' 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/diagnostics/main-thread-churn-probe.test.ts b/src/main/diagnostics/main-thread-churn-probe.test.ts new file mode 100644 index 00000000000..cfc159fc1fb --- /dev/null +++ b/src/main/diagnostics/main-thread-churn-probe.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + MAIN_THREAD_DIAGNOSTICS_ENV, + classifySubprocessCommand, + drainSubprocessSpawnStats, + isMainThreadDiagnosticsEnabled, + recordSubprocessSpawn +} from './main-thread-churn-probe' + +afterEach(() => { + vi.unstubAllEnvs() + drainSubprocessSpawnStats() +}) + +describe('classifySubprocessCommand', () => { + it('uses the git subcommand, skipping global value flags', () => { + expect(classifySubprocessCommand('git', ['-C', '/repo', 'status', '--porcelain=v2'])).toBe( + 'git status' + ) + expect( + classifySubprocessCommand('git', ['-c', 'core.quotepath=off', 'rev-list', '--count']) + ).toBe('git rev-list') + expect(classifySubprocessCommand('git', ['--git-dir=/repo/.git', 'log', '--oneline'])).toBe( + 'git log' + ) + }) + + it('normalizes absolute paths and .exe suffixes', () => { + expect(classifySubprocessCommand('/usr/bin/git', ['status'])).toBe('git status') + expect(classifySubprocessCommand('C:\\Program Files\\Git\\git.exe', ['fetch'])).toBe( + 'git fetch' + ) + expect(classifySubprocessCommand('gh', ['api', 'rate_limit'])).toBe('gh api') + }) + + it('unwraps wsl.exe-routed commands', () => { + expect( + classifySubprocessCommand('wsl.exe', ['-d', 'Ubuntu', '--', 'git', 'status', '--porcelain']) + ).toBe('git status') + expect(classifySubprocessCommand('wsl.exe', ['-d', 'Ubuntu'])).toBe('wsl') + }) + + it('falls back to the binary name when no subcommand exists', () => { + expect(classifySubprocessCommand('rg', ['--files'])).toBe('rg') + }) + + it('never treats positionals or git-flag values as subcommands for non-subcommand CLIs', () => { + // rg's -C takes a number; it must not be consumed as a git-style flag + // value, and "3"/"pattern" must not become fake subcommand buckets. + expect(classifySubprocessCommand('rg', ['-C', '3', 'pattern'])).toBe('rg') + expect(classifySubprocessCommand('node', ['script.js'])).toBe('node') + }) +}) + +describe('recordSubprocessSpawn', () => { + it('is a no-op when the diagnostics env var is unset', () => { + vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '') + expect(isMainThreadDiagnosticsEnabled()).toBe(false) + recordSubprocessSpawn('git', ['status'], 1) + expect(drainSubprocessSpawnStats()).toEqual({}) + }) + + it('aggregates count and block time per command, and drain resets', () => { + vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1') + recordSubprocessSpawn('git', ['-C', '/repo', 'status'], 2) + recordSubprocessSpawn('/usr/bin/git', ['status', '--porcelain=v2'], 4) + recordSubprocessSpawn('git', ['rev-list', '--count'], 1.5) + expect(drainSubprocessSpawnStats()).toEqual({ + 'git status': { count: 2, blockMsTotal: 6, blockMsMax: 4 }, + 'git rev-list': { count: 1, blockMsTotal: 1.5, blockMsMax: 1.5 } + }) + expect(drainSubprocessSpawnStats()).toEqual({}) + }) +}) diff --git a/src/main/diagnostics/main-thread-churn-probe.ts b/src/main/diagnostics/main-thread-churn-probe.ts new file mode 100644 index 00000000000..f4fe900cf8c --- /dev/null +++ b/src/main/diagnostics/main-thread-churn-probe.ts @@ -0,0 +1,185 @@ +import { writeStartupDiagnosticLine } from '../startup/startup-diagnostics' + +export const MAIN_THREAD_DIAGNOSTICS_ENV = 'ORCA_MAIN_THREAD_DIAGNOSTICS' + +// Why: 25ms mirrors event-loop-stall-probe — a timer that fires late by N ms +// proves the main thread was blocked for N ms, which is the direct in-process +// measurement of the macOS "Performance Diagnostics" main-thread warnings +// reported in issue #7576. +const TICK_MS = 25 +const REPORT_EVERY_MS = 5_000 + +export function isMainThreadDiagnosticsEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env[MAIN_THREAD_DIAGNOSTICS_ENV] === '1' +} + +// Git global options that precede the subcommand. Value-taking flags must be +// skipped together with their value to find the real subcommand. +const GIT_VALUE_FLAGS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--exec-path']) + +// Why: only subcommand-style CLIs get a " " bucket; for +// anything else (rg, node, …) the first positional is an operand, not a +// subcommand, and would fragment the aggregation. +const SUBCOMMAND_BINARIES = new Set(['git', 'gh', 'glab']) + +/** + * Reduce a resolved spawn to a stable aggregation key like "git status" or + * "gh api". Handles WSL wrapping (`wsl.exe -d -- git …`), absolute + * binary paths, `.exe` suffixes, and git global flags before the subcommand. + */ +// Split on both separators so Windows-style paths classify correctly even +// when the classifier itself runs in a posix test environment. +function binaryName(command: string): string { + const leaf = command.split(/[\\/]/).pop() ?? command + return leaf.replace(/\.exe$/i, '').toLowerCase() +} + +export function classifySubprocessCommand(command: string, args: readonly string[]): string { + let binary = binaryName(command) + const rest = [...args] + if (binary === 'wsl') { + while (rest.length > 0) { + const arg = rest.shift() + if (arg === '--') { + break + } + } + const unwrapped = rest.shift() + if (!unwrapped) { + return 'wsl' + } + binary = binaryName(unwrapped) + } + if (!SUBCOMMAND_BINARIES.has(binary)) { + return binary + } + let subcommand: string | null = null + for (let i = 0; i < rest.length; i++) { + const arg = rest[i] + if (!arg.startsWith('-')) { + subcommand = arg + break + } + // Why: only git's global flags take a separate value; e.g. rg's -C takes + // a number that must not be consumed as if it were a flag value. + if (binary === 'git' && GIT_VALUE_FLAGS.has(arg)) { + i++ + } + } + if (!subcommand) { + return binary + } + return `${binary} ${subcommand.slice(0, 40)}` +} + +export type SubprocessSpawnStats = { + count: number + // Cumulative and worst synchronous cost of initiating the spawn (the + // uv_spawn → posix_spawn call runs on the main thread before returning). + blockMsTotal: number + blockMsMax: number +} + +const spawnStatsByCommand = new Map() + +/** + * Record one subprocess spawn from the main process. `blockMs` is how long + * the synchronous spawn/execFile initiation call held the main thread. + * No-op unless ORCA_MAIN_THREAD_DIAGNOSTICS=1. + */ +export function recordSubprocessSpawn( + command: string, + args: readonly string[], + blockMs: number +): void { + if (!isMainThreadDiagnosticsEnabled()) { + return + } + const key = classifySubprocessCommand(command, args) + const stats = spawnStatsByCommand.get(key) + if (stats) { + stats.count++ + stats.blockMsTotal += blockMs + stats.blockMsMax = Math.max(stats.blockMsMax, blockMs) + } else { + spawnStatsByCommand.set(key, { count: 1, blockMsTotal: blockMs, blockMsMax: blockMs }) + } +} + +export function drainSubprocessSpawnStats(): Record { + const drained: Record = {} + for (const [key, stats] of spawnStatsByCommand) { + drained[key] = { + count: stats.count, + blockMsTotal: Math.round(stats.blockMsTotal * 100) / 100, + blockMsMax: Math.round(stats.blockMsMax * 100) / 100 + } + } + spawnStatsByCommand.clear() + return drained +} + +/** + * Timestamped marker line for correlating a specific main-process activity + * (e.g. an updater check) with the probe's stall windows and with macOS + * Performance Diagnostics log entries in field captures. No-op unless + * ORCA_MAIN_THREAD_DIAGNOSTICS=1. + */ +export function writeMainThreadDiagnosticMarker(marker: string): void { + if (!isMainThreadDiagnosticsEnabled()) { + return + } + writeStartupDiagnosticLine( + `[main-thread] ${JSON.stringify({ marker, t: Math.round(performance.now()) })}` + ) +} + +/** + * Long-running main-process jank probe for benchmarks and field diagnosis of + * issue #7576. Every 5s emits one `[main-thread] {json}` stderr line with the + * window's worst event-loop stall, stall counts over 50/250ms, and drained + * subprocess spawn stats. Unlike the startup stall probe this never stops: + * the churn it measures (git status polling, updater retries) is steady-state. + */ +export function startMainThreadChurnProbe(): void { + if (!isMainThreadDiagnosticsEnabled()) { + return + } + let last = performance.now() + let lastReport = last + let windowMaxGapMs = 0 + let gapsOver50Ms = 0 + let gapsOver250Ms = 0 + const timer = setInterval(() => { + const now = performance.now() + const gap = now - last - TICK_MS + last = now + if (gap > windowMaxGapMs) { + windowMaxGapMs = gap + } + if (gap > 50) { + gapsOver50Ms++ + } + if (gap > 250) { + gapsOver250Ms++ + } + if (now - lastReport < REPORT_EVERY_MS) { + return + } + lastReport = now + const spawns = drainSubprocessSpawnStats() + const report = { + t: Math.round(now), + maxGapMs: Math.max(0, Math.round(windowMaxGapMs)), + gapsOver50Ms, + gapsOver250Ms, + spawnCount: Object.values(spawns).reduce((sum, s) => sum + s.count, 0), + spawns + } + windowMaxGapMs = 0 + gapsOver50Ms = 0 + gapsOver250Ms = 0 + writeStartupDiagnosticLine(`[main-thread] ${JSON.stringify(report)}`) + }, TICK_MS) + timer.unref?.() +} 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/git/gh-rate-limit-breaker.test.ts b/src/main/git/gh-rate-limit-breaker.test.ts new file mode 100644 index 00000000000..0975dd325ae --- /dev/null +++ b/src/main/git/gh-rate-limit-breaker.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + _resetGhRateLimitBreaker, + classifyGhRateLimitBucket, + createGhRateLimitBlockedError, + getGhRateLimitBlockedUntilMs, + isGhPrimaryRateLimitStderr, + isGhRateLimitProbe, + notifyGhPrimaryRateLimit, + recordGhPrimaryRateLimit, + registerGhRateLimitResetProbe +} from './gh-rate-limit-breaker' + +afterEach(() => { + _resetGhRateLimitBreaker() +}) + +describe('classifyGhRateLimitBucket', () => { + it('classifies search API endpoints, skipping flag values', () => { + expect( + classifyGhRateLimitBucket([ + 'api', + '--cache', + '120s', + 'search/issues?q=repo:a/b is:issue is:open&per_page=1', + '--jq', + '.total_count' + ]) + ).toBe('search') + expect(classifyGhRateLimitBucket(['search', 'prs', '--author', 'x'])).toBe('search') + }) + + it('classifies graphql and defaults everything else to core', () => { + expect(classifyGhRateLimitBucket(['api', 'graphql', '-f', 'query=…'])).toBe('graphql') + expect(classifyGhRateLimitBucket(['api', 'repos/a/b/pulls?per_page=36'])).toBe('core') + expect(classifyGhRateLimitBucket(['pr', 'list', '--limit', '36'])).toBe('core') + }) +}) + +describe('isGhRateLimitProbe', () => { + it('recognizes the exempt rate_limit endpoint only', () => { + expect(isGhRateLimitProbe(['api', 'rate_limit'])).toBe(true) + expect(isGhRateLimitProbe(['api', '/rate_limit'])).toBe(true) + expect(isGhRateLimitProbe(['api', 'search/issues?q=x'])).toBe(false) + expect(isGhRateLimitProbe(['pr', 'list'])).toBe(false) + }) +}) + +describe('isGhPrimaryRateLimitStderr', () => { + it('matches primary rate-limit 403s but not secondary limits', () => { + expect( + isGhPrimaryRateLimitStderr( + 'gh: API rate limit exceeded for user ID 1775218. If you reach out to GitHub Support… (HTTP 403)' + ) + ).toBe(true) + expect(isGhPrimaryRateLimitStderr('You have exceeded a secondary rate limit.')).toBe(false) + expect(isGhPrimaryRateLimitStderr('gh: Not Found (HTTP 404)')).toBe(false) + }) +}) + +describe('breaker state', () => { + it('blocks until the recorded time, then expires', () => { + const now = 1_000_000 + recordGhPrimaryRateLimit('search', now + 5_000) + expect(getGhRateLimitBlockedUntilMs('search', now)).toBe(now + 5_000) + expect(getGhRateLimitBlockedUntilMs('core', now)).toBeNull() + expect(getGhRateLimitBlockedUntilMs('search', now + 5_001)).toBeNull() + // Expiry is sticky — the entry was removed on the expired read. + expect(getGhRateLimitBlockedUntilMs('search', now)).toBeNull() + }) + + it('keeps the later of two recorded reset times', () => { + const now = 1_000_000 + recordGhPrimaryRateLimit('core', now + 60_000) + recordGhPrimaryRateLimit('core', now + 10_000) + expect(getGhRateLimitBlockedUntilMs('core', now)).toBe(now + 60_000) + }) + + it('notifyGhPrimaryRateLimit applies a fallback block and fires the reset probe', () => { + const probe = vi.fn() + registerGhRateLimitResetProbe(probe) + notifyGhPrimaryRateLimit('search') + expect(probe).toHaveBeenCalledWith('search') + expect(getGhRateLimitBlockedUntilMs('search')).toBeGreaterThan(Date.now()) + }) + + it('creates an error that classifies as rate_limited, not permission_denied', () => { + const error = createGhRateLimitBlockedError('search', Date.now() + 30_000) + expect(error.stderr.toLowerCase()).toContain('rate limit') + expect(error.stderr.toLowerCase()).not.toContain('403') + expect(error.ghRateLimitBlocked).toBe(true) + }) +}) diff --git a/src/main/git/gh-rate-limit-breaker.ts b/src/main/git/gh-rate-limit-breaker.ts new file mode 100644 index 00000000000..94bef46ff7a --- /dev/null +++ b/src/main/git/gh-rate-limit-breaker.ts @@ -0,0 +1,166 @@ +/** + * Global circuit breaker for gh CLI primary rate limits. + * + * Why: a primary rate-limit 403 means every further request in that bucket + * will fail until GitHub's reset time. Without shared state, a 90-repo + * Tasks-page fan-out keeps spawning gh subprocesses that each take a fresh + * 403 — burning main-thread spawn time and log noise while returning nothing + * (the Discord "countWorkItems failed ×90" storm). This module holds + * blocked-until state consulted by the runner before every gh spawn. + * + * Lives under git/ (not github/) with zero imports so both the runner and + * the github rate-limit prober can use it without an import cycle. + */ + +export type GhRateLimitBucket = 'core' | 'search' | 'graphql' + +// Why: a primary 403 does not carry the reset time. The search window resets +// every minute; core/graphql reset hourly but blocking a full hour on a guess +// would be too punishing, so pick a modest re-probe interval. The registered +// reset probe (gh api rate_limit — exempt from limits) refines these to the +// real reset time right after the breaker trips. +const FALLBACK_BLOCK_MS: Record = { + search: 70_000, + core: 5 * 60_000, + graphql: 5 * 60_000 +} + +const blockedUntilMsByBucket = new Map() +let resetProbe: ((bucket: GhRateLimitBucket) => void) | null = null + +// gh api flags that take a separate value, so the endpoint arg can be found. +const GH_API_VALUE_FLAGS = new Set([ + '--cache', + '--jq', + '-q', + '--method', + '-X', + '--field', + '-F', + '--raw-field', + '-f', + '--header', + '-H', + '--hostname', + '--input', + '--template', + '-t', + '--preview', + '-p' +]) + +function findGhApiEndpoint(args: readonly string[]): string | null { + for (let i = 1; i < args.length; i++) { + const arg = args[i] + if (!arg.startsWith('-')) { + return arg + } + if (GH_API_VALUE_FLAGS.has(arg)) { + i++ + } + } + return null +} + +export function classifyGhRateLimitBucket(args: readonly string[]): GhRateLimitBucket { + const command = args[0] + if (command === 'search') { + return 'search' + } + if (command !== 'api') { + return 'core' + } + const endpoint = findGhApiEndpoint(args) + if (!endpoint) { + return 'core' + } + const path = endpoint.replace(/^\//, '') + if (path.startsWith('search/')) { + return 'search' + } + if (path === 'graphql' || path.startsWith('graphql?')) { + return 'graphql' + } + return 'core' +} + +/** `gh api rate_limit` is exempt from rate limits and must never be blocked — + * it is the probe that refines the breaker's reset time. */ +export function isGhRateLimitProbe(args: readonly string[]): boolean { + if (args[0] !== 'api') { + return false + } + const endpoint = findGhApiEndpoint(args) + return endpoint === 'rate_limit' || endpoint === '/rate_limit' +} + +/** + * Primary rate-limit detection. Secondary limits ("secondary rate limit") + * carry Retry-After and are handled by the runner's transient retry logic. + */ +export function isGhPrimaryRateLimitStderr(stderr: string): boolean { + const s = stderr.toLowerCase() + return s.includes('api rate limit exceeded') && !s.includes('secondary rate limit') +} + +export function recordGhPrimaryRateLimit(bucket: GhRateLimitBucket, blockedUntilMs: number): void { + const existing = blockedUntilMsByBucket.get(bucket) ?? 0 + blockedUntilMsByBucket.set(bucket, Math.max(existing, blockedUntilMs)) +} + +export function clearGhRateLimitBlock(bucket: GhRateLimitBucket): void { + blockedUntilMsByBucket.delete(bucket) +} + +export function getGhRateLimitBlockedUntilMs( + bucket: GhRateLimitBucket, + nowMs: number = Date.now() +): number | null { + const blockedUntil = blockedUntilMsByBucket.get(bucket) + if (blockedUntil === undefined) { + return null + } + if (blockedUntil <= nowMs) { + blockedUntilMsByBucket.delete(bucket) + return null + } + return blockedUntil +} + +/** Register the (single) callback that refreshes precise reset times after a + * breaker trip. Registered by the github rate-limit prober at module load. */ +export function registerGhRateLimitResetProbe( + probe: ((bucket: GhRateLimitBucket) => void) | null +): void { + resetProbe = probe +} + +/** Called by the runner when a gh spawn came back with a primary 403. */ +export function notifyGhPrimaryRateLimit(bucket: GhRateLimitBucket): void { + recordGhPrimaryRateLimit(bucket, Date.now() + FALLBACK_BLOCK_MS[bucket]) + try { + resetProbe?.(bucket) + } catch { + // best-effort refinement; the fallback block stands + } +} + +export function createGhRateLimitBlockedError( + bucket: GhRateLimitBucket, + blockedUntilMs: number +): Error & { stderr: string; ghRateLimitBlocked: true } { + const resetsIn = Math.max(1, Math.ceil((blockedUntilMs - Date.now()) / 1000)) + // Why: "rate limit" (and no "HTTP 403") so classifyGhError maps this to + // rate_limited instead of permission_denied, matching a real gh failure. + const message = `GitHub API rate limit exceeded (${bucket}); retrying in ~${resetsIn}s without spawning gh.` + return Object.assign(new Error(message), { + stderr: message, + ghRateLimitBlocked: true as const + }) +} + +/** @internal — test-only */ +export function _resetGhRateLimitBreaker(): void { + blockedUntilMsByBucket.clear() + resetProbe = null +} 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-gh-rate-limit-breaker.test.ts b/src/main/git/runner-gh-rate-limit-breaker.test.ts new file mode 100644 index 00000000000..dc22bb54f4e --- /dev/null +++ b/src/main/git/runner-gh-rate-limit-breaker.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + execFileSyncMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: execFileMock, + execFileSync: execFileSyncMock, + spawn: spawnMock +})) + +import { ghExecFileAsync } from './runner' +import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' + +const PRIMARY_RATE_LIMIT_STDERR = + 'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)' + +function mockGhFailure(stderr: string): void { + execFileMock.mockImplementation((_binary, _args, options, callback) => { + const done = typeof options === 'function' ? options : callback + queueMicrotask(() => + done(Object.assign(new Error(`Command failed: gh\n${stderr}`), { stderr }), '', stderr) + ) + return { once: vi.fn() } + }) +} + +function mockGhSuccess(stdout: string): void { + execFileMock.mockImplementation((_binary, _args, options, callback) => { + const done = typeof options === 'function' ? options : callback + queueMicrotask(() => done(null, stdout, '')) + return { once: vi.fn() } + }) +} + +beforeEach(() => { + execFileMock.mockReset() +}) + +afterEach(() => { + _resetGhRateLimitBreaker() +}) + +describe('ghExecFileAsync rate-limit breaker', () => { + it('spawns once for a primary 403, then short-circuits same-bucket calls', async () => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect( + ghExecFileAsync(['api', '--cache', '120s', 'search/issues?q=repo:a/b&per_page=1']) + ).rejects.toThrow('rate limit') + expect(execFileMock).toHaveBeenCalledTimes(1) + + // The 90-repo storm case: every further search-bucket call must fail fast + // without a subprocess. + await expect( + ghExecFileAsync(['api', '--cache', '120s', 'search/issues?q=repo:c/d&per_page=1']) + ).rejects.toMatchObject({ ghRateLimitBlocked: true }) + expect(execFileMock).toHaveBeenCalledTimes(1) + }) + + it('keeps other buckets working while one bucket is blocked', async () => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect(ghExecFileAsync(['api', 'search/issues?q=x'])).rejects.toThrow() + mockGhSuccess('[]') + await expect(ghExecFileAsync(['pr', 'list', '--limit', '36'])).resolves.toEqual({ + stdout: '[]', + stderr: '' + }) + expect(execFileMock).toHaveBeenCalledTimes(2) + }) + + it('never blocks the exempt rate_limit probe', async () => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).rejects.toThrow() + mockGhSuccess('{"resources":{}}') + await expect(ghExecFileAsync(['api', 'rate_limit'])).resolves.toMatchObject({ + stdout: '{"resources":{}}' + }) + expect(execFileMock).toHaveBeenCalledTimes(2) + }) + + it('does not trip the breaker on secondary rate limits', async () => { + mockGhFailure('gh: You have exceeded a secondary rate limit. (HTTP 403)') + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { idempotent: false }) + ).rejects.toThrow() + mockGhSuccess('[]') + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).resolves.toEqual({ + stdout: '[]', + stderr: '' + }) + }) +}) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 4bafa9f1984..3aae182432c 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: WSL fallback, retry safety, and glab parity share mocks. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as WslModule from '../wsl' diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index ec0d1d7b84d..c9009845369 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -19,6 +19,15 @@ import { } from 'node:child_process' import { StringDecoder } from 'node:string_decoder' import { withGitSpan } from '../observability/instrumentation' +import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe' +import { + classifyGhRateLimitBucket, + createGhRateLimitBlockedError, + getGhRateLimitBlockedUntilMs, + isGhPrimaryRateLimitStderr, + isGhRateLimitProbe, + notifyGhPrimaryRateLimit +} from './gh-rate-limit-breaker' import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' import { @@ -381,6 +390,7 @@ function execFileCapture( } try { + const spawnStartedAt = performance.now() child = execFile( command, args, @@ -399,6 +409,7 @@ function execFileCapture( finish(error, stdout, stderr) } ) + recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt) } catch (error) { finish(error instanceof Error ? error : new Error(String(error))) return @@ -441,12 +452,14 @@ async function spawnCommandCapture( let stderr = '' let stdoutBytes = 0 let stderrBytes = 0 + const spawnStartedAt = performance.now() const child = spawn(spawnCmd, spawnArgs, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) + recordSubprocessSpawn(spawnCmd, spawnArgs, performance.now() - spawnStartedAt) let timer: NodeJS.Timeout | null = null const onAbort = (): void => { killSpawnedCommandTree(child) @@ -1033,12 +1046,19 @@ export function gitExecFileSync( } ): string { const resolved = resolveCommand('git', args, options.cwd) - return execFileSync(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: options.encoding ?? 'utf-8', - stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'], - timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS - }) as string + const spawnStartedAt = performance.now() + try { + return execFileSync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: options.encoding ?? 'utf-8', + stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'], + timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS + }) as string + } finally { + // Sync exec holds the main thread for its whole duration, so the entire + // call is main-thread block time — the cost issue #7576 flags. + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + } } /** @@ -1053,10 +1073,13 @@ export function gitSpawn( const resolved = resolveCommand('git', args, options.cwd, wslDistro, { useWslLoginShell: Boolean(wslDistro) }) - return spawn(resolved.binary, resolved.args, { + const spawnStartedAt = performance.now() + const child = spawn(resolved.binary, resolved.args, { ...spawnOptions, cwd: resolved.cwd }) + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + return child } // ─── gh CLI runners ───────────────────────────────────────────────── @@ -1356,6 +1379,16 @@ export async function ghExecFileAsync( args: string[], options: GhExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { + // Why: while a bucket's primary rate limit is exhausted, every spawn would + // return the same 403 — fail fast without paying the subprocess cost. The + // rate_limit probe itself is exempt so the breaker can learn the reset time. + const rateLimitBucket = classifyGhRateLimitBucket(args) + if (!isGhRateLimitProbe(args)) { + const blockedUntilMs = getGhRateLimitBlockedUntilMs(rateLimitBucket) + if (blockedUntilMs !== null) { + throw createGhRateLimitBlockedError(rateLimitBucket, blockedUntilMs) + } + } let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) let lastError: unknown let attemptedHostFallback = false @@ -1375,6 +1408,9 @@ export async function ghExecFileAsync( } catch (err) { lastError = err const { stderr } = extractExecError(err) + if (isGhPrimaryRateLimitStderr(stderr)) { + notifyGhPrimaryRateLimit(rateLimitBucket) + } if ( process.platform === 'win32' && !attemptedDefaultWslFallback && @@ -1554,10 +1590,13 @@ export function wslAwareSpawn( const resolved = resolveCommand(command, args, options.cwd, wslDistro, { useWslLoginShell }) - return spawn(resolved.binary, resolved.args, { + const spawnStartedAt = performance.now() + const child = spawn(resolved.binary, resolved.args, { ...spawnOptions, cwd: resolved.cwd }) + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + return child } // ─── Path translation helpers ─────────────────────────────────────── diff --git a/src/main/git/status-upstream-probe-churn.test.ts b/src/main/git/status-upstream-probe-churn.test.ts index f5e8f06c34b..49f6913e0af 100644 --- a/src/main/git/status-upstream-probe-churn.test.ts +++ b/src/main/git/status-upstream-probe-churn.test.ts @@ -186,6 +186,90 @@ describe('getStatus missing-upstream polling churn', () => { expect(sameNameOriginProbeCalls).toHaveLength(1) }) + // The pushed-but-untracked branch shape (issue #7576): the effective + // upstream resolves to the same-name origin ref, and before the resolved-name + // cache each 3s poll re-ran the whole resolution chain (~5 spawns/tick). + function mockResolvedSameNameOrigin(revList: { failOnCall?: number } = {}): void { + let revListCalls = 0 + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + return { + stdout: '# branch.oid abcdef1234567890\n# branch.head bench/feature\n' + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: 'bench/feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error("fatal: no upstream configured for branch 'bench/feature'") + } + if (isConfigListSnapshotCommand(args)) { + return emptyGitConfigSnapshot() + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/bench/feature')) { + return { stdout: 'abcdef1234567890\n' } + } + if (args[0] === 'rev-list') { + revListCalls++ + if (revList.failOnCall === revListCalls) { + throw new Error('fatal: bad revision') + } + return { stdout: '1\t0\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + } + + function countCalls(predicate: (args: string[]) => boolean): number { + return gitExecFileAsyncMock.mock.calls.filter((call) => predicate(getGitArgs(call))).length + } + + it('revalidates a resolved same-name upstream with a single rev-list per poll', async () => { + mockResolvedSameNameOrigin() + + await getStatus('/repo') + await getStatus('/repo') + await getStatus('/repo') + + // Resolution chain ran once; the two later polls paid one rev-list each. + expect(countCalls((args) => args[0] === 'symbolic-ref')).toBe(1) + expect(countCalls((args) => args[0] === 'rev-parse' && args.includes('HEAD@{u}'))).toBe(1) + expect(countCalls(isConfigListSnapshotCommand)).toBe(1) + expect( + countCalls( + (args) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/bench/feature') + ) + ).toBe(1) + expect(countCalls((args) => args[0] === 'rev-list')).toBe(3) + }) + + it('re-resolves the upstream after the resolved-name cache TTL lapses', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + mockResolvedSameNameOrigin() + + await getStatus('/repo') + vi.setSystemTime(61_000) + await getStatus('/repo') + + expect(countCalls((args) => args[0] === 'symbolic-ref')).toBe(2) + expect(countCalls((args) => args[0] === 'rev-list')).toBe(2) + }) + + it('falls back to a full re-resolve when the cached upstream rev-list fails', async () => { + // Second rev-list (the first revalidation attempt) fails, e.g. deleted ref. + mockResolvedSameNameOrigin({ failOnCall: 2 }) + + await getStatus('/repo') + const status = await getStatus('/repo') + + // Full chain ran twice: initial resolve + the fallback after the failure. + expect(countCalls((args) => args[0] === 'symbolic-ref')).toBe(2) + // rev-list: initial + failed revalidate + fallback's own rev-list. + expect(countCalls((args) => args[0] === 'rev-list')).toBe(3) + expect(status.upstreamStatus?.hasUpstream).toBe(true) + }) + it('does not cache a positive configured push target signal', async () => { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args.includes('status')) { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index e7b0d95b2c8..862c8c9b76b 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -19,6 +19,7 @@ import type { import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import { getEffectiveGitUpstreamStatus, + getGitUpstreamStatusForUpstreamName, splitRemoteBranchName } from '../../shared/git-effective-upstream' import { createGitConfigSnapshotRunner } from '../../shared/git-config-snapshot-runner' @@ -66,6 +67,21 @@ const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } const submodulePathsCache = new Map() +// Why: the effective-upstream resolution chain (symbolic-ref + rev-parse ×2-3 +// + config snapshot) costs 4-5 subprocess spawns and only changes when branch +// or git config changes. Ahead/behind is a pure function of the two rev-list +// endpoints, so a recently-resolved name can be revalidated with one rev-list +// spawn per poll tick; a failed rev-list (deleted ref) falls back to a full +// re-resolve. Issue #7576: this path dominated idle main-process spawn churn. +const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000 + +type ResolvedUpstreamNameCacheEntry = { + upstreamName: string + expiresAt: number +} + +const resolvedUpstreamNameCache = new Map() + const effectiveUpstreamStatusCache = new Map() const effectiveUpstreamStatusInFlight = new Map>() const retiredEffectiveUpstreamStatusInFlight = new Map>() @@ -80,6 +96,7 @@ function clearGitReadInvalidationState(): void { gitDiffReadDedupe.clear() statusReadsInFlight.clear() submodulePathsCache.clear() + resolvedUpstreamNameCache.clear() } export function clearSubmodulePathsCacheForTests(): void { @@ -508,6 +525,7 @@ export function clearEffectiveUpstreamNegativeStatusCache(identity: { retireEffectiveUpstreamStatusProbe(cacheKey) effectiveUpstreamStatusCache.delete(cacheKey) effectiveUpstreamStatusInFlight.delete(cacheKey) + resolvedUpstreamNameCache.delete(cacheKey) effectiveUpstreamStatusWriteGeneration.set( cacheKey, (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1 @@ -626,7 +644,13 @@ async function readOrProbeEffectiveUpstreamStatus( // Why: source-control mount and root git refresh can overlap during startup. // Coalesce the richer upstream probe so a stable missing ref fails once. const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0 - const probe = probeEffectiveUpstreamStatus(worktreePath, branchName, options).then((result) => { + const probe = probeOrRevalidateEffectiveUpstreamStatus( + cacheKey, + worktreePath, + branchName, + options, + bypassCache + ).then((result) => { rememberEffectiveUpstreamStatus( cacheKey, result.status, @@ -649,6 +673,46 @@ async function readOrProbeEffectiveUpstreamStatus( } } +async function probeOrRevalidateEffectiveUpstreamStatus( + cacheKey: string, + worktreePath: string, + branchName: string, + options: GitRuntimeOptions = {}, + bypassCache = false +): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> { + const now = Date.now() + const cached = resolvedUpstreamNameCache.get(cacheKey) + if (cached && (bypassCache || cached.expiresAt <= now)) { + resolvedUpstreamNameCache.delete(cacheKey) + } else if (cached) { + try { + const status = await getGitUpstreamStatusForUpstreamName( + (args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)), + cached.upstreamName + ) + return { status, probedSameNameOriginRef: false } + } catch { + // Ref deleted or repo state changed — fall through to a full re-resolve. + resolvedUpstreamNameCache.delete(cacheKey) + } + } + const result = await probeEffectiveUpstreamStatus(worktreePath, branchName, options) + if (result.status.hasUpstream && result.status.upstreamName) { + resolvedUpstreamNameCache.set(cacheKey, { + upstreamName: result.status.upstreamName, + expiresAt: Date.now() + RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS + }) + while (resolvedUpstreamNameCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) { + const oldest = resolvedUpstreamNameCache.keys().next() + if (oldest.done) { + break + } + resolvedUpstreamNameCache.delete(oldest.value) + } + } + return result +} + async function probeEffectiveUpstreamStatus( worktreePath: string, branchName: string, diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index d46f461b963..6c64e4b8066 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: create-PR tests share gh/SSH mocks across - template, CLI, and error-path cases; keeping them together prevents drift. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { readFile } from 'node:fs/promises' diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 2b4a039b36a..5312343224a 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -1,7 +1,3 @@ -/* eslint-disable max-lines -- Why: the issue-source test suite covers the -heuristic split (#1076), the partial-failure envelope (feature 1), and the -three-state preference matrix (feature 2) as one surface so a regression in -any of them blocks the same merge gate. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GhUtils from './gh-utils' @@ -47,7 +43,8 @@ vi.mock('./gh-utils', async () => { vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })) })) import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client' diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index e6ce6005c69..66e728908df 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: work-items coverage stays in one file so -the fan-out mock plumbing (issue + PR gh calls, allSettled handling) does -not drift across split files. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -66,7 +63,8 @@ vi.mock('../git/runner', () => ({ vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })) })) import { @@ -75,6 +73,7 @@ import { _resetMergeQueueCacheForTests, _resetOwnerRepoCache } from './client' +import { _resetGhCwdRepoNegativeCache } from './gh-cwd-repo-negative-cache' import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' describe('listWorkItems', () => { @@ -102,6 +101,25 @@ describe('listWorkItems', () => { getOwnerRepoForRemoteMock.mockResolvedValue(null) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + _resetGhCwdRepoNegativeCache() + }) + + it('stops re-spawning gh for a repo whose cwd resolution already failed with no remotes', async () => { + getIssueOwnerRepoMock.mockResolvedValue(null) + getOwnerRepoMock.mockResolvedValue(null) + ghExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('Command failed: gh pr list\nno git remotes found'), { + stderr: 'no git remotes found' + }) + ) + + await expect(listWorkItems('/no-remote-repo', 36)).rejects.toThrow('no git remotes found') + // The first refresh pays the two cwd-fallback spawns (issue + pr list). + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + + await expect(listWorkItems('/no-remote-repo', 36)).rejects.toThrow('no git remotes found') + // The second refresh is served from the negative cache — zero new spawns. + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('runs both issue and PR GitHub searches for a mixed query and merges the results by recency', async () => { @@ -540,6 +558,22 @@ describe('listWorkItems', () => { expect(apiPath).not.toContain('-is:merged') }) + it('returns zero without spawning gh when the search bucket is rate-limit blocked', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + rateLimitGuardMock.mockReturnValue({ + blocked: true, + remaining: 0, + limit: 30, + resetAt: Math.floor(Date.now() / 1000) + 60 + } as unknown as { blocked: boolean }) + + await expect(countWorkItems('/repo-root', 'is:issue is:open')).resolves.toBe(0) + + expect(rateLimitGuardMock).toHaveBeenCalledWith('search') + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + it('returns zero for oversized count queries before resolving repo sources', async () => { const secret = 'main-github-work-items-secret' const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index eb794325774..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() }) @@ -801,7 +801,7 @@ describe('getPRForBranch', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, - ['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100'], + ['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100&page=1'], expect.anything() ) expect(pr).toMatchObject({ @@ -929,6 +929,209 @@ describe('getPRForBranch', () => { }) }) + function mockMergedLinkedPRLookup(prNumber = 7447) { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: prNumber, + title: 'Merged linked PR', + state: 'MERGED', + url: `https://github.com/acme/widgets/pull/${prNumber}`, + statusCheckRollup: [], + updatedAt: '2026-07-03T21:27:36Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'old-linked-branch', + baseRefOid: 'base-oid', + headRefOid: 'aaaa1111aaaa1111' + }) + }) + } + + it('stamps confirmedContainedHeadOid for a linked merged PR when HEAD is its commit', async () => { + mockMergedLinkedPRLookup() + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([{ number: 7447 }]) + }) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ + kind: 'found', + pr: { + number: 7447, + state: 'merged', + confirmedContainedHeadOid: 'bbbb2222bbbb2222' + } + }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + }) + + it('stamps headDivergedFromMergedPRAtOid for a linked merged PR with a definite miss', async () => { + mockMergedLinkedPRLookup() + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([{ number: 42 }]) + }) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ + kind: 'found', + pr: { + number: 7447, + state: 'merged', + headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' + } + }) + }) + + it('stamps linked merged divergence when a later membership page proves absence', async () => { + mockMergedLinkedPRLookup() + // Page 1 is full and omits the linked PR (truncated), but page 2 is short and + // still omits it — that pair definitively proves the head is not contained. + const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index })) + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 2000 }]) }) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ + kind: 'found', + pr: { number: 7447, state: 'merged', headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' } + }) + }) + + it('leaves linked merged divergence unset when membership pages stay full to the cap', async () => { + mockMergedLinkedPRLookup() + // Every page up to the cap is full and omits the linked PR, so absence can + // never be proven — the probe must stay unknown rather than clear the link. + const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index })) + for (let page = 0; page < 5; page += 1) { + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) }) + } + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + }) + + it('stamps linked merged divergence via the PR url when no repo candidates resolve', async () => { + // Fallback path: no resolved candidates, so `gh pr view` returns the PR with + // dataRepo=null. The membership probe must still run against the repo derived + // from the PR's own URL so a diverged merged linked PR can clear. + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7447, + title: 'Merged linked PR', + state: 'MERGED', + url: 'https://github.com/acme/widgets/pull/7447', + statusCheckRollup: [], + updatedAt: '2026-07-03T21:27:36Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'old-linked-branch', + baseRefOid: 'base-oid', + headRefOid: 'aaaa1111aaaa1111' + }) + }) + .mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 2000 }]) }) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ + kind: 'found', + pr: { number: 7447, state: 'merged', headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' } + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100&page=1'], + expect.anything() + ) + }) + + it('leaves linked merged divergence unset when the membership probe is rate-limited', async () => { + mockMergedLinkedPRLookup() + rateLimitGuardMock.mockImplementation((bucket?: string) => + bucket === 'core' + ? { blocked: true, remaining: 0, limit: 5000, resetAt: 0 } + : { blocked: false } + ) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('leaves linked merged divergence unset when the membership probe throws', async () => { + mockMergedLinkedPRLookup() + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: No commit found')) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + }) + + it('leaves linked merged divergence unset when the membership probe returns a non-array payload', async () => { + mockMergedLinkedPRLookup() + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ message: 'Server Error' }) + }) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + }) + + it('leaves linked merged divergence unset without a current head oid', async () => { + mockMergedLinkedPRLookup() + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null) + + expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + it('prefers branch lookup over a fallback PR number', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock @@ -2784,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' }) @@ -2796,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' }) @@ -3364,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 0c86d547e3f..3a308a3adfb 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -62,7 +62,10 @@ import { type LocalGitExecOptions, type OwnerRepo } from './gh-utils' -import { isCommitPartOfMergedPR } from './merged-pr-commit-membership' +import { + isCommitPartOfMergedPR, + type MergedPRCommitMembership +} from './merged-pr-commit-membership' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { hasHostedReviewLocalGitOptions, @@ -70,6 +73,12 @@ import { type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { readLocalGitConfigSignature } from './local-git-config-signature' +import { + getRememberedGhCwdResolutionFailure, + isGhCwdRepoResolutionFailure, + rememberGhCwdResolutionFailure +} from './gh-cwd-repo-negative-cache' +import type { GitHubRepoContext } from './github-repository-identity' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, @@ -1002,6 +1011,32 @@ async function resolvePrWorkItemSource( return { source, originCandidate, upstreamCandidate } } +/** + * gh exec for calls that rely on gh's own cwd→repo resolution (no explicit + * owner/repo). Serves a remembered deterministic resolution failure without + * spawning, so a remote-less repo costs one gh spawn per config change/TTL + * instead of two per Tasks refresh. + */ +async function ghCwdResolvedExec( + context: GitHubRepoContext, + args: string[], + ghOptions: GhExecOptions +): Promise<{ stdout: string; stderr: string }> { + const remembered = await getRememberedGhCwdResolutionFailure(context) + if (remembered !== null) { + throw new Error(remembered) + } + try { + return await ghExecFileAsync(args, ghOptions) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (isGhCwdRepoResolutionFailure(message)) { + await rememberGhCwdResolutionFailure(context, message) + } + throw err + } +} + async function listRecentWorkItems( repoPath: string, issueOwnerRepo: OwnerRepo | null, @@ -1011,7 +1046,8 @@ async function listRecentWorkItems( noCache?: boolean, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions) + const ghOptions = ghRepoExecOptions(repoContext) const requiresExplicitRepo = Boolean(connectionId) const restCacheArgs = noCache ? [] : ['--cache', '120s'] assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) @@ -1031,7 +1067,8 @@ async function listRecentWorkItems( ) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) - : ghExecFileAsync( + : ghCwdResolvedExec( + repoContext, [ 'issue', 'list', @@ -1055,7 +1092,8 @@ async function listRecentWorkItems( ) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) - : ghExecFileAsync( + : ghCwdResolvedExec( + repoContext, [ 'pr', 'list', @@ -1127,7 +1165,8 @@ async function listRecentWorkItems( // effectively unusable for the feature — reject-all matches reality. If // non-GitHub remotes ever grow source metadata, revisit this symmetry. const [issuesResult, prsResult] = await Promise.all([ - ghExecFileAsync( + ghCwdResolvedExec( + repoContext, [ 'issue', 'list', @@ -1140,7 +1179,8 @@ async function listRecentWorkItems( ], ghOptions ), - ghExecFileAsync( + ghCwdResolvedExec( + repoContext, [ 'pr', 'list', @@ -1177,7 +1217,8 @@ async function listQueriedWorkItems( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions) + const ghOptions = ghRepoExecOptions(repoContext) const requiresExplicitRepo = Boolean(connectionId) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) const hasPrOnlyFilter = @@ -1206,7 +1247,9 @@ async function listQueriedWorkItems( before }) try { - const { stdout } = await ghExecFileAsync(args, ghOptions) + const { stdout } = issueOwnerRepo + ? await ghExecFileAsync(args, ghOptions) + : await ghCwdResolvedExec(repoContext, args, ghOptions) return { items: (JSON.parse(stdout) as Record[]).map(mapIssueWorkItem) } @@ -1231,7 +1274,9 @@ async function listQueriedWorkItems( before }) try { - const { stdout } = await ghExecFileAsync(args, ghOptions) + const { stdout } = prOwnerRepo + ? await ghExecFileAsync(args, ghOptions) + : await ghCwdResolvedExec(repoContext, args, ghOptions) const mapped = (JSON.parse(stdout) as Record[]).map((item) => mapPullRequestWorkItem(item, prOwnerRepo) ) @@ -1395,6 +1440,9 @@ async function countWorkItemsForQuery( ], ghOptions ) + // Why: over-counts gh cache hits, which is the safe direction — the search + // bucket is only 30/min and the next probe corrects the estimate. + noteRateLimitSpend('search') return Number.parseInt(stdout.trim(), 10) || 0 } @@ -1450,6 +1498,15 @@ export async function countWorkItems( const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery() + // Why: counts are decorative (pagination totals). The search bucket is only + // 30/min, so a multi-repo Tasks page must stop counting when the budget is + // gone instead of converting the remaining repos into 403 spawns. getRateLimit + // is 30s-cached and single-flight, so priming here is one spawn per window. + await getRateLimit() + if (rateLimitGuard('search').blocked) { + return 0 + } + await acquire() try { if (sameOwnerRepo(issueOwnerRepo, prOwnerRepo)) { @@ -2771,6 +2828,15 @@ export async function getPRForBranch( return outcome.kind === 'found' ? outcome.pr : null } +// Why: the exact-linked fallback (`gh pr view` with no resolved repo candidates) +// returns dataRepo=null, which would leave the merged-PR membership probe unable +// to run. Derive the PR's own repo from its web URL so a diverged merged linked +// PR can still be confirmed and cleared. Host-agnostic to cover GitHub Enterprise. +function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null { + const match = url.match(/^https?:\/\/[^/\s]+\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/) + return match ? { owner: match[1], repo: match[2] } : null +} + export async function getPRForBranchOutcome( repoPath: string, branch: string, @@ -2810,24 +2876,49 @@ export async function getPRForBranchOutcome( ? options.currentHeadOid.trim() : null let confirmedContainedHeadOid: string | null = null + let headDivergedFromMergedPRAtOid: string | null = null const mergedPRContainsHead = async ( candidate: PullRequestLookupData, candidateRepo: OwnerRepo | null, headOid: string | null - ): Promise => { + ): Promise => { if (!candidateRepo || !headOid) { - return false + return 'unknown' } - const contained = await isCommitPartOfMergedPR({ + const membership = await isCommitPartOfMergedPR({ ownerRepo: candidateRepo, prNumber: candidate.number, commitOid: headOid, ghOptions }) - if (contained) { + if (membership === 'contained') { confirmedContainedHeadOid = headOid } - return contained + return membership + } + const recordLinkedMergedPRDivergence = async ( + candidate: PullRequestLookupData | null, + candidateRepo: OwnerRepo | null + ): Promise => { + if ( + typeof linkedPRNumber !== 'number' || + !candidate || + mapPRState(candidate.state, candidate.isDraft) !== 'merged' || + explicitCurrentHeadOid === null || + candidate.headRefOid === explicitCurrentHeadOid + ) { + return + } + const membership = await mergedPRContainsHead( + candidate, + candidateRepo ?? ownerRepoFromPullRequestUrl(candidate.url), + explicitCurrentHeadOid + ) + if (membership === 'not-contained') { + // explicitCurrentHeadOid is non-null here (guarded above); record the + // exact head so consumers only clear the worktree that actually diverged. + headDivergedFromMergedPRAtOid = explicitCurrentHeadOid + } } const hideMergedImplicitPR = async ( candidate: PullRequestLookupData | null, @@ -2850,11 +2941,10 @@ export async function getPRForBranchOutcome( // merges, web-committed suggestions). A head that is one of the PR's own // commits is the same line of work, not a reused branch name — keep the // merged PR visible instead of offering "create a pull request". - return !(await mergedPRContainsHead( - candidate, - candidateRepo, - currentHeadOidForMergedImplicit - )) + return ( + (await mergedPRContainsHead(candidate, candidateRepo, currentHeadOidForMergedImplicit)) !== + 'contained' + ) } if (typeof linkedPRNumber === 'number') { @@ -2942,6 +3032,7 @@ export async function getPRForBranchOutcome( } return { kind: 'no-pr', fetchedAt: Date.now() } } + await recordLinkedMergedPRDivergence(data, dataRepo) const fallbackConfirmedMergedBranch = typeof fallbackPRNumber === 'number' && mergedBranchLookupNumber === fallbackPRNumber && @@ -2949,7 +3040,7 @@ export async function getPRForBranchOutcome( const explicitHeadHidesMergedImplicitPR = explicitCurrentHeadOid !== null && shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid) && - !(await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) + (await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) !== 'contained' // Why no lazy-HEAD re-check on preservation: fallback numbers come from // callers that already gated them on head equality or confirmed // containment; re-hiding against the main-repo HEAD would blank @@ -3003,6 +3094,7 @@ export async function getPRForBranchOutcome( ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), headSha: data.headRefOid, ...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}), + ...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}), ...(data.baseRefName ? { baseRefName: data.baseRefName } : {}), prRepo: dataRepo ?? undefined, headRepo: dataHeadRepo ?? undefined, 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/gh-cwd-repo-negative-cache.test.ts b/src/main/github/gh-cwd-repo-negative-cache.test.ts new file mode 100644 index 00000000000..240711143f1 --- /dev/null +++ b/src/main/github/gh-cwd-repo-negative-cache.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { readLocalGitConfigSignatureMock } = vi.hoisted(() => ({ + readLocalGitConfigSignatureMock: vi.fn() +})) + +vi.mock('./local-git-config-signature', () => ({ + readLocalGitConfigSignature: readLocalGitConfigSignatureMock +})) + +import { + _resetGhCwdRepoNegativeCache, + getRememberedGhCwdResolutionFailure, + isGhCwdRepoResolutionFailure, + rememberGhCwdResolutionFailure +} from './gh-cwd-repo-negative-cache' + +const context = { repoPath: '/repo', connectionId: null } + +afterEach(() => { + _resetGhCwdRepoNegativeCache() + readLocalGitConfigSignatureMock.mockReset() +}) + +describe('isGhCwdRepoResolutionFailure', () => { + it('matches gh cwd-resolution failures only', () => { + expect(isGhCwdRepoResolutionFailure('Command failed: gh\nno git remotes found')).toBe(true) + expect( + isGhCwdRepoResolutionFailure( + 'none of the git remotes configured for this repository point to a known GitHub host' + ) + ).toBe(true) + expect(isGhCwdRepoResolutionFailure('gh: API rate limit exceeded (HTTP 403)')).toBe(false) + expect(isGhCwdRepoResolutionFailure('gh: Not Found (HTTP 404)')).toBe(false) + }) +}) + +describe('remembered failures', () => { + it('serves the remembered failure while the config signature is unchanged', async () => { + readLocalGitConfigSignatureMock.mockResolvedValue('sig-1') + await rememberGhCwdResolutionFailure(context, 'no git remotes found') + await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBe('no git remotes found') + }) + + it('invalidates when the git config signature changes (remote added)', async () => { + readLocalGitConfigSignatureMock.mockResolvedValueOnce('sig-1') + await rememberGhCwdResolutionFailure(context, 'no git remotes found') + readLocalGitConfigSignatureMock.mockResolvedValue('sig-2') + await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBeNull() + // The stale entry is gone, not just skipped. + readLocalGitConfigSignatureMock.mockResolvedValue('sig-1') + await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBeNull() + }) + + it('separates repos and runtimes by cache key', async () => { + readLocalGitConfigSignatureMock.mockResolvedValue('sig') + await rememberGhCwdResolutionFailure(context, 'no git remotes found') + await expect( + getRememberedGhCwdResolutionFailure({ repoPath: '/other-repo', connectionId: null }) + ).resolves.toBeNull() + await expect( + getRememberedGhCwdResolutionFailure({ repoPath: '/repo', connectionId: 'ssh-1' }) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/github/gh-cwd-repo-negative-cache.ts b/src/main/github/gh-cwd-repo-negative-cache.ts new file mode 100644 index 00000000000..97a810f5e22 --- /dev/null +++ b/src/main/github/gh-cwd-repo-negative-cache.ts @@ -0,0 +1,83 @@ +import { readLocalGitConfigSignature } from './local-git-config-signature' +import type { GitHubRepoContext } from './github-repository-identity' + +/** + * Negative cache for repos where gh's cwd-based repo resolution fails + * ("no git remotes found" etc). Orca only falls back to cwd resolution when + * it could not resolve an owner/repo itself, so for a remote-less repo the + * failure is deterministic — yet the Tasks page re-spawned gh for every such + * repo on every refresh (×90 repos in the reported storm). Remember the + * failure and re-throw it without a subprocess until the repo's git config + * changes (signature check) or the TTL lapses. + */ +const NEGATIVE_CACHE_TTL_MS = 5 * 60_000 +const MAX_ENTRIES = 512 + +type NegativeCacheEntry = { + message: string + configSignature: string | undefined + expiresAt: number +} + +const entries = new Map() + +function negativeCacheKey(context: GitHubRepoContext): string { + const runtimeKey = context.connectionId ?? `local:${context.wslDistro ?? 'host'}` + return `${runtimeKey}\0${context.repoPath}` +} + +export function isGhCwdRepoResolutionFailure(text: string): boolean { + const s = text.toLowerCase() + return ( + s.includes('no git remotes found') || + s.includes('not a git repository') || + s.includes('unable to determine base repository') || + s.includes('none of the git remotes configured') + ) +} + +export async function getRememberedGhCwdResolutionFailure( + context: GitHubRepoContext +): Promise { + const key = negativeCacheKey(context) + const entry = entries.get(key) + if (!entry) { + return null + } + if (entry.expiresAt <= Date.now()) { + entries.delete(key) + return null + } + // Why: adding a remote is the way out of this failure; the config signature + // (mtime/size of the repo's git config chain) changes when that happens. + const signature = await readLocalGitConfigSignature(context) + if (signature !== entry.configSignature) { + entries.delete(key) + return null + } + return entry.message +} + +export async function rememberGhCwdResolutionFailure( + context: GitHubRepoContext, + message: string +): Promise { + const configSignature = await readLocalGitConfigSignature(context) + entries.set(negativeCacheKey(context), { + message, + configSignature, + expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS + }) + while (entries.size > MAX_ENTRIES) { + const oldest = entries.keys().next() + if (oldest.done) { + break + } + entries.delete(oldest.value) + } +} + +/** @internal — test-only */ +export function _resetGhCwdRepoNegativeCache(): void { + entries.clear() +} diff --git a/src/main/github/gh-error-classification.ts b/src/main/github/gh-error-classification.ts index b7064ef0f6e..2de37919d25 100644 --- a/src/main/github/gh-error-classification.ts +++ b/src/main/github/gh-error-classification.ts @@ -4,6 +4,14 @@ import type { ClassifiedError } from '../../shared/types' // patterns to typed errors so callers can show user-friendly messages. export function classifyGhError(stderr: string): ClassifiedError { const s = stderr.toLowerCase() + // Why: primary rate-limit errors also carry "HTTP 403" — check rate limit + // first so they don't misclassify as a token-scope problem. + if (s.includes('rate limit')) { + return { + type: 'rate_limited', + message: 'GitHub rate limit hit. Try again in a few minutes.' + } + } if (s.includes('http 403') || s.includes('resource not accessible')) { return { type: 'permission_denied', @@ -19,12 +27,6 @@ export function classifyGhError(stderr: string): ClassifiedError { if (s.includes('http 422') || s.includes('validation failed')) { return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` } } - if (s.includes('rate limit')) { - return { - type: 'rate_limited', - message: 'GitHub rate limit hit. Try again in a few minutes.' - } - } if ( s.includes('timeout') || s.includes('no such host') || diff --git a/src/main/github/merged-pr-commit-membership.ts b/src/main/github/merged-pr-commit-membership.ts index a09f43ef01e..0bb390f6495 100644 --- a/src/main/github/merged-pr-commit-membership.ts +++ b/src/main/github/merged-pr-commit-membership.ts @@ -11,8 +11,15 @@ type GhExecOptions = Parameters[1] const MEMBERSHIP_CACHE_MAX_ENTRIES = 200 const MEMBERSHIP_DEFINITIVE_TTL_MS = 6 * 60 * 60 * 1000 const MEMBERSHIP_ERROR_TTL_MS = 5 * 60 * 1000 +const COMMIT_PULLS_PAGE_SIZE = 100 +// Why: a worktree HEAD is associated with ~1 PR, so page 1 is short in practice +// and this cap is never reached; it only bounds the pathological case of a commit +// linked to hundreds of PRs, where staying 'unknown' is the safe answer. +const COMMIT_PULLS_MAX_PAGES = 5 -const membershipCache = new Map() +export type MergedPRCommitMembership = 'contained' | 'not-contained' | 'unknown' + +const membershipCache = new Map() function pruneMergedPRCommitMembershipCache(now = Date.now()): void { for (const [cacheKey, cached] of membershipCache) { @@ -38,17 +45,17 @@ export function resetMergedPRCommitMembershipCacheForTest(): void { * commit belongs to that PR's history rather than merely sharing a branch name. * A worktree sitting on such a commit is on the PR's own line of work (for * example behind web-committed suggestions or an update-branch merge), not a - * reused branch name. Conservative on any failure: returns false. + * reused branch name. Conservative on any failure: returns unknown. */ export async function isCommitPartOfMergedPR(args: { ownerRepo: OwnerRepo prNumber: number commitOid: string ghOptions: GhExecOptions -}): Promise { +}): Promise { const oid = args.commitOid.trim().toLowerCase() if (!/^[0-9a-f]{4,64}$/.test(oid) || !Number.isInteger(args.prNumber)) { - return false + return 'unknown' } const owner = args.ownerRepo.owner const repo = args.ownerRepo.repo @@ -59,38 +66,78 @@ export async function isCommitPartOfMergedPR(args: { if (cached && cached.expiresAt > now) { return cached.value } - // Why blocked → uncached false: keep the merged PR hidden without burning - // budget; the next poll re-asks once the rate-limit window recovers. + // Why blocked stays unknown: hiding a transient branch match is safe, but + // callers must not clear a durable linked PR when the probe never ran. if (rateLimitGuard('core').blocked) { - return false + return 'unknown' } try { - noteRateLimitSpend('core') - const { stdout } = await ghExecFileAsync( - ['api', `repos/${owner}/${repo}/commits/${oid}/pulls?per_page=100`], - args.ghOptions - ) - const parsed = JSON.parse(stdout) as unknown - const value = - Array.isArray(parsed) && - parsed.some( + // Why paginate: a full page that omits the target PR may just be truncated (a + // commit can belong to many PRs). Reading only page 1 and calling it + // 'not-contained' would wrongly clear a durable link; calling it 'unknown' + // would never clear one that genuinely diverged. Walk pages until the PR is + // found (contained) or a short page proves absence (not-contained); only the + // pathological all-full case up to the cap stays 'unknown'. + for (let page = 1; page <= COMMIT_PULLS_MAX_PAGES; page += 1) { + if (page > 1 && rateLimitGuard('core').blocked) { + membershipCache.set(cacheKey, { + value: 'unknown', + expiresAt: now + MEMBERSHIP_ERROR_TTL_MS + }) + return 'unknown' + } + noteRateLimitSpend('core') + const { stdout } = await ghExecFileAsync( + [ + 'api', + `repos/${owner}/${repo}/commits/${oid}/pulls?per_page=${COMMIT_PULLS_PAGE_SIZE}&page=${page}` + ], + args.ghOptions + ) + const parsed = JSON.parse(stdout) as unknown + // Why: a non-array success payload is a shape mismatch, not an empty page; + // caching it as definitive not-contained could wrongly clear a durable link. + if (!Array.isArray(parsed)) { + membershipCache.set(cacheKey, { + value: 'unknown', + expiresAt: now + MEMBERSHIP_ERROR_TTL_MS + }) + return 'unknown' + } + const entries = parsed + const contained = entries.some( (entry) => typeof entry === 'object' && entry !== null && (entry as { number?: unknown }).number === args.prNumber ) + if (contained) { + membershipCache.set(cacheKey, { + value: 'contained', + expiresAt: now + MEMBERSHIP_DEFINITIVE_TTL_MS + }) + return 'contained' + } + if (entries.length < COMMIT_PULLS_PAGE_SIZE) { + membershipCache.set(cacheKey, { + value: 'not-contained', + expiresAt: now + MEMBERSHIP_DEFINITIVE_TTL_MS + }) + return 'not-contained' + } + } membershipCache.set(cacheKey, { - value, - expiresAt: now + MEMBERSHIP_DEFINITIVE_TTL_MS - }) - return value - } catch { - // Why: the common failure is 422 for a commit never pushed to GitHub — - // definitive "new local work" today, but a push can change it; retry later. - membershipCache.set(cacheKey, { - value: false, + value: 'unknown', expiresAt: now + MEMBERSHIP_ERROR_TTL_MS }) - return false + return 'unknown' + } catch { + // Why: 422 often means "new local work" today, but a later push can make + // the answer knowable; preserve durable links until a probe succeeds. + membershipCache.set(cacheKey, { + value: 'unknown', + expiresAt: now + MEMBERSHIP_ERROR_TTL_MS + }) + return 'unknown' } } diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 766a734f26b..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, @@ -116,6 +123,25 @@ describe('pr-refresh-coordinator', () => { ) }) + it('copies the candidate worktree head onto broadcast aliases', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ state: 'merged' }), + fetchedAt: Date.now() + }) + + reportVisiblePRRefreshCandidates([makeCandidate({ currentHeadOid: 'worktree-head-oid' })], 1, 1) + await vi.runOnlyPendingTimersAsync() + + // Why: the renderer clear of a diverged merged linked PR is head-scoped, so + // the broadcast alias must carry the request-time head it was probed against. + const outcomeEvent = sendMock.mock.calls + .map(([, event]) => event) + .find((event) => event.outcome) + expect(outcomeEvent?.aliases[0]?.currentHeadOid).toBe('worktree-head-oid') + }) + it('does not show visible background refreshes as queued', async () => { const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ @@ -486,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', @@ -633,6 +691,103 @@ describe('pr-refresh-coordinator', () => { expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) }) + it('probes with the survivor head after the representative alias is invalidated', async () => { + const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'found', + pr: makePR({ state: 'merged' }), + fetchedAt: Date.now() + }) + + const survivor = makeCandidate({ + cacheKey: '/repo::feature/b', + branch: 'feature/b', + linkedPRNumber: 12, + worktreeId: 'wt-b', + currentHeadOid: 'head-b' + }) + const representative = makeCandidate({ + cacheKey: '/repo::feature/a', + branch: 'feature/a', + linkedPRNumber: 12, + worktreeId: 'wt-a', + currentHeadOid: 'head-a' + }) + + // Enqueue the survivor first, then the representative (active coalescing + // promotes the latter to representative), then invalidate the representative + // so the still-queued entry rebinds to the survivor before draining. + enqueuePRRefresh(survivor, 'active', 80, 1) + enqueuePRRefresh(representative, 'active', 80, 1) + enqueuePRRefresh({ ...representative, isArchived: true }, 'active', 80, 1) + await vi.runOnlyPendingTimersAsync() + + const probedHeads = getPRForBranchOutcomeMock.mock.calls.map((call) => call[5]?.currentHeadOid) + expect(probedHeads).toContain('head-b') + expect(probedHeads).not.toContain('head-a') + }) + + it('probes with the survivor head after the representative worktree is pruned', async () => { + const { enqueuePRRefresh, pruneWorktreePRRefreshAliases } = + await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'found', + pr: makePR({ state: 'merged' }), + fetchedAt: Date.now() + }) + + const survivor = makeCandidate({ + cacheKey: '/repo::feature/b', + branch: 'feature/b', + linkedPRNumber: 12, + worktreeId: 'wt-b', + currentHeadOid: 'head-b' + }) + const representative = makeCandidate({ + cacheKey: '/repo::feature/a', + branch: 'feature/a', + linkedPRNumber: 12, + worktreeId: 'wt-a', + currentHeadOid: 'head-a' + }) + + enqueuePRRefresh(survivor, 'active', 80, 1) + enqueuePRRefresh(representative, 'active', 80, 1) + pruneWorktreePRRefreshAliases('wt-a') + await vi.runOnlyPendingTimersAsync() + + const probedHeads = getPRForBranchOutcomeMock.mock.calls.map((call) => call[5]?.currentHeadOid) + expect(probedHeads).toContain('head-b') + expect(probedHeads).not.toContain('head-a') + }) + + it('refreshes the representative head when the same worktree re-reports a moved head', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'found', + pr: makePR({ state: 'merged' }), + fetchedAt: Date.now() + }) + + // Same worktree/branch, moved head, coalescing visible→visible (no promote): + // the representative head must track the newest report before the drain. + reportVisiblePRRefreshCandidates( + [makeCandidate({ linkedPRNumber: 12, worktreeId: 'wt-a', currentHeadOid: 'head-a' })], + 1, + 1 + ) + reportVisiblePRRefreshCandidates( + [makeCandidate({ linkedPRNumber: 12, worktreeId: 'wt-a', currentHeadOid: 'head-b' })], + 2, + 1 + ) + await vi.runOnlyPendingTimersAsync() + + const probedHeads = getPRForBranchOutcomeMock.mock.calls.map((call) => call[5]?.currentHeadOid) + expect(probedHeads).toContain('head-b') + expect(probedHeads).not.toContain('head-a') + }) + it('includes request start time on manual refresh events', async () => { const { refreshPRNow } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 5bb70a970cb..8065fbb8145 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -188,7 +188,11 @@ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { ...entry.candidate, cacheKey: replacementAlias.cacheKey, branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId + worktreeId: replacementAlias.worktreeId, + // Why: the probe now represents the replacement worktree, so it must + // use that worktree's head — otherwise divergence is stamped for the + // removed worktree's head and the survivor's link is never cleared. + currentHeadOid: replacementAlias.currentHeadOid ?? null } } } @@ -321,6 +325,7 @@ function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefres branch: candidate.branch, worktreeId: candidate.worktreeId, connectionId: candidate.connectionId ?? null, + currentHeadOid: candidate.currentHeadOid ?? null, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, @@ -393,6 +398,10 @@ function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefres cacheKey: replacementAlias.cacheKey, branch: replacementAlias.branch, worktreeId: replacementAlias.worktreeId, + // Why: the probe now represents the replacement worktree, so it must use + // that worktree's head — otherwise divergence is stamped for the pruned + // candidate's head and the survivor's link is never cleared. + currentHeadOid: replacementAlias.currentHeadOid ?? null, isArchived: false, isBare: false } @@ -699,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)) @@ -809,6 +809,18 @@ export function enqueuePRRefresh( existing.activeDelayNotified = false existing.candidate = candidate existing.windowId = windowId ?? existing.windowId + } else if (existing.candidate.worktreeId === candidate.worktreeId) { + // Why: a non-promoting coalesce (e.g. visible→visible) keeps the existing + // representative, but the representative drives the probe head. If its own + // worktree moved head/branch, refresh those probe inputs so divergence is + // stamped for the current head — otherwise the head-scoped clear never + // matches and a merged linked PR lingers after a branch switch. + existing.candidate = { + ...existing.candidate, + cacheKey: candidate.cacheKey, + branch: candidate.branch, + currentHeadOid: candidate.currentHeadOid ?? null + } } } else { diagnosticsCounters.enqueued += 1 diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index 52509c30652..586bbaa6e95 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -1,7 +1,3 @@ -/* eslint-disable max-lines -- Why: shared infrastructure for project-view — -slug validation, error classification, runGraphql/runRest, and rate-limit -synthesis. Co-located so the read and write paths observe identical -classification semantics. */ // Why: `ghExecFileAsync` (WSL-aware, retry-enabled) is the single spawn site // for gh calls. The legacy plain `execFileAsync` is NOT used here — routing // every gh call through the runner gives us transient-5xx retry, WSL path 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 1108b6bd219..f4a1ba56248 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -20,6 +20,13 @@ import type { } from '../../shared/types' import { acquire, release } from './gh-utils' import { ghExecFileAsync } from '../git/runner' +import { + clearGhRateLimitBlock, + getGhRateLimitBlockedUntilMs, + recordGhPrimaryRateLimit, + registerGhRateLimitResetProbe, + type GhRateLimitBucket +} from '../git/gh-rate-limit-breaker' // Why: GitHub explicitly states `GET /rate_limit` does NOT count against // any bucket, so the only reason to cache is to avoid spawning a `gh` @@ -28,6 +35,11 @@ import { ghExecFileAsync } from '../git/runner' // 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?: { @@ -59,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 @@ -93,6 +106,18 @@ export function rateLimitGuard(bucket: RateLimitBucketKind): limit: number resetAt: number } { + // Why: the runner-level breaker learns about exhaustion from actual 403s, + // which can happen long before (or without) a snapshot probe — e.g. quota + // burned by another tool on the same account. + const breakerBlockedUntilMs = getGhRateLimitBlockedUntilMs(bucket) + if (breakerBlockedUntilMs !== null) { + return { + blocked: true, + remaining: 0, + limit: cached?.[bucket].limit ?? 0, + resetAt: Math.ceil(breakerBlockedUntilMs / 1000) + } + } if (!cached) { return { blocked: false } } @@ -103,6 +128,11 @@ export function rateLimitGuard(bucket: RateLimitBucketKind): : bucket === 'graphql' ? MIN_REMAINING_GRAPHQL : MIN_REMAINING_SEARCH + // Why: a snapshot from before the bucket's reset time describes a window + // that has already ended — fail open rather than blocking on stale data. + if (b.resetAt * 1000 <= Date.now()) { + return { blocked: false } + } // Why: only block when we have a positive limit (limit:0 means "unknown" per // parseBucket fallback — don't block on missing data, that would brick the // app on a single bad rate_limit response). @@ -130,10 +160,66 @@ export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void } } +// Why: when the runner's breaker trips it only knows "blocked", not for how +// long. `gh api rate_limit` is exempt from limits, so one forced probe turns +// the fallback block into the bucket's real reset time (or clears a block +// that a stale fallback would otherwise keep alive). Single-flight: a 90-repo +// 403 burst must refine once, not 90 times. +let resetRefinementInFlight: Promise | null = null + +function refineBreakerFromSnapshot(): void { + if (resetRefinementInFlight) { + return + } + resetRefinementInFlight = (async () => { + try { + const result = await getRateLimit({ force: true }) + if (!result.ok) { + return + } + for (const bucket of ['core', 'search', 'graphql'] as GhRateLimitBucket[]) { + const b = result.snapshot[bucket] + if (b.limit > 0 && b.remaining <= 0) { + recordGhPrimaryRateLimit(bucket, b.resetAt * 1000) + } else if (b.limit > 0) { + clearGhRateLimitBlock(bucket) + } + } + } finally { + resetRefinementInFlight = null + } + })() +} + +registerGhRateLimitResetProbe(() => refineBreakerFromSnapshot()) + +// Why: a 90-repo fan-out that primes the guard concurrently must resolve to +// one `gh api rate_limit` spawn, not one per repo — the TTL cache alone can't +// dedupe calls that all start before the first probe lands. +let probeInFlight: Promise | null = null + export async function getRateLimit(options?: { force?: boolean }): Promise { if (!options?.force && cached && Date.now() - cached.fetchedAt < RATE_LIMIT_CACHE_TTL_MS) { return { ok: true, snapshot: cached } } + if (!options?.force && probeFailure && Date.now() - probeFailure.at < RATE_LIMIT_CACHE_TTL_MS) { + return { ok: false, error: probeFailure.error } + } + if (!options?.force && probeInFlight) { + return probeInFlight + } + const probe = fetchRateLimitSnapshot() + probeInFlight = probe + try { + return await probe + } finally { + if (probeInFlight === probe) { + probeInFlight = null + } + } +} + +async function fetchRateLimitSnapshot(): Promise { await acquire() try { const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { encoding: 'utf-8' }) @@ -145,9 +231,11 @@ export async function getRateLimit(options?: { force?: boolean }): Promise void) | null = null +let unsubscribeSystemResumeBroadcast: (() => void) | null = null let watcherShutdownPromise: Promise | null = null let watcherShutdownDone = false let automations: AutomationService | null = null @@ -431,6 +438,9 @@ if (startupDiagnosticsEnabled) { }) startEventLoopStallProbe() } +// Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; unlike the startup probe it +// runs for the whole session to catch steady-state churn (issue #7576). +startMainThreadChurnProbe() function focusExistingWindow(): void { focusExistingMainWindow({ @@ -573,6 +583,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() @@ -585,6 +596,7 @@ if (hasSingleInstanceLock) { platform: process.platform }) configureElectronNetworkCompatibility() + enableRendererHeapHeadroom() maybeApplyGpuFallbackForThisLaunch() if (!gpuFallbackActiveThisLaunch) { enableMainProcessGpuFeatures() @@ -635,7 +647,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 @@ -978,6 +999,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, @@ -1618,7 +1648,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 @@ -1646,7 +1677,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 @@ -2164,6 +2199,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/browser.test.ts b/src/main/ipc/browser.test.ts index 98fab06f4d8..042140cd38b 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: browser IPC tests share one mocked trust-boundary handler registry plus registration waiters; splitting would duplicate setup and weaken coverage. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index 1c2059bb762..04432899abb 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: crash-reporting IPC tests share mocked Electron/observability handler setup; splitting would duplicate brittle IPC wiring. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CrashReportRecord } from '../../shared/crash-reporting' diff --git a/src/main/ipc/diagnostics.test.ts b/src/main/ipc/diagnostics.test.ts index b30d013f6ea..2868fdec19d 100644 --- a/src/main/ipc/diagnostics.test.ts +++ b/src/main/ipc/diagnostics.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: diagnostics IPC tests share mocked Electron handler setup; splitting would duplicate brittle IPC wiring. */ import { readFileSync, writeFileSync } from 'node:fs' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CollectedBundle } from '../observability/bundle' diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index 979d6b5611f..c6fa78b126d 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -8,8 +8,11 @@ import type * as RepoWorktrees from '../repo-worktrees' import { listRepoWorktrees } from '../repo-worktrees' import type { FolderWorkspace, GitWorktreeInfo, ProjectGroup, Repo } from '../../shared/types' import { + AUTHORIZED_EXTERNAL_PATHS_MAX, + authorizeExternalPath, invalidateAuthorizedRootsCache, isDescendantOrEqual, + isPathAllowed, rebuildAuthorizedRootsCache, resolveAuthorizedPath, resolveRegisteredWorktreePath, @@ -336,3 +339,42 @@ describe('filesystem-auth path containment', () => { } }) }) + +describe('filesystem-auth authorized external path bound', () => { + // Empty allow-list store, so a path is allowed only if it (or an ancestor) is + // in the session-authorized external-path set. + const emptyStore = makeStore([]) + const flood = (n: number): string => + resolve(`/leak-audit-ext/flood-${String(n).padStart(6, '0')}`) + + it('bounds the authorized external path set with LRU eviction', () => { + const keep = resolve('/leak-audit-ext/keep') + authorizeExternalPath(keep) + + // Flood past the cap with distinct external paths, re-authorizing `keep` + // periodically so LRU keeps it hot. + const total = AUTHORIZED_EXTERNAL_PATHS_MAX + 200 + for (let i = 0; i < total; i += 1) { + authorizeExternalPath(flood(i)) + if (i % 250 === 0) { + authorizeExternalPath(keep) + } + } + + // The oldest never-re-touched entries fell out of the bounded set... + expect(isPathAllowed(flood(0), emptyStore)).toBe(false) + // ...while the periodically re-authorized path and the most recent survive. + expect(isPathAllowed(keep, emptyStore)).toBe(true) + expect(isPathAllowed(flood(total - 1), emptyStore)).toBe(true) + }) + + it('re-authorizes an evicted path on next use (self-healing)', () => { + const path = resolve('/leak-audit-ext/evicted-then-reused') + for (let i = 0; i < AUTHORIZED_EXTERNAL_PATHS_MAX + 50; i += 1) { + authorizeExternalPath(flood(100_000 + i)) + } + expect(isPathAllowed(path, emptyStore)).toBe(false) + authorizeExternalPath(path) + expect(isPathAllowed(path, emptyStore)).toBe(true) + }) +}) diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index f931ebe119b..7fee4ca1990 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -13,6 +13,14 @@ import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types' export const PATH_ACCESS_DENIED_MESSAGE = 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' +// Why: authorized external paths accumulate for the whole session (file drops, +// terminal link opens, editor/composer/notebook opens). Bound the set with LRU +// eviction — mirrors rememberUnwatchableRoot in filesystem-watcher.ts — so a long +// session touching many distinct external files cannot grow it (or the O(n) auth +// scan in isPathAllowed) without limit. Every caller re-authorizes a path right +// before operating on it, so evicting a stale entry is self-healing and does not +// weaken the security boundary. +export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096 const authorizedExternalPaths = new Set() const registeredWorktreeRoots = new Set() const registeredWorktreeRootsByRepo = new Map>() @@ -23,12 +31,26 @@ const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8 type FolderScopeStore = Pick & Partial> +function rememberAuthorizedExternalPath(path: string): void { + // Delete-then-add keeps re-authorized (actively used) paths most-recent so + // eviction only sheds the oldest never-re-touched entries. + authorizedExternalPaths.delete(path) + authorizedExternalPaths.add(path) + while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) { + const oldest = authorizedExternalPaths.keys().next().value + if (oldest === undefined) { + break + } + authorizedExternalPaths.delete(oldest) + } +} + export function authorizeExternalPath(targetPath: string): void { const resolvedTarget = resolve(targetPath) - authorizedExternalPaths.add(resolvedTarget) + rememberAuthorizedExternalPath(resolvedTarget) try { // Why: macOS canonicalizes /tmp to /private/tmp during read authorization. - authorizedExternalPaths.add(realpathSync(resolvedTarget)) + rememberAuthorizedExternalPath(realpathSync(resolvedTarget)) } catch {} } diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index 01b28e1c39a..fcd004dcd06 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: import tests cover local copy, SSH routing, -symlink safety, and runtime-upload staging against one shared IPC fixture. */ import path from 'node:path' import { constants } from 'node:fs' import { Readable, Writable } from 'node:stream' diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 9a04660ec80..638775a4811 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: one Quick Open file-list suite covers both rg and git fallback process lifecycles. */ import { describe, expect, it, vi, beforeEach } from 'vitest' const { diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index b1af20fd139..0b3b07ca071 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: filesystem watcher tests share module-level -state across local, WSL, and SSH lifecycle paths; keeping them together makes -closeAllWatchers cleanup regressions visible in one suite. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ 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..bdaba67efae --- /dev/null +++ b/src/main/ipc/notification-authorization-status.ts @@ -0,0 +1,91 @@ +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 + 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..cb5caf2eaec --- /dev/null +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -0,0 +1,204 @@ +// 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) + const subscription = await promise + + 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) + const subscription = await promise + + 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('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..25e52794a8d --- /dev/null +++ b/src/main/ipc/parcel-watcher-process.ts @@ -0,0 +1,320 @@ +// 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), []) + } + 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 + }) + } +} + +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() + } + 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.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-management.test.ts b/src/main/ipc/pty-management.test.ts index c644d885d27..443b356ba4a 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -1,7 +1,3 @@ -/* eslint-disable max-lines -- Why: covers every pty:management IPC channel -against shared mocks (electron, fs, daemon-init, DaemonPtyRouter). Splitting -across files would duplicate the vi.hoisted setup and the shared helpers, -with no meaningful ownership seam. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DaemonSessionInfo } from '../daemon/types' diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index bab351ce291..230f519c5d4 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 @@ -7245,58 +7607,6 @@ describe('registerPtyHandlers', () => { } }) - it('bounds pending renderer delivery for a PTY that never ACKs and flags the trim', async () => { - vi.useFakeTimers() - const mockProc = createMockProc() - spawnMock.mockReturnValue(mockProc.proc) - - try { - registerPtyHandlers(mainWindow as never) - const spawnResult = (await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - cwd: '/tmp' - })) as { id: string } - const ackData = getPtyAckDataListener() - mainWindow.webContents.send.mockClear() - - // A hidden/never-mounted pane never ACKs (STA-1397): 2MB of output must - // trim to the delivery bound instead of accumulating forever. - mockProc.emitData('x'.repeat(2 * 1024 * 1024)) - vi.advanceTimersByTime(8) - for (let index = 0; index < 31; index++) { - vi.advanceTimersByTime(1) - } - - expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ - pendingPtyCount: 1, - pendingChars: 640 * 1024 - 512 * 1024, - peakMaxPendingCharsByPty: 640 * 1024, - rendererInFlightChars: 512 * 1024 - }) - // The first chunk after the trim tells the renderer to rebuild from a - // buffer snapshot rather than paint the spliced stream. - expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(1, 'pty:data', { - id: spawnResult.id, - data: 'x'.repeat(16 * 1024), - dropped: true - }) - expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(2, 'pty:data', { - id: spawnResult.id, - data: 'x'.repeat(16 * 1024) - }) - - ackData(null, { id: spawnResult.id, charCount: 16 * 1024 }) - vi.advanceTimersByTime(1) - expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { - id: spawnResult.id, - data: 'x'.repeat(16 * 1024) - }) - } finally { - vi.useRealTimers() - } - }) - it('releases pending delivery and in-flight budget when provider state is cleared', async () => { vi.useFakeTimers() const mockProc = createMockProc() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index e73ee8b92f9..d8ec261ee8a 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 @@ -1374,9 +1377,10 @@ export function registerPtyHandlers( data: string startSeq?: number containsBackgroundOutput?: boolean - /** Output before this data was trimmed by the delivery bound; the next - * flushed payload must tell the renderer to rebuild from a snapshot. */ - droppedOutput?: 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 = { @@ -1385,7 +1389,7 @@ export function registerPtyHandlers( seq?: number rawLength?: number background?: boolean - dropped?: boolean + droppedBacklog?: boolean } const pendingData = new Map() @@ -1399,18 +1403,19 @@ 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. const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 - // Why: without a bound, a pane that never ACKs (hidden worktree, window that - // never mounted a terminal, wedged renderer) accumulates its PTY's entire - // output here for the process lifetime — the STA-1397 OOM driver. The cap - // keeps only the newest tail; the renderer rebuilds the hole from a main - // buffer snapshot via the payload's `dropped` flag. Keep-chars sits above the - // per-PTY in-flight high water so a healthy ACKing pane never trims. - const PTY_PENDING_DATA_MAX_CHARS = 1024 * 1024 - const PTY_PENDING_DATA_KEEP_CHARS = 640 * 1024 // Why: keep the immediate path bounded to keystroke-sized TUI redraws; // large output and non-interactive output must still use the batcher. const INTERACTIVE_OUTPUT_WINDOW_MS = 100 @@ -1457,13 +1462,24 @@ export function registerPtyHandlers( } function recordPtyRendererDeliveryPressure(): void { - const current = readCurrentPtyRendererDeliveryDebugSnapshot() - peakPendingChars = Math.max(peakPendingChars, current.pendingChars) - peakMaxPendingCharsByPty = Math.max(peakMaxPendingCharsByPty, current.maxPendingCharsByPty) - peakRendererInFlightChars = Math.max(peakRendererInFlightChars, current.rendererInFlightChars) + // Why: this fires on every PTY delivery event (per send, per flush, per + // onData append). Update the four diagnostic peaks directly instead of + // allocating a full 13-field debug snapshot object per call — that object + // is only needed when the debug getter is actually read. Peak values are + // computed identically to readCurrentPtyRendererDeliveryDebugSnapshot. + let pendingChars = 0 + let maxPendingCharsByPty = 0 + for (const pending of pendingData.values()) { + const chars = pending.data.length + pendingChars += chars + maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) + } + peakPendingChars = Math.max(peakPendingChars, pendingChars) + peakMaxPendingCharsByPty = Math.max(peakMaxPendingCharsByPty, maxPendingCharsByPty) + peakRendererInFlightChars = Math.max(peakRendererInFlightChars, rendererInFlightTotalChars) peakMaxRendererInFlightCharsByPty = Math.max( peakMaxRendererInFlightCharsByPty, - current.maxRendererInFlightCharsByPty + getMaxMapValue(rendererInFlightCharsByPty.values()) ) } @@ -1489,7 +1505,7 @@ export function registerPtyHandlers( remaining.data, remaining.startSeq, remaining.containsBackgroundOutput, - remaining.droppedOutput + remaining.droppedBacklog ) ) } @@ -1540,7 +1556,7 @@ export function registerPtyHandlers( data: string, startSeq: number | undefined, containsBackgroundOutput: boolean | undefined, - droppedOutput?: boolean + droppedBacklog?: boolean ): PtyDataPayload { const payload: PtyDataPayload = { id, data } if (typeof startSeq === 'number') { @@ -1550,8 +1566,8 @@ export function registerPtyHandlers( if (containsBackgroundOutput === true) { payload.background = true } - if (droppedOutput === true) { - payload.dropped = true + if (droppedBacklog === true) { + payload.droppedBacklog = true } return payload } @@ -1624,6 +1640,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, @@ -1633,48 +1671,29 @@ export function registerPtyHandlers( ): PendingPtyData { const nextContainsBackgroundOutput = existing?.containsBackgroundOutput === true || containsBackgroundOutput - const nextDroppedOutput = existing?.droppedOutput === true + // 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 } : {}), - ...(nextDroppedOutput ? { droppedOutput: 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 } : {}), - ...(nextDroppedOutput ? { droppedOutput: true } : {}) + if (inheritedDropped) { + base.droppedBacklog = true } - if (typeof existing.startSeq === 'number') { - next.startSeq = existing.startSeq - } - return next - } - - function capPendingPtyData(pending: PendingPtyData): PendingPtyData { - if (pending.data.length <= PTY_PENDING_DATA_MAX_CHARS) { - return pending - } - const dropCount = pending.data.length - PTY_PENDING_DATA_KEEP_CHARS - const next: PendingPtyData = { - data: pending.data.slice(dropCount), - droppedOutput: true, - ...(pending.containsBackgroundOutput === true ? { containsBackgroundOutput: true } : {}) - } - if (typeof pending.startSeq === 'number') { - // Why: seq must stay truthful for the kept tail so the renderer's - // snapshot-alignment math (getChunkDataAfterSnapshot) keeps working. - next.startSeq = pending.startSeq + dropCount - } - return next + return capPendingPtyData(base) } function schedulePendingDataFlush(delayMs: number): void { @@ -1717,6 +1736,9 @@ 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( @@ -1724,7 +1746,7 @@ export function registerPtyHandlers( chunk, pending.startSeq, pending.containsBackgroundOutput, - pending.droppedOutput + pending.droppedBacklog ) ) writes++ @@ -1790,7 +1812,7 @@ export function registerPtyHandlers( remaining.data, remaining.startSeq, remaining.containsBackgroundOutput, - remaining.droppedOutput + remaining.droppedBacklog ) ) pendingData.delete(payload.id) @@ -1914,7 +1936,7 @@ export function registerPtyHandlers( ? { seq: pending.startSeq + nextData.length, rawLength: nextData.length } : {}), ...(pending.containsBackgroundOutput === true ? { background: true } : {}), - ...(pending.droppedOutput === true ? { dropped: true } : {}) + ...(pending.droppedBacklog === true ? { droppedBacklog: true } : {}) }) return } @@ -2071,21 +2093,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. @@ -2096,7 +2130,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()) { @@ -2382,7 +2416,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) @@ -2634,6 +2681,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 @@ -2662,6 +2710,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 @@ -2705,7 +2757,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) @@ -3261,7 +3332,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) @@ -3353,7 +3438,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 5ce6342c351..ad836b8e178 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -1,8 +1,9 @@ -/* eslint-disable max-lines -- Why: this test mirrors the complete core IPC handler registry so - duplicate-registration coverage stays tied to the one production entry point. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { + getPathMock, + listEnvironmentsMock, + callRuntimeEnvironmentMock, registerCliHandlersMock, registerPreflightHandlersMock, registerClaudeUsageHandlersMock, @@ -30,6 +31,7 @@ const { registerRuntimeEnvironmentHandlersMock, registerEphemeralVmHandlersMock, registerAiVaultHandlersMock, + registerOrcaProfileHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, registerAgentTrustHandlersMock, @@ -59,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(), @@ -86,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(), @@ -116,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 })) @@ -265,6 +285,10 @@ vi.mock('./ai-vault', () => ({ registerAiVaultHandlers: registerAiVaultHandlersMock })) +vi.mock('./orca-profiles', () => ({ + registerOrcaProfileHandlers: registerOrcaProfileHandlersMock +})) + vi.mock('./codex-accounts', () => ({ registerCodexAccountHandlers: registerCodexAccountHandlersMock })) @@ -328,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() @@ -355,6 +384,7 @@ describe('registerCoreHandlers', () => { registerRuntimeEnvironmentHandlersMock.mockReset() registerEphemeralVmHandlersMock.mockReset() registerAiVaultHandlersMock.mockReset() + registerOrcaProfileHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() registerAgentTrustHandlersMock.mockReset() @@ -384,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' } @@ -417,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) @@ -445,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() @@ -453,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() @@ -468,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/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts index 6de465626e9..1c97ad6b724 100644 --- a/src/main/ipc/remote-workspace-patch-queue.test.ts +++ b/src/main/ipc/remote-workspace-patch-queue.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: queue/cache regression cases share one mocked IPC harness so stale revision sequencing stays visible. */ import { ipcMain } from 'electron' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' 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 24ac41e2deb..2e1b842904d 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: shell IPC path validation, OS opener fallbacks, and launcher lifecycle tests share one mocked Electron/child_process boundary. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { normalize, resolve } from 'node:path' import { pathToFileURL } from 'node:url' 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/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 27c7d0cd15d..40679160f93 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: a single test file pins the IPC boundary behavior for all four telemetry handlers plus the cohort-injection invariants; splitting would fragment the threat-model coverage. */ // IPC boundary behavior for the telemetry surface. Strict type narrows must // drop obviously-malformed calls before they reach the validator (the // renderer is in the threat model). Pins the consent-mutation rate limit: 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.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 6fe5320b1b1..af084713a0b 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: scan and IPC process-liveness tests share - hoisted Electron/git provider mocks; splitting would duplicate brittle setup. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ipcMain } from 'electron' import type { Store } from '../persistence' diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 7ba2350c855..219235ac913 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: these worktree path/name tests share a -single setup-free pure-logic module, and splitting them would make the related -edge cases harder to audit together. */ import { posix, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { 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 c7ff8f45c82..d3ed835646a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -103,6 +103,7 @@ type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { } import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' +import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy' import { assertWorktreeDoesNotContainRegisteredWorktree, canCleanupUnregisteredOrcaLeftoverDirectory, @@ -161,6 +162,9 @@ function removeWorktreeMetadataAndTransientState(store: Store, worktreeId: strin // drop process-local caches before the same ID can point at a new workspace. store.removeWorktreeMeta(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) + // Why: drop this worktree's localhost label routes so they don't accumulate + // in the proxy's route maps for the rest of the session. + localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) // Why: release the removed worktree's PR-refresh aliases so coalesced queue // entries do not retain it for the rest of the session (memory creep). @@ -927,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') @@ -1803,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/localhost-worktree-label-proxy.test.ts b/src/main/localhost-worktree-label-proxy.test.ts index 49a5e866ec1..7d499209847 100644 --- a/src/main/localhost-worktree-label-proxy.test.ts +++ b/src/main/localhost-worktree-label-proxy.test.ts @@ -124,4 +124,33 @@ describe('localhost worktree label proxy', () => { expect(result.status).toBe(404) }) + + it('drops a worktree label routes on unregisterWorktree, leaving others intact', async () => { + const port = await startUpstream((_request, response) => { + response.writeHead(200, { 'content-type': 'text/plain' }) + response.end('ok') + }) + const proxy = new LocalhostWorktreeLabelProxy() + const a = await proxy.registerRoute({ + targetUrl: `http://localhost:${port}/`, + projectName: 'Snap Studio', + worktreeName: 'feature-a', + worktreeId: 'wt-a' + }) + const b = await proxy.registerRoute({ + targetUrl: `http://localhost:${port}/`, + projectName: 'Snap Studio', + worktreeName: 'feature-b', + worktreeId: 'wt-b' + }) + + expect((await fetchThroughProxy(a.url)).status).toBe(200) + expect((await fetchThroughProxy(b.url)).status).toBe(200) + + proxy.unregisterWorktree('wt-a') + + // The removed worktree's label no longer routes; the other worktree is unaffected. + expect((await fetchThroughProxy(a.url)).status).toBe(404) + expect((await fetchThroughProxy(b.url)).status).toBe(200) + }) }) diff --git a/src/main/localhost-worktree-label-proxy.ts b/src/main/localhost-worktree-label-proxy.ts index b2f8152707b..acc1164d1ee 100644 --- a/src/main/localhost-worktree-label-proxy.ts +++ b/src/main/localhost-worktree-label-proxy.ts @@ -49,6 +49,18 @@ export class LocalhostWorktreeLabelProxy { } } + // Why: routes are added per (worktreeId, targetUrl) but were never removed, so + // labels for deleted worktrees accumulated in both maps for the whole session. + // Drop them when the worktree is torn down. The shared http.Server stays up. + unregisterWorktree(worktreeId: string): void { + for (const [label, route] of this.routes) { + if (route.worktreeId === worktreeId) { + this.routes.delete(label) + this.routeKeys.delete(route.routeKey) + } + } + } + private async ensureServer(): Promise { if (this.server && this.listenPort !== null) { return 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 539eebc70c0..347e1025893 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: diagnostics bundle fixtures cover collection, preview deletion, upload URL hardening, and byte caps as one contract surface. Splitting would duplicate the temp-file/server harness and make edge-case coverage harder to audit. */ // Bundle collection + upload tests. Upload helpers live outside bundle.ts, but // this suite keeps the diagnostic bundle contract in one place. @@ -141,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/observability/redactor.test.ts b/src/main/observability/redactor.test.ts index 4ad470bc7a2..8de2661515a 100644 --- a/src/main/observability/redactor.test.ts +++ b/src/main/observability/redactor.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: the redactor has intentionally broad fixture coverage across every secret location and key-shape rule; keeping it together makes gaps visible. */ // Fixture-based test suite for the redactor. Each provider-key shape is // exercised in three locations (attribute value, span event message, // exit-status `cause`) — that's the contract telemetry-error-tracking.md diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index 6efec7beb5d..c4bd46aae68 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: OpenCode scanner tests cover multiple DB schema generations and attribution boundaries together so parser regressions stay auditable. */ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 9d71a25d891..2ad00cf5baa 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -1,8 +1,3 @@ -/* eslint-disable max-lines -- Why: this suite covers four orthogonal regimes - (plugin source, id guards, legacy per-PTY round-trip, and overlay mode for - user-set OPENCODE_CONFIG_DIR). Splitting them across files would scatter - tightly coupled fixtures (userData mock, hooks/overlay roots) and obscure - the docs/opencode-config-dir-collision.md regression matrix. */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, 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..137696e17ce --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-dev-auth.ts @@ -0,0 +1,67 @@ +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..9db1d830986 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service-auth-retry.test.ts @@ -0,0 +1,247 @@ +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..075573eea3a --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service-refresh.test.ts @@ -0,0 +1,254 @@ +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..552a2ee8ea1 --- /dev/null +++ b/src/main/orca-profiles/profile-cloud-service.test.ts @@ -0,0 +1,285 @@ +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..22f20157d8f 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, @@ -2988,6 +3013,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 +3093,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 +5034,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 +6432,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..67fdd0c8215 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 ( @@ -2181,6 +2189,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 +2576,8 @@ function backfillFolderScopeConnectionIds(state: PersistedState): { function deleteRemovedTerminalScrollbackSnapshots( prior: WorkspaceSessionState | undefined, - next: WorkspaceSessionState + next: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage ): void { if (!prior) { return @@ -2572,16 +2585,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 +2624,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 +2842,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 @@ -3339,6 +3371,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), @@ -3397,7 +3434,10 @@ export class Store { 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 +3493,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 +3641,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 +3652,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 +3697,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 +3709,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 +4213,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 +4307,7 @@ export class Store { | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' + | 'executionHostId' | 'symlinkPaths' | 'issueSourcePreference' | 'forkSyncMode' @@ -5463,7 +5564,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 +5738,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 +6077,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 +6448,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/project-groups/nested-repo-discovery.test.ts b/src/main/project-groups/nested-repo-discovery.test.ts index 0d016cf1cbc..f38e29ea53b 100644 --- a/src/main/project-groups/nested-repo-discovery.test.ts +++ b/src/main/project-groups/nested-repo-discovery.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: nested scan behavior is intentionally covered in one -suite so traversal order, ignore rules, cancellation, and filesystem fixtures stay aligned. */ import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' 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-stream.test.ts b/src/main/providers/ssh-filesystem-provider-stream.test.ts index 29daf5af532..b66b5633503 100644 --- a/src/main/providers/ssh-filesystem-provider-stream.test.ts +++ b/src/main/providers/ssh-filesystem-provider-stream.test.ts @@ -80,7 +80,8 @@ describe('SshFilesystemProvider readFile streaming', () => { const result = await provider.readFile('/home/user/file.txt') expect(mux.request).toHaveBeenCalledWith('fs.readFileStream', { - filePath: '/home/user/file.txt' + filePath: '/home/user/file.txt', + flowControl: 'ack' }) expect(result).toEqual({ content: text, isBinary: false }) }) diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index f008c380dde..d35460bd489 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: SSH filesystem provider coverage keeps relay fallback, -SFTP binary writes, watch fan-out, and provider lifecycle tests together so -transport parity regressions are visible in one suite. */ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshFilesystemProvider } from './ssh-filesystem-provider' import { JsonRpcErrorCode } from '../ssh/relay-protocol' @@ -420,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']) }) @@ -429,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/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/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index f2cf2587d57..3d36716dd5e 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: integration tests cover the full mobile subscribe lifecycle across many scenarios; splitting would scatter related assertions. */ /** * Integration tests for the server-authoritative mobile subscribe lifecycle. * Tests handleMobileSubscribe, handleMobileUnsubscribe, applyMobileDisplayMode, diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index b3edc6cdbfd..7c55f75327b 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: browser runtime command tests share one -mocked BrowserManager/agent-browser bridge so page wake and registration -ordering regressions stay in one boundary-focused suite. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' 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 48892c2d2a2..7cf71bea957 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 @@ -856,6 +863,8 @@ function antigravityPromptBeforeModelReadyScreen(model = 'Gemini 3.5 Flash (High class InMemoryOrchestrationMessages { private sequence = 0 + private activeCoordinatorRun: { coordinator_handle: string } | null = null + private messages: MessageRow[] = [] insertMessage(msg: { @@ -903,6 +912,14 @@ class InMemoryOrchestrationMessages { return this.getUnreadMessages(toHandle, types).filter((message) => !message.delivered_at) } + setActiveCoordinatorRun(run: { coordinator_handle: string } | null): void { + this.activeCoordinatorRun = run + } + + getActiveCoordinatorRun(): { coordinator_handle: string } | null { + return this.activeCoordinatorRun + } + markAsDelivered(ids: string[]): void { const deliveredIds = new Set(ids) for (const message of this.messages) { @@ -5240,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 = { @@ -9288,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) @@ -10751,6 +11272,122 @@ 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() + }) + + 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) @@ -10880,6 +11517,7 @@ describe('OrcaRuntimeService', () => { const [terminal] = (await runtime.listTerminals()).terminals runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + db.setActiveCoordinatorRun({ coordinator_handle: 'term_other' }) db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello' }) runtime.deliverPendingMessagesForHandle(terminal.handle) @@ -10905,6 +11543,52 @@ describe('OrcaRuntimeService', () => { } }) + it('injects pending orchestration messages into the active coordinator without auto-submitting', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + db.setActiveCoordinatorRun({ coordinator_handle: terminal.handle }) + db.insertMessage({ + from: 'term_sender', + to: terminal.handle, + subject: 'hello coordinator' + }) + + runtime.deliverPendingMessagesForHandle(terminal.handle) + + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('Subject: hello coordinator') + ) + await vi.advanceTimersByTimeAsync(500) + const submitWrites = write.mock.calls.filter( + ([ptyId, text]) => ptyId === 'pty-1' && text === '\r' + ) + expect(submitWrites).toHaveLength(0) + + const unread = db.getUnreadMessages(terminal.handle) + expect(unread).toHaveLength(1) + expect(unread[0].read).toBe(0) + expect(unread[0].delivered_at).not.toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + it('injects pending orchestration messages into Cursor Agent without auto-submitting', async () => { vi.useFakeTimers() try { @@ -14088,6 +14772,90 @@ 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] + } 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 is a + // deliberate non-goal — no renderer, no wake). + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + 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() + }) + + 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. + await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'runtime' + }) + + expect(resumeSleepingAgents).not.toHaveBeenCalled() + }) + 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. @@ -15985,6 +16753,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' @@ -17900,6 +18819,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: [] }) @@ -25922,4 +27248,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 2e87e28e681..9681ea33e14 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,11 @@ import type { WorkspaceSessionState, DirEntry } from '../../shared/types' +import { 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 +184,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 +218,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 +1185,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 +1293,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 +1426,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 +2044,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 +2817,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 +4007,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 +4034,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 +5284,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 +5342,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 @@ -5679,6 +5777,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } @@ -5713,6 +5812,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + pendingEscapeTailAnsi?: string } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, @@ -5888,18 +5988,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 +6097,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 +6215,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 +6247,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 +6283,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 +7394,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 +7441,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 +7582,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 +7778,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 +8186,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 +8201,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 +8252,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 +8271,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 +8937,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 +9323,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 +9967,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 +10006,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 +10407,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 +10424,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 +10456,7 @@ export class OrcaRuntimeService { path, displayName: getRepoName(path), badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(executionHostId != null ? { executionHostId } : {}), ...detected, addedAt: Date.now(), kind, @@ -10289,7 +10595,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 +10627,8 @@ export class OrcaRuntimeService { trimmedUrl, trimmedDestination, clonePath, - clonePathKey + clonePathKey, + executionHostId ) } finally { release() @@ -10331,14 +10642,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 +10714,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 +10739,7 @@ export class OrcaRuntimeService { path: clonePath, displayName: getRepoName(clonePath), badgeColor: DEFAULT_REPO_BADGE_COLOR, + ...(executionHostId != null ? { executionHostId } : {}), ...detected, addedAt: Date.now(), kind: 'git', @@ -12271,7 +12592,7 @@ export class OrcaRuntimeService { async activateManagedWorktree( worktreeSelector: string, - opts: { notifyClients?: boolean } = {} + opts: { notifyClients?: boolean; clientKind?: 'mobile' | 'runtime' } = {} ): Promise<{ repoId: string worktreeId: string @@ -12303,6 +12624,13 @@ 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, and only when a renderer is attached to receive it. + if (opts.clientKind === 'mobile' && this.getAvailableAuthoritativeWindow()) { + this.notifier?.resumeSleepingAgents?.(worktree.id) + } } return { repoId: repo.id, worktreeId: worktree.id, activated: true } } @@ -15721,7 +16049,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 +16242,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 +16340,7 @@ export class OrcaRuntimeService { launchAgent?: TuiAgent activate?: boolean clientMutationId?: string + signal?: AbortSignal } = {} ): Promise { const mutationId = opts.clientMutationId @@ -16053,12 +16382,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 +16422,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 +16442,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 +16470,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 +16529,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 +16652,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 +16768,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 +16840,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 +17599,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( @@ -19936,6 +20394,12 @@ export class OrcaRuntimeService { return } + // The active coordinator prompt is user-owned input, so push-on-idle must not synthesize Enter. + if (this._orchestrationDb.getActiveCoordinatorRun()?.coordinator_handle === handle) { + this._orchestrationDb.markAsDelivered(unread.map((m) => m.id)) + return + } + const tabTitle = this.tabs.get(leaf.tabId)?.title if (isCursorAgentOrchestrationTarget(leaf, tabTitle)) { // Why: Cursor Agent treats injected PTY text as editable prompt input. diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 5ec2b914ab8..8593fd81622 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: coordinator tests cover dispatch, DAG ordering, escalation, decision gates, concurrency, and stop — splitting by category would scatter shared setup without improving clarity. */ import { afterEach, describe, expect, it } from 'vitest' import { OrchestrationDb } from './db' import { reconcileLifecycleMessage } from './lifecycle-reconciliation' @@ -41,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() { @@ -276,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') } @@ -691,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/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 3f2c12ef279..8f43298a92c 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: DB tests cover messages, tasks, dispatch contexts, decision gates, coordinator runs, and lifecycle in one suite to share the createDb() helper and afterEach cleanup. */ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' 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/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 7aea0b7c78f..7282489c706 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: file RPC routing coverage stays together so -the dispatcher contract for read, write, mutation, and watch methods is easy to audit. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 850276906a5..bb69dfc5958 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: git RPC methods share one dispatcher fixture, and keeping the contract cases together makes method coverage easy to audit. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 8330a1d0ba5..2135507c3e9 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: runtime GitHub RPC methods share one dispatcher suite so repo-scoped and Project-scoped contract coverage cannot drift. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/gitlab.test.ts b/src/main/runtime/rpc/methods/gitlab.test.ts index cb88fbff7bb..ca429f3d492 100644 --- a/src/main/runtime/rpc/methods/gitlab.test.ts +++ b/src/main/runtime/rpc/methods/gitlab.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: one routing test exercises the full GitLab RPC surface so provider parity regressions show up in a single contract fixture. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' 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/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index dfd185b33b4..adad88dded0 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: terminal subscribe buffering tests share a live dispatcher harness; splitting would duplicate stream setup and weaken lifecycle coverage. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' 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..93871cad631 --- /dev/null +++ b/src/main/runtime/rpc/ws-fallback-port-store.ts @@ -0,0 +1,43 @@ +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. + +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 775050fe8b3..a44469d1fcc 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: these tests exercise one stateful transport - boundary across connection lifecycle, heartbeat, pre-auth timeout, and - shutdown behavior; splitting the setup would obscure the shared invariants. */ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -444,4 +441,55 @@ 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('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 53a776f3983..7b1d8b8ead0 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: the WebSocket transport owns connection - admission, heartbeat, pre-auth timeout, and client-id cleanup together; those - invariants are easier to audit in one transport boundary. */ // Why: the WebSocket transport enables mobile clients to connect to the Orca // runtime over the local network. When TLS cert/key are provided it uses wss:// // to prevent passive sniffing; otherwise it falls back to plain ws://. Per-device @@ -51,6 +48,11 @@ 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: when the preferred port is taken, an unstable OS-assigned port would + // change on every restart and permanently orphan existing mobile pairings + // (their stored ws://ip:port endpoint goes dead — STA-1511). Callers pass + // the previously assigned fallback port so it is retried first. + fallbackPort?: number } export class WebSocketTransport implements RpcTransport { @@ -61,6 +63,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 @@ -84,7 +87,8 @@ export class WebSocketTransport implements RpcTransport { tlsKey, heartbeatIntervalMs, preAuthTimeoutMs, - staticRoot + staticRoot, + fallbackPort }: WebSocketTransportOptions) { this.host = host this.port = port @@ -93,6 +97,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 { @@ -147,19 +152,37 @@ export class WebSocketTransport implements RpcTransport { // 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. + // advertise the correct port regardless. A persisted fallback port is + // retried before port 0 so paired devices keep a stable endpoint across + // restarts (STA-1511). let port = this.port try { await this.tryListen(port) + return } 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 - await this.tryListen(port) - } else { + if (!isEAddressInUse(error) || port === 0) { throw error } } + if ( + this.fallbackPort !== undefined && + this.fallbackPort !== 0 && + this.fallbackPort !== this.port + ) { + try { + console.warn( + `[ws-transport] Port ${port} is in use, retrying previous fallback port ${this.fallbackPort}` + ) + await this.tryListen(this.fallbackPort) + return + } catch (error: unknown) { + if (!isEAddressInUse(error)) { + throw error + } + } + } + console.warn(`[ws-transport] Port ${port} is in use, falling back to 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 59efa6abfa2..8e05aaeec02 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' @@ -153,6 +154,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.openDiff', 'files.read', 'files.readChunk', + 'files.readDir', 'files.readPreview', 'files.readTerminalArtifact', 'files.readTerminalArtifactPreview', @@ -307,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', @@ -699,7 +703,11 @@ 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). 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 @@ -782,6 +790,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/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index e50731c8588..c03447d4043 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: hosted review creation permutations share large mocks; splitting would hide branch-specific expectations. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { 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/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts index 8369c3c02cd..c4e9f089743 100644 --- a/src/main/ssh/relay-protocol.test.ts +++ b/src/main/ssh/relay-protocol.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { HEADER_LENGTH, MessageType, @@ -160,6 +160,74 @@ describe('FrameDecoder', () => { expect(frames).toHaveLength(1) expect(frames[0].id).toBe(2) }) + + it('decodes frames fed one byte at a time (worst-case boundary straddling)', () => { + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + + const frame1 = encodeFrame(MessageType.Regular, 1, 0, Buffer.from('first payload')) + const frame2 = encodeFrame(MessageType.Regular, 2, 1, Buffer.from('second')) + const combined = Buffer.concat([frame1, frame2]) + + for (let i = 0; i < combined.length; i += 1) { + decoder.feed(combined.subarray(i, i + 1)) + } + + expect(frames).toHaveLength(2) + expect(frames[0].payload.toString()).toBe('first payload') + expect(frames[1].id).toBe(2) + expect(frames[1].payload.toString()).toBe('second') + }) + + it('skips an oversized frame fed in odd-sized chunks and resynchronizes', () => { + const errors: Error[] = [] + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder( + (f) => frames.push(f), + (err) => errors.push(err) + ) + + const oversizedLength = 17 * 1024 * 1024 + const header = Buffer.alloc(HEADER_LENGTH) + header[0] = MessageType.Regular + header.writeUInt32BE(1, 1) + header.writeUInt32BE(0, 5) + header.writeUInt32BE(oversizedLength, 9) + const oversized = Buffer.concat([header, Buffer.alloc(oversizedLength)]) + const valid = encodeFrame(MessageType.Regular, 2, 0, Buffer.from('after')) + const combined = Buffer.concat([oversized, valid]) + + const chunkSize = 1024 * 1024 - 7 + for (let i = 0; i < combined.length; i += chunkSize) { + decoder.feed(combined.subarray(i, i + chunkSize)) + } + + expect(errors).toHaveLength(1) + expect(frames).toHaveLength(1) + expect(frames[0].payload.toString()).toBe('after') + }) + + it('never rebuilds the buffered stream per feed while assembling a large frame', () => { + // Regression: feed() used Buffer.concat([buffered, chunk]) per data event, + // re-copying the whole backlog for every TCP chunk — O(n²) memcpy on the + // Electron main thread while fs.streamChunk frames arrive (SSH typing lag). + const frames: DecodedFrame[] = [] + const decoder = new FrameDecoder((f) => frames.push(f)) + const frame = encodeFrame(MessageType.Regular, 1, 0, Buffer.alloc(512 * 1024, 0x61)) + + const concatSpy = vi.spyOn(Buffer, 'concat') + try { + for (let i = 0; i < frame.length; i += 32 * 1024) { + decoder.feed(frame.subarray(i, i + 32 * 1024)) + } + } finally { + concatSpy.mockRestore() + } + + expect(frames).toHaveLength(1) + expect(frames[0].payload.length).toBe(512 * 1024) + expect(concatSpy).not.toHaveBeenCalled() + }) }) describe('parseJsonRpcMessage', () => { diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index 3b8b63af159..db57c4d19aa 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -127,7 +127,13 @@ export type DecodedFrame = { * Incremental frame parser. Feed it chunks of data; it emits complete frames. */ export class FrameDecoder { - private buffer = Buffer.alloc(0) + // Why: feed() runs on the Electron main thread for every SSH channel data + // event. Rebuilding one contiguous buffer per feed (Buffer.concat) re-copies + // every already-buffered byte for each incoming ~32KB TCP chunk — O(n²) per + // large frame (a 340KB fs.streamChunk frame cost ~2MB of memcpy). A chunk + // list assembles each frame exactly once instead. + private chunks: Buffer[] = [] + private bufferedLength = 0 private onFrame: (frame: DecodedFrame) => void private onError: ((err: Error) => void) | null @@ -137,21 +143,31 @@ export class FrameDecoder { } feed(chunk: Buffer | Uint8Array): void { - this.buffer = Buffer.concat([this.buffer, chunk]) + const buf = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) + if (buf.length > 0) { + this.chunks.push(buf) + this.bufferedLength += buf.length + } - while (this.buffer.length >= HEADER_LENGTH) { - const length = this.buffer.readUInt32BE(9) + while (this.bufferedLength >= HEADER_LENGTH) { + const header = this.peekBytes(HEADER_LENGTH) + const length = header.readUInt32BE(9) const totalLength = HEADER_LENGTH + length + if (this.bufferedLength < totalLength) { + // Not fully received yet (also holds oversized frames until they can + // be skipped whole, keeping the decoder synchronized). + break + } + // Why: throwing here would leave the buffer in a partially consumed // state — subsequent feed() calls would try to parse leftover payload // bytes as a new header, corrupting every future frame. Instead we // skip the entire oversized frame so the decoder stays synchronized. if (length > MAX_MESSAGE_SIZE) { - if (this.buffer.length < totalLength) { - break - } - this.buffer = this.buffer.subarray(totalLength) + this.discardBytes(totalLength) const err = new Error(`Frame payload too large: ${length} bytes — discarded`) if (this.onError) { this.onError(err) @@ -159,24 +175,83 @@ export class FrameDecoder { continue } - if (this.buffer.length < totalLength) { - break - } - + const framed = this.takeBytes(totalLength) const frame: DecodedFrame = { - type: this.buffer[0], - id: this.buffer.readUInt32BE(1), - ack: this.buffer.readUInt32BE(5), - payload: this.buffer.subarray(HEADER_LENGTH, totalLength) + type: framed[0], + id: framed.readUInt32BE(1), + ack: framed.readUInt32BE(5), + payload: framed.subarray(HEADER_LENGTH, totalLength) } - - this.buffer = this.buffer.subarray(totalLength) this.onFrame(frame) } } reset(): void { - this.buffer = Buffer.alloc(0) + this.chunks = [] + this.bufferedLength = 0 + } + + /** View of the first `count` buffered bytes without consuming them. */ + private peekBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length >= count) { + return first + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + for (const part of this.chunks) { + copied += part.copy(out, copied, 0, Math.min(part.length, count - copied)) + if (copied >= count) { + break + } + } + return out + } + + /** Consume and return the first `count` buffered bytes (single copy). */ + private takeBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length === count) { + this.chunks.shift() + this.bufferedLength -= count + return first + } + if (first.length > count) { + this.chunks[0] = first.subarray(count) + this.bufferedLength -= count + return first.subarray(0, count) + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + while (copied < count) { + const part = this.chunks[0] + const take = Math.min(part.length, count - copied) + part.copy(out, copied, 0, take) + copied += take + if (take === part.length) { + this.chunks.shift() + } else { + this.chunks[0] = part.subarray(take) + } + } + this.bufferedLength -= count + return out + } + + /** Consume the first `count` buffered bytes without assembling them. */ + private discardBytes(count: number): void { + let remaining = count + while (remaining > 0) { + const part = this.chunks[0] + if (part.length <= remaining) { + this.chunks.shift() + remaining -= part.length + } else { + this.chunks[0] = part.subarray(remaining) + remaining = 0 + } + } + this.bufferedLength -= count } } diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index f0576a65fb5..10123d56a25 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -1,5 +1,3 @@ -/* oxlint-disable max-lines -- Why: keeps the mux protocol lifecycle harness - together across request, notification, keepalive, and disposal cases. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer' import { encodeFrame, MessageType, HEADER_LENGTH, encodeKeepAliveFrame } from './relay-protocol' @@ -309,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-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts index 5999c1c96d2..f550c833a0f 100644 --- a/src/main/ssh/ssh-config-parser.test.ts +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: SSH config parsing fixtures cover OpenSSH file parsing and ssh -G output together so import and connection resolution stay aligned. */ import { describe, expect, it, vi } from 'vitest' import { join } from 'node:path' import { parseSshConfig, sshConfigHostsToTargets, parseSshGOutput } from './ssh-config-parser' 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-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index ed10780ca3d..0c578cff7d2 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: SSH connection utility tests share mocked filesystem and environment setup across auth, proxy, and retry helpers. */ import { EventEmitter } from 'node:events' import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest' import { join } from 'node:path' diff --git a/src/main/ssh/ssh-filesystem-stream-reader.ts b/src/main/ssh/ssh-filesystem-stream-reader.ts index cda490c8580..2147ed0ef4f 100644 --- a/src/main/ssh/ssh-filesystem-stream-reader.ts +++ b/src/main/ssh/ssh-filesystem-stream-reader.ts @@ -148,6 +148,10 @@ export async function readFileViaStream( expectedSeq += 1 receivedChunks += 1 bytesReceived += decoded.length + // Why: credit-based flow control — the relay caps unacked chunks so bulk + // stream frames cannot queue unbounded ahead of interactive pty.data + // frames on the shared SSH channel. Old relays ignore this notification. + mux.notify('fs.streamAck', { streamId: id, seq }) } const handleEnd = (params: Record): void => { @@ -260,7 +264,9 @@ export async function readFileViaStream( unsubscribers.push(onDispose) void mux - .request('fs.readFileStream', { filePath }) + // Why: flowControl declares this client acks each chunk, letting a new + // relay pace the pump. Old relays ignore the extra param and flood. + .request('fs.readFileStream', { filePath, flowControl: 'ack' }) .then((rawMetadata) => { if (settled) { return 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-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 0cedf1aff05..2b63b3a2d50 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,8 +1,3 @@ -/* eslint-disable max-lines -- Why: pinning every layer that should have - caught the original "node-pty not available" bug (chained shell, package - ordering, probe shape, channel-failure surfacing, .bashrc-noise immunity, - platform-tagged logs) requires keeping these scenarios in one file so the - shared mock connection and exec-response fixture stay aligned. */ // Why: regression coverage for the install-probe contract. The original // "node-pty is not available" bug shipped because every layer that should // have caught it (chained shell, swallowing catch, dir-only probe) was diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 31b357ca047..3de35a65fb2 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: this integration spec keeps the SSH relay, -agent-hook server, and replay/interrupt ordering fixtures together so regressions -cover the full mux-to-main path. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index aa616dd1518..614c121d56a 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: relay session tests need one shared mocked -provider/multiplexer harness to cover establish, reconnect, detach, and dispose -state transitions without duplicating brittle setup. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { SshRelaySession } from './ssh-relay-session' import type { SshConnection } from './ssh-connection' @@ -121,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/startup/renderer-heap-headroom.test.ts b/src/main/startup/renderer-heap-headroom.test.ts new file mode 100644 index 00000000000..88b1baa0f2e --- /dev/null +++ b/src/main/startup/renderer-heap-headroom.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeRendererHeapCeilingMb } from './renderer-heap-headroom' + +vi.mock('electron', () => ({ + app: { + commandLine: { + appendSwitch: vi.fn(), + getSwitchValue: vi.fn(() => '') + } + } +})) + +afterEach(() => { + vi.restoreAllMocks() +}) + +const GIB = 1024 * 1024 * 1024 + +describe('computeRendererHeapCeilingMb', () => { + it('leaves Chromium default (null) below the ~8 GB gate to avoid OS memory pressure', () => { + expect(computeRendererHeapCeilingMb(4 * GIB)).toBeNull() + expect(computeRendererHeapCeilingMb(6 * GIB)).toBeNull() // 6 GB reports ~5.7 GiB + expect(computeRendererHeapCeilingMb(7 * GIB)).toBeNull() // below the 7.5 GiB gate + }) + + it('includes 8 GB machines that report below 8 GiB (Linux MemTotal excludes reserved RAM)', () => { + // A real 8 GB Linux box reports ~7.7 GiB; it must still get the headroom. + expect(computeRendererHeapCeilingMb(7.7 * GIB)).toBe(3072) + expect(computeRendererHeapCeilingMb(7.5 * GIB)).toBe(3072) + }) + + it('raises the ceiling toward the 4 GB pointer-compression cage, floored and capped', () => { + expect(computeRendererHeapCeilingMb(8 * GIB)).toBe(3072) // floor: 8 GB default ~2.2 GB -> 3072 + expect(computeRendererHeapCeilingMb(12 * GIB)).toBe(4096) // 0.4*12 -> 4096 (cage) + expect(computeRendererHeapCeilingMb(16 * GIB)).toBe(4096) // cage cap + expect(computeRendererHeapCeilingMb(128 * GIB)).toBe(4096) // cage cap, never higher + }) + + it('honors a positive ORCA_RENDERER_HEAP_MB override regardless of RAM', () => { + expect(computeRendererHeapCeilingMb(4 * GIB, '5000')).toBe(5000) + expect(computeRendererHeapCeilingMb(128 * GIB, '4096')).toBe(4096) + }) + + it('opts out (null) for default/off/none/0/negative overrides', () => { + for (const value of ['default', 'off', 'none', '0', '-1']) { + expect(computeRendererHeapCeilingMb(16 * GIB, value)).toBeNull() + } + }) + + it('opts out (null) for a fractional override that would floor to 0 (never emits max-old-space-size=0)', () => { + for (const value of ['0.5', '0.9', '0.0001']) { + expect(computeRendererHeapCeilingMb(16 * GIB, value)).toBeNull() + } + }) + + it('falls through to RAM tiers for blank/invalid overrides', () => { + expect(computeRendererHeapCeilingMb(16 * GIB, '')).toBe(4096) + expect(computeRendererHeapCeilingMb(16 * GIB, 'abc')).toBe(4096) + }) + + it('returns null for a non-finite / non-positive RAM reading', () => { + expect(computeRendererHeapCeilingMb(Number.NaN)).toBeNull() + expect(computeRendererHeapCeilingMb(0)).toBeNull() + }) +}) + +describe('enableRendererHeapHeadroom', () => { + it('appends --max-old-space-size as a js-flags switch on a RAM-capable machine', async () => { + const { app } = await import('electron') + const { enableRendererHeapHeadroom } = await import('./renderer-heap-headroom') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + vi.mocked(app.commandLine.getSwitchValue).mockReturnValue('') + + enableRendererHeapHeadroom({ totalMemoryBytes: 16 * GIB, env: {} }) + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'js-flags', + '--max-old-space-size=4096' + ) + }) + + it('does not set a switch on low-RAM machines', async () => { + const { app } = await import('electron') + const { enableRendererHeapHeadroom } = await import('./renderer-heap-headroom') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + vi.mocked(app.commandLine.getSwitchValue).mockReturnValue('') + + enableRendererHeapHeadroom({ totalMemoryBytes: 4 * GIB, env: {} }) + + expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('js-flags', expect.anything()) + }) + + it('preserves an explicit prior --max-old-space-size instead of stacking a second value', async () => { + const { app } = await import('electron') + const { enableRendererHeapHeadroom } = await import('./renderer-heap-headroom') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + vi.mocked(app.commandLine.getSwitchValue).mockReturnValue('--max-old-space-size=2048') + + enableRendererHeapHeadroom({ totalMemoryBytes: 16 * GIB, env: {} }) + + expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('js-flags', expect.anything()) + }) + + it('merges with an unrelated existing js-flags value', async () => { + const { app } = await import('electron') + const { enableRendererHeapHeadroom } = await import('./renderer-heap-headroom') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + vi.mocked(app.commandLine.getSwitchValue).mockReturnValue('--no-opt') + + enableRendererHeapHeadroom({ totalMemoryBytes: 16 * GIB, env: {} }) + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'js-flags', + '--no-opt --max-old-space-size=4096' + ) + }) +}) diff --git a/src/main/startup/renderer-heap-headroom.ts b/src/main/startup/renderer-heap-headroom.ts new file mode 100644 index 00000000000..78ea8907c08 --- /dev/null +++ b/src/main/startup/renderer-heap-headroom.ts @@ -0,0 +1,102 @@ +import { app } from 'electron' +import { totalmem } from 'node:os' + +const RENDERER_HEAP_ENV_VAR = 'ORCA_RENDERER_HEAP_MB' +const BYTES_PER_GIB = 1024 * 1024 * 1024 +// Why: Chromium sizes the renderer's V8 old-space heap from a physical-memory +// heuristic (~RAM/4), so an 8 GB machine caps the renderer near ~2.2 GB even +// though V8's pointer-compression cage allows up to ~4 GB. Heavy Orca sessions +// (many agent terminals × scrollback, PR/git caches, React tree) legitimately +// reach that low default and V8 aborts with an OOM — the dominant renderer +// crash in the crash channel. Reclaim the unused headroom up to the 4 GB cage +// on machines that have the RAM. Requests above 4096 are silently capped by the +// pointer-compression cage, so 4096 is the real ceiling — we cannot go higher. +// Machines below ~8 GB keep Chromium's default: raising their ceiling would +// trade a clean OOM for OS memory-pressure kills / swap thrash. +// Why 7.5 not 8: os.totalmem() on Linux reports MemTotal, which excludes +// kernel/firmware-reserved RAM, so a real 8 GB machine reports ~7.7 GiB. Gating +// at exactly 8 would wrongly exclude 8 GB Linux boxes (a crashing population) +// while still cleanly excluding 6 GB machines (which report ~5.7 GiB). +const RENDERER_HEAP_MIN_TOTAL_GIB = 7.5 +const RENDERER_HEAP_RAM_FRACTION = 0.4 +const RENDERER_HEAP_FLOOR_MB = 3072 +// V8 pointer-compression cage hard limit; --max-old-space-size above this is ignored. +const RENDERER_HEAP_CAP_MB = 4096 + +type HeapOverride = number | 'disable' | undefined + +function parseRendererHeapOverrideMb(value: string | undefined): HeapOverride { + if (value === undefined) { + return undefined + } + const normalized = value.trim().toLowerCase() + if (normalized === '') { + return undefined + } + // Why: give operators an explicit opt-out (and E2E a way to pin the default) + // without editing the RAM tiers. + if (normalized === 'default' || normalized === 'off' || normalized === 'none') { + return 'disable' + } + const parsed = Number(normalized) + // Why: ignore an unparseable value (typo) and fall through to the RAM tiers, + // but treat an explicit non-positive number as an opt-out. + if (!Number.isFinite(parsed)) { + return undefined + } + if (parsed <= 0) { + return 'disable' + } + // Why: a fractional value in (0,1) floors to 0, which would emit an invalid + // --max-old-space-size=0. Treat a floored-to-0 override as an opt-out too. + const flooredMb = Math.floor(parsed) + return flooredMb <= 0 ? 'disable' : flooredMb +} + +/** + * Renderer V8 old-space ceiling (MB) to request via --max-old-space-size, or + * null to keep Chromium's physical-memory default. Pure so the RAM tiers and + * the env override are unit-testable without spawning Electron. + */ +export function computeRendererHeapCeilingMb( + totalMemoryBytes: number, + envOverride?: string +): number | null { + const override = parseRendererHeapOverrideMb(envOverride) + if (override === 'disable') { + return null + } + if (typeof override === 'number') { + return override + } + if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) { + return null + } + const totalGib = totalMemoryBytes / BYTES_PER_GIB + if (totalGib < RENDERER_HEAP_MIN_TOTAL_GIB) { + return null + } + const targetMb = Math.floor(totalGib * RENDERER_HEAP_RAM_FRACTION) * 1024 + return Math.min(RENDERER_HEAP_CAP_MB, Math.max(RENDERER_HEAP_FLOOR_MB, targetMb)) +} + +export function enableRendererHeapHeadroom( + options: { totalMemoryBytes?: number; env?: NodeJS.ProcessEnv } = {} +): void { + const totalMemoryBytes = options.totalMemoryBytes ?? totalmem() + const envOverride = (options.env ?? process.env)[RENDERER_HEAP_ENV_VAR] + const ceilingMb = computeRendererHeapCeilingMb(totalMemoryBytes, envOverride) + if (ceilingMb === null) { + return + } + const existing = app.commandLine.getSwitchValue('js-flags') + // Why: respect an explicit --max-old-space-size someone already set (e.g. via + // ELECTRON_EXTRA_LAUNCH_ARGS) instead of stacking a second, ignored value. + if (existing.includes('--max-old-space-size')) { + return + } + const flag = `--max-old-space-size=${ceilingMb}` + // Why: js-flags is process-wide and must be set before app 'ready' so it + // reaches renderer/utility V8 isolates when Chromium spawns them. + app.commandLine.appendSwitch('js-flags', existing ? `${existing} ${flag}` : flag) +} diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index 7a30d115b16..2146563373d 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: this integration-style wrapper suite shares - process cleanup and fake Electron CLI fixtures across related regressions. */ import { existsSync, mkdtempSync, readFileSync, readlinkSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' 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-history.test.ts b/src/main/terminal-history.test.ts index c5e8e2ca805..a9750bb60bc 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -1,7 +1,3 @@ -/* eslint-disable max-lines -- Why: history scoping touches shell detection, env -injection, fallback patching, WSL translation, cleanup, and GC with a TOCTOU age -guard — covering each path in one test file keeps assertions co-located with the -shared mock harness rather than splitting across files. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { 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.check-failure.test.ts b/src/main/updater.check-failure.test.ts index 2ecf0c855be..1b5c1dfc7d5 100644 --- a/src/main/updater.check-failure.test.ts +++ b/src/main/updater.check-failure.test.ts @@ -217,4 +217,54 @@ describe('updater check failure handling', () => { await vi.advanceTimersByTimeAsync(ONE_HOUR_MS - THIRTY_SECONDS_MS) expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) }) + + it('backs off consecutive failing background retries instead of re-checking hourly forever', async () => { + vi.useFakeTimers() + makeBenignCheckFailure('Unable to find latest version on GitHub') + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdates } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + checkForUpdates() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + // First retry keeps the fast 1h cadence (release-publishing windows). + await vi.advanceTimersByTimeAsync(ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + + // Second retry doubles to 2h: nothing at +1h, fires by +2h. + await vi.advanceTimersByTimeAsync(ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3) + + // Third retry doubles to 4h. + await vi.advanceTimersByTimeAsync(2 * ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(2 * ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(4) + + // A completed check resets the backoff to the fast retry. + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('update-not-available', { version: '1.0.51' }) + }) + return Promise.resolve(null) + }) + checkForUpdates() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(5) + + makeBenignCheckFailure('Unable to find latest version on GitHub') + checkForUpdates() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(6) + await vi.advanceTimersByTimeAsync(ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(7) + }) }) 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 99553eadfb8..42924bdf08c 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,11 +1,11 @@ /* 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' import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' +import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn-probe' import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady, @@ -34,6 +34,12 @@ type ReleaseFeedPreflightResult = 'ready' | 'not-available' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 +// Why: a persistently-failing feed (blocked domain, proxy, GHE mirror) used +// to re-arm the retry at an exact 1h cadence forever — the recurring hourly +// macOS Performance Diagnostics signature in issue #7576. Double the retry +// delay per consecutive failure up to this cap; any completed check resets. +// Release-publishing windows resolve within the first (still 1h) retry. +const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000 const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 const QUIT_AND_INSTALL_DELAY_MS = 100 @@ -384,6 +390,9 @@ function beginUpdateCheckAttempt(): number { updateCheckAttemptSequence += 1 activeUpdateCheckAttemptId = updateCheckAttemptSequence armUpdateCheckStallTimer(activeUpdateCheckAttemptId) + // Why: issue #7576's warnings recurred at the retry cadence; field captures + // need a timestamp for each check attempt to confirm or rule the updater out. + writeMainThreadDiagnosticMarker('updater-check-attempt') return activeUpdateCheckAttemptId } @@ -690,7 +699,20 @@ export function getUpdateStatus(): UpdateStatus { return currentStatus } +let consecutiveAutomaticRetrySchedules = 0 + function scheduleAutomaticUpdateCheck(delayMs: number): void { + let effectiveDelayMs = delayMs + // All retry-cadence callers (here and updater-events) pass exactly this + // constant, so keying the backoff on it keeps one choke point instead of + // threading a flag through seven schedule sites. + if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) { + effectiveDelayMs = Math.min( + AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** consecutiveAutomaticRetrySchedules, + MAX_AUTO_UPDATE_RETRY_INTERVAL_MS + ) + consecutiveAutomaticRetrySchedules += 1 + } if (autoUpdateCheckTimer) { clearTimeout(autoUpdateCheckTimer) } @@ -700,10 +722,11 @@ function scheduleAutomaticUpdateCheck(delayMs: number): void { // background attempt scheduled in the main process instead of tying checks // to relaunches or renderer lifetime. runBackgroundUpdateCheck() - }, delayMs) + }, effectiveDelayMs) } function recordCompletedUpdateCheck(): void { + consecutiveAutomaticRetrySchedules = 0 persistLastUpdateCheckAt?.(Date.now()) } @@ -1245,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/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 192e6e8f7f0..718711b629d 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: attachMainWindowServices centralizes main-window IPC wiring; keeping its integration-style mocks together avoids brittle cross-file setup. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' 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/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index d6b24b3dfde..bfee5295635 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: scan, cancellation, SSH fallback, and compaction tests share temp-repo fixtures. */ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4c7f4df72f4..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,7 +1268,7 @@ export type PreloadApi = { seq?: number rawLength?: number background?: boolean - dropped?: boolean + droppedBacklog?: boolean }) => void ) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void @@ -1986,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: { @@ -2240,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[] @@ -2678,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?: { @@ -2809,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/dispatcher.test.ts b/src/relay/dispatcher.test.ts index 5598ae46e0a..5ab997c9d21 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: dispatcher behavior is stateful across - primary, socket, timeout, and cancellation paths; keeping fixtures shared - makes regression tests easier to audit. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { RelayDispatcher } from './dispatcher' import { @@ -398,4 +395,119 @@ describe('RelayDispatcher', () => { expect(listener).toHaveBeenCalledWith(1) }) + + describe('notifyBulk (bulk lane backpressure)', () => { + it('resolves immediately when the sink accepts the frame', async () => { + const frames: Buffer[] = [] + const bulkDispatcher = new RelayDispatcher((data) => { + frames.push(Buffer.from(data)) + return true + }) + try { + await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }) + expect(frames).toHaveLength(1) + const frame = decodeFirstFrame(frames[0]) + const msg = JSON.parse(frame.payload.toString()) as JsonRpcNotification + expect(msg.method).toBe('bulk.event') + } finally { + bulkDispatcher.dispose() + } + }) + + it('holds the next bulk frame until the saturated sink drains', async () => { + const frames: Buffer[] = [] + const drainWaiters = new Set<() => void>() + const bulkDispatcher = new RelayDispatcher( + (data) => { + frames.push(Buffer.from(data)) + return false + }, + { waitWriteDrain: (cb) => drainWaiters.add(cb) } + ) + try { + let firstSettled = false + const first = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }).then(() => { + firstSettled = true + }) + void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }) + await vi.advanceTimersByTimeAsync(0) + + // First frame written, but its send has not settled and the second + // frame is not admitted while the sink stays saturated. + expect(frames).toHaveLength(1) + expect(firstSettled).toBe(false) + + for (const cb of Array.from(drainWaiters)) { + drainWaiters.delete(cb) + cb() + } + await first + await vi.advanceTimersByTimeAsync(0) + expect(frames).toHaveLength(2) + } finally { + bulkDispatcher.dispose() + } + }) + + it('interactive notify() frames are not gated behind a stalled bulk lane', async () => { + const frames: Buffer[] = [] + const bulkDispatcher = new RelayDispatcher( + (data) => { + frames.push(Buffer.from(data)) + return false + }, + { waitWriteDrain: () => {} } + ) + try { + void bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }) + void bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }) + await vi.advanceTimersByTimeAsync(0) + expect(frames).toHaveLength(1) + + bulkDispatcher.notify('pty.data', { id: 'pty-1', data: 'x' }) + expect(frames).toHaveLength(2) + const msg = JSON.parse( + decodeFirstFrame(frames[1]).payload.toString() + ) as JsonRpcNotification + expect(msg.method).toBe('pty.data') + } finally { + bulkDispatcher.dispose() + } + }) + + it('releases a parked bulk send when the dispatcher is disposed', async () => { + const bulkDispatcher = new RelayDispatcher(() => false, { waitWriteDrain: () => {} }) + const pending = bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }) + await vi.advanceTimersByTimeAsync(0) + bulkDispatcher.dispose() + await expect(pending).resolves.toBeUndefined() + }) + + it('targets only the requested client and resolves for missing clients', async () => { + const primaryFrames: Buffer[] = [] + const secondaryFrames: Buffer[] = [] + const bulkDispatcher = new RelayDispatcher((data) => { + primaryFrames.push(Buffer.from(data)) + return true + }) + try { + const secondaryId = bulkDispatcher.attachClient((data) => { + secondaryFrames.push(Buffer.from(data)) + return true + }) + + await bulkDispatcher.notifyBulk('bulk.event', { seq: 0 }, { clientId: secondaryId }) + expect(primaryFrames).toHaveLength(0) + expect(secondaryFrames).toHaveLength(1) + + await expect( + bulkDispatcher.notifyBulk('bulk.event', { seq: 1 }, { clientId: 999 }) + ).resolves.toBeUndefined() + expect(primaryFrames).toHaveLength(0) + expect(secondaryFrames).toHaveLength(1) + } finally { + bulkDispatcher.dispose() + } + }) + }) }) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 41809a4d749..427d90e6ac0 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -27,10 +27,28 @@ export type MethodHandler = ( export type NotificationHandler = (params: Record, context: RequestContext) => void +/** Sink write. Returning literal `false` signals saturation (Node stream + * semantics); `void`/`true` mean the frame was accepted. */ +export type RelayClientWrite = (data: Buffer) => boolean | void + +export type RelayClientSinkOptions = { + /** One-shot: invoke `cb` once when the sink can accept more data again + * (stream 'drain'), or when the sink is permanently dead (error/close) so + * bulk senders waiting on it never hang. */ + waitWriteDrain?: (cb: () => void) => void +} + type RelayClient = { id: number decoder: FrameDecoder - write: (data: Buffer) => void + write: RelayClientWrite + waitWriteDrain?: (cb: () => void) => void + /** Pending resolvers for bulk sends stalled on sink saturation. Flushed on + * drain, write failure, detach, setWrite, and dispose so no pump hangs. */ + drainWaiters: Set<() => void> + /** Serializes bulk-lane sends per client so at most one bulk frame is + * admitted past the sink's high-water mark at a time. */ + bulkChain: Promise nextOutgoingSeq: number highestReceivedSeq: number generation: number @@ -58,8 +76,8 @@ export class RelayDispatcher { private nextClientId = 1 private nextRequestId = 1 - constructor(write: (data: Buffer) => void) { - this.primaryClient = this.createClient(write) + constructor(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions) { + this.primaryClient = this.createClient(write, sinkOptions) this.clients.set(this.primaryClient.id, this.primaryClient) this.startKeepalive() } @@ -75,10 +93,14 @@ export class RelayDispatcher { // never acks the new client's frames until the new client's seq catches // up - causing the client's unacked-timeout checker to accumulate stale // timestamps that could eventually fire a false connection-dead signal. - setWrite(write: (data: Buffer) => void): void { + setWrite(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): void { this.requestAborts.abortClient(this.primaryClient.id) this.primaryClient.write = write + this.primaryClient.waitWriteDrain = sinkOptions?.waitWriteDrain this.primaryClient.closed = false + // Why: the saturated sink the waiters were parked on no longer exists; + // wake stalled bulk senders so they re-evaluate against the new sink. + this.flushDrainWaiters(this.primaryClient) this.resetClient(this.primaryClient) } @@ -89,14 +111,15 @@ export class RelayDispatcher { this.requestAborts.abortClient(this.primaryClient.id) this.primaryClient.generation++ this.primaryClient.closed = true + this.flushDrainWaiters(this.primaryClient) this.notifyClientDetached(this.primaryClient.id) } // Why: synced remote workspaces can have more than one Orca client attached // to the same relay. Frame sequence numbers and JSON-RPC request ids are per // SSH channel, so each socket client needs independent protocol state. - attachClient(write: (data: Buffer) => void): number { - const client = this.createClient(write) + attachClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): number { + const client = this.createClient(write, sinkOptions) this.clients.set(client.id, client) return client.id } @@ -109,6 +132,7 @@ export class RelayDispatcher { this.requestAborts.abortClient(clientId) client.generation++ client.closed = true + this.flushDrainWaiters(client) this.clients.delete(clientId) this.notifyClientDetached(clientId) } @@ -165,6 +189,90 @@ export class RelayDispatcher { } } + /** + * Bulk-lane notification. Sends are serialized per client and the returned + * promise resolves only after the sink accepted the frame without reporting + * saturation (or the client went away). Bulk producers (file streams) await + * this between frames so interactive frames (pty.data echo) never queue + * behind an unbounded backlog on the shared SSH channel. + * + * With `clientId`, the frame goes only to that client — stream chunks have + * exactly one consumer, and broadcasting them would let one slow secondary + * client stall everyone. A missing/closed target resolves immediately; the + * caller's staleness check owns aborting the stream. + */ + notifyBulk( + method: string, + params?: Record, + opts?: { clientId?: number } + ): Promise { + if (this.disposed) { + return Promise.resolve() + } + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method, + ...(params !== undefined ? { params } : {}) + } + const targets = + opts?.clientId !== undefined + ? [this.clients.get(opts.clientId)].filter((c): c is RelayClient => c !== undefined) + : Array.from(this.clients.values()) + const waits: Promise[] = [] + for (const client of targets) { + if (client.closed) { + continue + } + // Why: the frame is encoded inside the chain step, not at call time — + // sequence numbers must be assigned in actual write order. + const step = client.bulkChain.then(() => { + if (this.disposed || client.closed) { + return + } + const accepted = this.sendFrame(client, msg) + if (accepted === false) { + return this.waitForClientDrain(client) + } + return undefined + }) + client.bulkChain = step.catch(() => {}) + waits.push(step) + } + if (waits.length === 0) { + return Promise.resolve() + } + return Promise.all(waits).then(() => {}) + } + + private waitForClientDrain(client: RelayClient): Promise { + if (this.disposed || client.closed || !client.waitWriteDrain) { + return Promise.resolve() + } + return new Promise((resolve) => { + let settled = false + const finish = (): void => { + if (settled) { + return + } + settled = true + client.drainWaiters.delete(finish) + resolve() + } + client.drainWaiters.add(finish) + try { + client.waitWriteDrain!(finish) + } catch { + finish() + } + }) + } + + private flushDrainWaiters(client: RelayClient): void { + for (const waiter of Array.from(client.drainWaiters)) { + waiter() + } + } + requestPrimary( method: string, params?: Record, @@ -236,14 +344,20 @@ export class RelayDispatcher { // Why: dispose means this relay instance cannot send responses anymore; // abort in-flight request work so stale SSH-side scans/watchers release. this.requestAborts.abortAll() + for (const client of this.clients.values()) { + this.flushDrainWaiters(client) + } } - private createClient(write: (data: Buffer) => void): RelayClient { + private createClient(write: RelayClientWrite, sinkOptions?: RelayClientSinkOptions): RelayClient { const id = this.nextClientId++ const client: RelayClient = { id, decoder: new FrameDecoder((frame) => this.handleFrame(client, frame)), write, + waitWriteDrain: sinkOptions?.waitWriteDrain, + drainWaiters: new Set(), + bulkChain: Promise.resolve(), nextOutgoingSeq: 1, highestReceivedSeq: 0, generation: 0, @@ -387,13 +501,13 @@ export class RelayDispatcher { private sendFrame( client: RelayClient, msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification - ): void { + ): boolean | void { if (this.disposed || client.closed) { return } const seq = client.nextOutgoingSeq++ const frame = encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) - this.writeFrame(client, frame) + return this.writeFrame(client, frame) } private startKeepalive(): void { @@ -416,12 +530,13 @@ export class RelayDispatcher { this.keepaliveTimer.unref() } - private writeFrame(client: RelayClient, frame: Buffer): void { + private writeFrame(client: RelayClient, frame: Buffer): boolean | void { try { - client.write(frame) + return client.write(frame) } catch (err) { client.closed = true client.generation++ + this.flushDrainWaiters(client) if (client !== this.primaryClient) { this.clients.delete(client.id) this.notifyClientDetached(client.id) diff --git a/src/relay/fs-handler-file-read.ts b/src/relay/fs-handler-file-read.ts index 45a0685b8ac..003172c08fd 100644 --- a/src/relay/fs-handler-file-read.ts +++ b/src/relay/fs-handler-file-read.ts @@ -1,7 +1,7 @@ import { open, readFile, stat } from 'node:fs/promises' import { extname } from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' -import { STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol' +import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE, RelayErrorCode } from './protocol' import type { RelayStreamRegistry, TooManyStreamsError } from './fs-stream-registry' import { BINARY_PROBE_BYTES, @@ -61,11 +61,21 @@ type StreamChunkReader = { ): Promise<{ bytesRead: number }> } +export type StreamPumpOptions = { + /** Client that requested the stream. Chunks go only to it — broadcasting + * bulk frames would let one slow secondary client stall the requester. */ + clientId?: number + /** True when the client declared `flowControl: 'ack'` — it sends + * fs.streamAck per processed chunk and the pump caps unacked chunks. */ + paceWithAcks: boolean +} + export async function readRelayFileStreamMetadata( filePath: string, dispatcher: RelayDispatcher, registry: RelayStreamRegistry, - context: RequestContext + context: RequestContext, + pumpOptions?: StreamPumpOptions ): Promise { const stats = await stat(filePath) const mimeType = IMAGE_MIME_TYPES[extname(filePath).toLowerCase()] @@ -106,8 +116,9 @@ export async function readRelayFileStreamMetadata( // Why: pumpChunks owns its own try/finally for handle release; the outer // setImmediate kicks the pump off the metadata-response task so the client // sees the response before the first chunk frame. + const resolvedPumpOptions = pumpOptions ?? { paceWithAcks: false } setImmediate(() => { - void pumpChunks(streamId, stats.size, dispatcher, registry, context) + void pumpChunks(streamId, stats.size, dispatcher, registry, context, resolvedPumpOptions) }) return { @@ -126,7 +137,8 @@ async function pumpChunks( totalSize: number, dispatcher: RelayDispatcher, registry: RelayStreamRegistry, - context: RequestContext + context: RequestContext, + pumpOptions: StreamPumpOptions ): Promise { const entry = registry.get(streamId) if (!entry) { @@ -150,6 +162,27 @@ async function pumpChunks( endReason = 'aborted' break } + // Why: credit window — bulk chunks share one ordered SSH channel with + // interactive pty.data frames. Waiting for client acks bounds how many + // stream bytes a keystroke echo can queue behind, and yields the relay + // event loop so incoming keystrokes are handled between chunks. + if (pumpOptions.paceWithAcks) { + while ( + seq - registry.ackedThroughSeq(streamId) > STREAM_ACK_WINDOW_CHUNKS && + !context.isStale() && + !registry.isAborted(streamId) + ) { + await registry.waitForAck(streamId) + } + if (context.isStale()) { + endReason = 'stale' + break + } + if (registry.isAborted(streamId)) { + endReason = 'aborted' + break + } + } const want = Math.min(STREAM_CHUNK_SIZE, totalSize - offset) const bytesRead = await readFullStreamChunk(entry.handle, buffer, want, offset) if (bytesRead !== want) { @@ -167,7 +200,14 @@ async function pumpChunks( break } const data = buffer.subarray(0, bytesRead).toString('base64') - dispatcher.notify('fs.streamChunk', { streamId, seq, data }) + // Why: the bulk lane waits out sink saturation, so a flood of chunk + // frames cannot pile up in the outbound pipe ahead of interactive + // pty.data frames written via plain notify(). + await dispatcher.notifyBulk( + 'fs.streamChunk', + { streamId, seq, data }, + pumpOptions.clientId !== undefined ? { clientId: pumpOptions.clientId } : undefined + ) offset += bytesRead seq += 1 } 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-stream.test.ts b/src/relay/fs-handler-stream.test.ts index d71dbf8c076..c7624afce65 100644 --- a/src/relay/fs-handler-stream.test.ts +++ b/src/relay/fs-handler-stream.test.ts @@ -34,6 +34,9 @@ function createMockDispatcher() { notify: vi.fn((method: string, params?: Record) => { notifications.push({ method, params }) }), + notifyBulk: vi.fn(async (method: string, params?: Record): Promise => { + notifications.push({ method, params }) + }), _notifications: notifications, callRequest( method: string, @@ -278,6 +281,61 @@ describe('FsHandler readFileStream', () => { expect(err).toBeNull() }) + it('parks the pump at the ack credit window and resumes on fs.streamAck', async () => { + const filePath = path.join(tmpDir, 'paced.png') + const content = Buffer.alloc(1536 * 1024, 0x42) // 6 chunks + writeFileSync(filePath, content) + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath, flowControl: 'ack' }, + { isStale: () => false } + )) as { streamId: number } + + // Window of 4 admits seqs 0..3; seq 4 must wait for an ack. + await waitFor(() => collectStream(dispatcher).chunks.length === 4) + await flush(10) + expect(collectStream(dispatcher).chunks.length).toBe(4) + expect(collectStream(dispatcher).end).toBeNull() + + // Ack one chunk → exactly one more is admitted. + dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq: 0 }) + await waitFor(() => collectStream(dispatcher).chunks.length === 5) + await flush(10) + expect(collectStream(dispatcher).chunks.length).toBe(5) + + for (let seq = 1; seq < 6; seq += 1) { + dispatcher.callNotification('fs.streamAck', { streamId: meta.streamId, seq }) + } + await waitFor(() => collectStream(dispatcher).end !== null) + + const { chunks, err } = collectStream(dispatcher) + expect(err).toBeNull() + const reassembled = Buffer.concat(chunks.map((c) => Buffer.from(c.data, 'base64'))) + expect(reassembled.equals(content)).toBe(true) + }) + + it('releases a pump parked on the ack window when the stream is cancelled', async () => { + const filePath = path.join(tmpDir, 'parked-cancel.png') + writeFileSync(filePath, Buffer.alloc(4 * 1024 * 1024, 0x42)) // 16 chunks + + const meta = (await dispatcher.callRequest( + 'fs.readFileStream', + { filePath, flowControl: 'ack' }, + { isStale: () => false } + )) as { streamId: number } + + await waitFor(() => collectStream(dispatcher).chunks.length === 4) + dispatcher.callNotification('fs.cancelStream', { streamId: meta.streamId }) + + const registry = (handler as unknown as { streamRegistry: { size(): number } }).streamRegistry + await waitFor(() => registry.size() === 0) + + const { end, err } = collectStream(dispatcher) + expect(end).toBeNull() + expect(err).toBeNull() + }) + it('rejects the 17th concurrent stream with TooManyStreams', async () => { const paths: string[] = [] for (let i = 0; i < 17; i++) { diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index b90a786f7fd..7d5354cf47a 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: this suite covers relay filesystem RPCs, - Space scans, file watcher lifecycle edges, and cross-platform path behavior together. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { MAX_TEXT_FILE_SIZE } from './fs-handler-utils' diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 0c4a263f13e..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,11 +87,18 @@ 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 this.registerHandlers() - this.dispatcher.onClientDetached?.((clientId) => this.releaseClientWatches(clientId)) + this.dispatcher.onClientDetached?.((clientId) => { + this.releaseClientWatches(clientId) + // Why: a detached client's fs.streamAck frames will never arrive; wake + // any pump parked on the ack window so it re-checks staleness and exits + // instead of stranding its open file handle. + this.streamRegistry.wakeAllAckWaiters() + }) } private registerHandlers(): void { @@ -108,11 +120,12 @@ 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)) this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p)) + this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p)) } private async readDir(params: Record) { @@ -148,7 +161,13 @@ export class FsHandler { private async readFileStream(params: Record, context?: RequestContext) { const filePath = expandTilde(params.filePath as string) const ctx = context ?? { clientId: 0, isStale: () => false } - return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx) + return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx, { + // Why: only target the requesting client when the dispatcher actually + // routed this request (context present) — direct-call tests and legacy + // paths keep broadcast semantics. + ...(context ? { clientId: context.clientId } : {}), + paceWithAcks: params.flowControl === 'ack' + }) } private async tempDir(): Promise { @@ -162,6 +181,14 @@ export class FsHandler { } } + private streamAck(params: Record): void { + const streamId = params.streamId as number | undefined + const seq = params.seq as number | undefined + if (typeof streamId === 'number' && typeof seq === 'number') { + this.streamRegistry.recordAck(streamId, seq) + } + } + private async writeFile(params: Record) { const filePath = expandTilde(params.filePath as string) const content = params.content as string @@ -310,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 @@ -340,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)) @@ -354,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/fs-stream-pty-echo-backpressure.integration.test.ts b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts new file mode 100644 index 00000000000..8cca2787891 --- /dev/null +++ b/src/relay/fs-stream-pty-echo-backpressure.integration.test.ts @@ -0,0 +1,295 @@ +/** + * Regression: SSH typing latency under bulk file-stream load. + * + * The relay and the client share ONE ordered SSH channel. If the relay + * enqueues an entire file's fs.streamChunk frames into the outbound pipe + * at once, an interactive pty.data echo emitted mid-stream queues behind + * megabytes of bulk data and typing feels seconds-slow. + * + * These tests model the SSH channel as a congestible in-memory pipe and + * assert deterministic byte bounds instead of wall-clock latency: + * - with a saturated sink, the relay stalls the pump on write backpressure + * so at most ~1 chunk frame sits ahead of a pty echo; + * - with a fast sink but an unresponsive client, the fs.streamAck credit + * window bounds the in-flight backlog; + * - legacy clients that never ack still receive the full stream. + */ +import { describe, expect, it, beforeEach, afterEach } 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' +import { randomBytes } from 'node:crypto' + +import { + SshChannelMultiplexer, + type MultiplexerTransport +} from '../main/ssh/ssh-channel-multiplexer' +import { readFileViaStream } from '../main/ssh/ssh-filesystem-stream-reader' + +import { RelayDispatcher } from './dispatcher' +import { RelayContext } from './context' +import { FsHandler } from './fs-handler' +import { STREAM_CHUNK_SIZE } from './protocol' + +// One framed fs.streamChunk: 256KB raw → base64 (4/3) + JSON envelope + header. +const FRAMED_CHUNK_BYTES = Math.ceil((STREAM_CHUNK_SIZE * 4) / 3) + 512 +// Node pipe/socket sinks report saturation via write() === false past the HWM. +const SINK_HIGH_WATER_MARK = 64 * 1024 + +async function waitUntil( + predicate: () => boolean, + what: string, + timeoutMs = 10_000 +): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`waitUntil timed out: ${what}`) + } + await new Promise((r) => setImmediate(r)) + } +} + +/** Waits until `read()` stops changing for `stableTurns` macrotask turns. */ +async function waitUntilSettled(read: () => number, stableTurns = 25): Promise { + let last = read() + let stable = 0 + while (stable < stableTurns) { + await new Promise((r) => setImmediate(r)) + const current = read() + if (current === last) { + stable += 1 + } else { + stable = 0 + last = current + } + } +} + +type Harness = { + mux: SshChannelMultiplexer + dispatcher: RelayDispatcher + fsHandler: FsHandler + queuedBytes: () => number + /** Deliver every queued relay→client buffer to the client mux. */ + deliverAll: () => void + /** Start delivering continuously on each macrotask turn. */ + startAutoDeliver: () => void + dispose: () => void +} + +function createHarness(opts: { congested: boolean }): Harness { + let relayFeed: ((data: Buffer) => void) | null = null + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + + const clientTransport: MultiplexerTransport = { + write: (data: Buffer) => { + // Client → relay: keystrokes and acks flow on the opposite direction of + // the duplex channel; they are not blocked by relay→client congestion. + setImmediate(() => relayFeed?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + onClose: () => {} + } + + const outQueue: Buffer[] = [] + let queuedBytes = 0 + const drainWaiters = new Set<() => void>() + const fireDrainIfIdle = (): void => { + if (queuedBytes > 0) { + return + } + for (const cb of Array.from(drainWaiters)) { + drainWaiters.delete(cb) + cb() + } + } + + const dispatcher = new RelayDispatcher( + (data: Buffer) => { + outQueue.push(data) + queuedBytes += data.length + if (!opts.congested) { + return true + } + return queuedBytes < SINK_HIGH_WATER_MARK + }, + { + waitWriteDrain: (cb: () => void) => { + drainWaiters.add(cb) + fireDrainIfIdle() + } + } + ) + relayFeed = (data: Buffer) => dispatcher.feed(data) + + const deliverAll = (): void => { + while (outQueue.length > 0) { + const buf = outQueue.shift()! + queuedBytes -= buf.length + for (const cb of clientDataCallbacks) { + cb(buf) + } + } + fireDrainIfIdle() + } + + let autoDeliverTimer: ReturnType | null = null + const startAutoDeliver = (): void => { + if (autoDeliverTimer) { + return + } + autoDeliverTimer = setInterval(deliverAll, 1) + } + + const context = new RelayContext() + const fsHandler = new FsHandler(dispatcher, context) + const mux = new SshChannelMultiplexer(clientTransport) + + return { + mux, + dispatcher, + fsHandler, + queuedBytes: () => queuedBytes, + deliverAll, + startAutoDeliver, + dispose: () => { + if (autoDeliverTimer) { + clearInterval(autoDeliverTimer) + } + mux.dispose() + dispatcher.dispose() + fsHandler.dispose() + } + } +} + +describe('fs.readFileStream vs pty.data echo head-of-line blocking', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-stream-hol-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('bounds bulk bytes queued ahead of a pty echo when the channel is congested', async () => { + const harness = createHarness({ congested: true }) + try { + const filePath = path.join(tmpDir, 'big.png') + const original = randomBytes(3 * 1024 * 1024) // 12 chunks + writeFileSync(filePath, original) + + // Relay-side fake PTY: echoes input back immediately, mirroring + // PtyHandler's interactive fast path (dispatcher.notify on echo). + let queuedBytesAheadOfEcho = -1 + harness.dispatcher.onNotification('pty.data', (params) => { + queuedBytesAheadOfEcho = harness.queuedBytes() + harness.dispatcher.notify('pty.data', { id: params.id, data: params.data }) + }) + + const readPromise = readFileViaStream(harness.mux, filePath) + // Deliver until the client has the stream metadata, then congest fully. + await waitUntil(() => harness.queuedBytes() > 0, 'metadata response queued') + harness.deliverAll() + // Let the pump run to whatever bound it enforces (pre-fix: whole file). + await waitUntil(() => harness.queuedBytes() > 0, 'first chunk queued') + await waitUntilSettled(() => harness.queuedBytes()) + + // Type one key while the stream is congested. + harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' }) + await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay') + + // The echo must not sit behind an unbounded chunk backlog: at most one + // in-flight bulk frame (the write that saturated the sink) plus slack. + expect(queuedBytesAheadOfEcho).toBeLessThan(2 * FRAMED_CHUNK_BYTES) + + // Un-congest: the stream must still complete with intact content. + harness.startAutoDeliver() + const result = await readPromise + expect(Buffer.from(result.content, 'base64').equals(original)).toBe(true) + } finally { + harness.dispose() + } + }, 30_000) + + it('caps in-flight chunks via the fs.streamAck credit window when the client stalls', async () => { + const harness = createHarness({ congested: false }) + try { + const filePath = path.join(tmpDir, 'big.png') + writeFileSync(filePath, randomBytes(3 * 1024 * 1024)) // 12 chunks + + const receivedSeqs: number[] = [] + harness.mux.onNotificationByMethod('fs.streamChunk', (params) => { + receivedSeqs.push(params.seq as number) + }) + let streamEnded = false + harness.mux.onNotificationByMethod('fs.streamEnd', () => { + streamEnded = true + }) + + harness.startAutoDeliver() + // Raw ack-capable request without sending any acks: models a client + // whose main thread is too busy to process chunks. + const metadata = (await harness.mux.request('fs.readFileStream', { + filePath, + flowControl: 'ack' + })) as { streamId: number } + + await waitUntil(() => receivedSeqs.length > 0, 'first chunk received') + await waitUntilSettled(() => receivedSeqs.length) + + // Without acks the relay must stop at the credit window, not flood + // the remaining chunks. + expect(receivedSeqs.length).toBeLessThanOrEqual(5) + expect(streamEnded).toBe(false) + + // Acking releases the window and the stream completes. + const totalChunks = 12 + for (let seq = 0; seq < totalChunks; seq += 1) { + harness.mux.notify('fs.streamAck', { streamId: metadata.streamId, seq }) + } + await waitUntil(() => streamEnded, 'stream completed after acks') + expect(receivedSeqs).toEqual(Array.from({ length: totalChunks }, (_, i) => i)) + } finally { + harness.dispose() + } + }, 30_000) + + it('still delivers the full stream to legacy clients that never ack', async () => { + const harness = createHarness({ congested: false }) + try { + const filePath = path.join(tmpDir, 'legacy.png') + const original = randomBytes(1024 * 1024 + 12345) + writeFileSync(filePath, original) + + const chunks = new Map() + let streamEnded = false + harness.mux.onNotificationByMethod('fs.streamChunk', (params) => { + chunks.set(params.seq as number, Buffer.from(params.data as string, 'base64')) + }) + harness.mux.onNotificationByMethod('fs.streamEnd', () => { + streamEnded = true + }) + + harness.startAutoDeliver() + // Legacy request shape: no flowControl param, and no acks ever sent. + await harness.mux.request('fs.readFileStream', { filePath }) + await waitUntil(() => streamEnded, 'legacy stream completed') + + const reassembled = Buffer.concat( + Array.from(chunks.entries()) + .sort(([a], [b]) => a - b) + .map(([, buf]) => buf) + ) + expect(reassembled.equals(original)).toBe(true) + } finally { + harness.dispose() + } + }, 30_000) +}) diff --git a/src/relay/fs-stream-registry.ts b/src/relay/fs-stream-registry.ts index e0bf2a5ed6f..2bca76c9495 100644 --- a/src/relay/fs-stream-registry.ts +++ b/src/relay/fs-stream-registry.ts @@ -1,9 +1,14 @@ import type { FileHandle } from 'node:fs/promises' -import { MAX_CONCURRENT_STREAMS, RelayErrorCode } from './protocol' +import { MAX_CONCURRENT_STREAMS, RelayErrorCode, STREAM_ACK_STALL_RECHECK_MS } from './protocol' type StreamEntry = { handle: FileHandle aborted: boolean + /** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */ + ackedThroughSeq: number + /** Pumps parked on the ack credit window. Woken by acks, abort, release, + * and a periodic stall recheck so a vanished client cannot strand a pump. */ + ackWaiters: Set<() => void> } export class TooManyStreamsError extends Error { @@ -22,7 +27,12 @@ export class RelayStreamRegistry { throw new TooManyStreamsError() } const streamId = this.nextId++ - this.streams.set(streamId, { handle, aborted: false }) + this.streams.set(streamId, { + handle, + aborted: false, + ackedThroughSeq: -1, + ackWaiters: new Set() + }) return streamId } @@ -30,6 +40,7 @@ export class RelayStreamRegistry { const entry = this.streams.get(streamId) if (entry) { entry.aborted = true + this.wakeAckWaiters(entry) } } @@ -41,11 +52,65 @@ export class RelayStreamRegistry { return this.streams.get(streamId) } + recordAck(streamId: number, seq: number): void { + const entry = this.streams.get(streamId) + if (!entry || typeof seq !== 'number' || !Number.isFinite(seq)) { + return + } + if (seq > entry.ackedThroughSeq) { + entry.ackedThroughSeq = seq + } + this.wakeAckWaiters(entry) + } + + ackedThroughSeq(streamId: number): number { + return this.streams.get(streamId)?.ackedThroughSeq ?? Number.MAX_SAFE_INTEGER + } + + /** Resolves on the next ack/abort/release for this stream, or after the + * stall-recheck interval so callers can re-evaluate staleness. */ + waitForAck(streamId: number): Promise { + const entry = this.streams.get(streamId) + if (!entry || entry.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + let settled = false + const timer = setTimeout(() => finish(), STREAM_ACK_STALL_RECHECK_MS) + timer.unref?.() + const finish = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + entry.ackWaiters.delete(finish) + resolve() + } + entry.ackWaiters.add(finish) + }) + } + + /** Wake every parked pump (all streams) so it re-checks staleness — used + * when a client detaches and its acks will never arrive. */ + wakeAllAckWaiters(): void { + for (const entry of this.streams.values()) { + this.wakeAckWaiters(entry) + } + } + + private wakeAckWaiters(entry: StreamEntry): void { + for (const waiter of Array.from(entry.ackWaiters)) { + waiter() + } + } + async release(streamId: number): Promise { const entry = this.streams.get(streamId) if (!entry) { return } + this.wakeAckWaiters(entry) this.streams.delete(streamId) try { await entry.handle.close() diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 668354b03db..8833ddcb97f 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -236,6 +236,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..27e7713343c 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -22,6 +22,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 +54,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 +123,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, diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 66818efaf61..267f6ef480f 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -48,6 +48,17 @@ export const TIMEOUT_MS = 20_000 export const STREAM_CHUNK_SIZE = 256 * 1024 export const MAX_CONCURRENT_STREAMS = 16 +/** Max unacked fs.streamChunk frames in flight per stream when the client + * requested `flowControl: 'ack'`. Bounds how many bulk bytes an interactive + * pty.data frame can queue behind on the shared SSH channel (~1MB raw) while + * keeping the pipe full across one ack round-trip on fast links. */ +export const STREAM_ACK_WINDOW_CHUNKS = 4 + +/** Safety-valve poll interval for a pump stalled on acks: re-checks stream + * abort/staleness so a client that vanished mid-stream cannot park the pump + * (and its open file handle) forever. */ +export const STREAM_ACK_STALL_RECHECK_MS = 1_000 + export const RelayErrorCode = { TooManyStreams: -33006, StreamProtocolError: -33007 @@ -109,7 +120,12 @@ export function encodeKeepAliveFrame(id: number, ack: number): Buffer { } export class FrameDecoder { - private buffer = Buffer.alloc(0) + // Why: feed() sits on the hot receive path. Rebuilding one contiguous + // buffer per feed (Buffer.concat) re-copies every already-buffered byte for + // each incoming chunk — O(n²) per large frame. A chunk list assembles each + // frame exactly once instead. + private chunks: Buffer[] = [] + private bufferedLength = 0 private onFrame: (frame: DecodedFrame) => void private onError: ((err: Error) => void) | null @@ -119,23 +135,32 @@ export class FrameDecoder { } feed(chunk: Buffer | Uint8Array): void { - this.buffer = Buffer.concat([this.buffer, chunk]) + const buf = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) + if (buf.length > 0) { + this.chunks.push(buf) + this.bufferedLength += buf.length + } - while (this.buffer.length >= HEADER_LENGTH) { - const length = this.buffer.readUInt32BE(9) + while (this.bufferedLength >= HEADER_LENGTH) { + const header = this.peekBytes(HEADER_LENGTH) + const length = header.readUInt32BE(9) const totalLength = HEADER_LENGTH + length + if (this.bufferedLength < totalLength) { + // Not fully received yet (also holds oversized frames until they can + // be skipped whole, keeping the decoder synchronized). + break + } + if (length > MAX_MESSAGE_SIZE) { // Why: Throwing here would leave the buffer in a partially consumed // state — subsequent feed() calls would try to parse the leftover // payload bytes as a new header, corrupting every future frame. // Instead we skip the entire oversized frame so the decoder stays // synchronized with the stream. - if (this.buffer.length < totalLength) { - // Haven't received the full oversized payload yet; wait for more data. - break - } - this.buffer = this.buffer.subarray(totalLength) + this.discardBytes(totalLength) const err = new Error(`Frame payload too large: ${length} bytes — discarded`) if (this.onError) { this.onError(err) @@ -145,23 +170,20 @@ export class FrameDecoder { continue } - if (this.buffer.length < totalLength) { - break - } - + const framed = this.takeBytes(totalLength) const frame: DecodedFrame = { - type: this.buffer[0], - id: this.buffer.readUInt32BE(1), - ack: this.buffer.readUInt32BE(5), - payload: this.buffer.subarray(HEADER_LENGTH, totalLength) + type: framed[0], + id: framed.readUInt32BE(1), + ack: framed.readUInt32BE(5), + payload: framed.subarray(HEADER_LENGTH, totalLength) } - this.buffer = this.buffer.subarray(totalLength) this.onFrame(frame) } } reset(): void { - this.buffer = Buffer.alloc(0) + this.chunks = [] + this.bufferedLength = 0 } // Why: at the handshake → dispatcher transition, the next consumer must @@ -169,10 +191,74 @@ export class FrameDecoder { // frame. This returns and clears the decoder's internal residue so the // caller can hand it to the dispatcher (or stdout pipe) without loss. drain(): Buffer { - const out = this.buffer - this.buffer = Buffer.alloc(0) + const out = + this.chunks.length === 1 ? this.chunks[0] : Buffer.concat(this.chunks, this.bufferedLength) + this.reset() return out } + + /** View of the first `count` buffered bytes without consuming them. */ + private peekBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length >= count) { + return first + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + for (const part of this.chunks) { + copied += part.copy(out, copied, 0, Math.min(part.length, count - copied)) + if (copied >= count) { + break + } + } + return out + } + + /** Consume and return the first `count` buffered bytes (single copy). */ + private takeBytes(count: number): Buffer { + const first = this.chunks[0] + if (first.length === count) { + this.chunks.shift() + this.bufferedLength -= count + return first + } + if (first.length > count) { + this.chunks[0] = first.subarray(count) + this.bufferedLength -= count + return first.subarray(0, count) + } + const out = Buffer.allocUnsafe(count) + let copied = 0 + while (copied < count) { + const part = this.chunks[0] + const take = Math.min(part.length, count - copied) + part.copy(out, copied, 0, take) + copied += take + if (take === part.length) { + this.chunks.shift() + } else { + this.chunks[0] = part.subarray(take) + } + } + this.bufferedLength -= count + return out + } + + /** Consume the first `count` buffered bytes without assembling them. */ + private discardBytes(count: number): void { + let remaining = count + while (remaining > 0) { + const part = this.chunks[0] + if (part.length <= remaining) { + this.chunks.shift() + remaining -= part.length + } else { + this.chunks[0] = part.subarray(remaining) + remaining = 0 + } + } + this.bufferedLength -= count + } } export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage { 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 c930a244b65..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. @@ -373,15 +374,44 @@ async function main(): Promise { // write to a dead pipe, silently failing or throwing EPIPE. When a // socket client reconnects, setWrite swaps the callback to the socket. let stdoutAlive = true - const dispatcher = new RelayDispatcher((data) => { - if (stdoutAlive) { + // Why: one-shot waiters parked by the dispatcher's bulk lane when stdout + // reports saturation (write() === false). Flushed on 'drain' and on every + // stdout-death path so a stalled file-stream pump never outlives the pipe + // it was waiting on. + const stdoutDrainWaiters = new Set<() => void>() + const flushStdoutDrainWaiters = (): void => { + for (const cb of Array.from(stdoutDrainWaiters)) { + stdoutDrainWaiters.delete(cb) + cb() + } + } + process.stdout.on('drain', flushStdoutDrainWaiters) + const dispatcher = new RelayDispatcher( + (data) => { + if (!stdoutAlive) { + return + } try { - process.stdout.write(data) + // Why: surface Node's backpressure signal to the dispatcher so bulk + // frames (fs.streamChunk) wait for drain instead of queueing megabytes + // ahead of interactive pty.data frames on the SSH channel. + return process.stdout.write(data) } catch { stdoutAlive = false + flushStdoutDrainWaiters() + return undefined + } + }, + { + waitWriteDrain: (cb) => { + if (!stdoutAlive) { + cb() + return + } + stdoutDrainWaiters.add(cb) } } - }) + ) const context = new RelayContext() @@ -500,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)}` ) } @@ -659,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 @@ -675,16 +705,40 @@ 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') - const clientId = dispatcher.attachClient((data) => { - if (!sock.destroyed) { - sock.write(data) + // Why: same backpressure surface as the stdout sink — bulk frames wait + // for the socket to drain so they cannot bury interactive PTY frames. + const sockDrainWaiters = new Set<() => void>() + const flushSockDrainWaiters = (): void => { + for (const cb of Array.from(sockDrainWaiters)) { + sockDrainWaiters.delete(cb) + cb() } - }) + } + sock.on('drain', flushSockDrainWaiters) + sock.on('close', flushSockDrainWaiters) + sock.on('error', flushSockDrainWaiters) + const clientId = dispatcher.attachClient( + (data) => { + if (!sock.destroyed) { + return sock.write(data) + } + return undefined + }, + { + waitWriteDrain: (cb) => { + if (sock.destroyed) { + cb() + return + } + sockDrainWaiters.add(cb) + } + } + ) socketClients.set(sock, clientId) // Why: bytes that arrived in the same TCP send as the handshake frame @@ -726,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') } @@ -768,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() } @@ -778,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) } @@ -850,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) }) @@ -889,6 +941,7 @@ async function main(): Promise { // before the grace period starts. process.stdout.on('error', () => { stdoutAlive = false + flushStdoutDrainWaiters() dispatcher.invalidateClient() }) @@ -904,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) } @@ -934,6 +987,7 @@ async function main(): Promise { // dead so the primary client write callback becomes a no-op while // socket clients, if any, keep their own live transports. stdoutAlive = false + flushStdoutDrainWaiters() dispatcher.invalidateClient() if (socketClients.size === 0) { startGrace('stdin ended') @@ -942,6 +996,7 @@ async function main(): Promise { process.stdin.on('error', () => { stdoutAlive = false + flushStdoutDrainWaiters() dispatcher.invalidateClient() if (socketClients.size === 0) { startGrace('stdin error') @@ -950,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 @@ -978,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 @@ -1003,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/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index 059e5739f6b..4d19542b91c 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: subprocess coverage shares one bundled relay artifact; splitting this file would rebuild the same daemon bundle across suites and make these lifecycle tests slower/flakier. */ import { afterAll, beforeAll, describe, expect, it, afterEach } from 'vitest' import { existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { rm } from 'node:fs/promises' diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 72f93b7bd5f..eb34e42b62e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -64,6 +64,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 @@ -112,10 +113,11 @@ import { } from './lib/workspace-session' import { createSessionWriteSubscriber } from './lib/session-write-subscriber' import { - fetchWorkspaceSessionFromHosts, + fetchWorkspaceSessionWithRuntimeHostOwners, patchWorkspaceSessionByHost, persistWorkspaceSessionByHostSync } from './lib/workspace-session-host-persistence' +import { collectFolderWorkspaceKeysFromSession } from './lib/workspace-session-hydration-keys' import { getStartupErrorFallbackUI, hydratePersistedUIAfterStartupRead @@ -158,6 +160,7 @@ import { type KeybindingContext, type PhysicalModifierToken } from '../../shared/keybindings' +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { ModifierDoubleTapDetector, toModifierDoubleTapEvent @@ -180,6 +183,17 @@ const hasCustomTitleBar = shouldRenderDesktopWindowChrome({ isWebClient: isPairedWebClientWindow() }) +async function listRuntimeSessionHostIdsForStartup(): Promise { + try { + return (await window.api.runtimeEnvironments.list()).map((environment) => + toRuntimeExecutionHostId(environment.id) + ) + } catch (err) { + console.warn('Failed to list runtime session hosts for startup:', err) + return [] + } +} + function getKeybindingContext(target: EventTarget | null): KeybindingContext { return target instanceof HTMLElement && target.classList.contains('xterm-helper-textarea') ? 'terminal' @@ -405,6 +419,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, @@ -851,21 +866,23 @@ 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. await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings()) - // Why: these three reads are main-side store/file reads with no - // dependency on anything fetched below, so start them now and await - // them at their original positions — the round-trips overlap the - // repo/worktree scans instead of queuing after them. Browser session - // profiles are deliberately NOT started early: on a remote runtime - // they route through a runtime RPC that may not be connected this - // early, and a failed fetch clears the profile list. The floating - // .catch marks the rejection handled if an earlier awaited step - // throws first; each await still rethrows its own failure. - const uiGetPromise = timeRendererStartupStep('ui-get', () => window.api.ui.get()) - uiGetPromise.catch(() => {}) + // Why: keybindings + onboarding are main-side reads with no dependency + // on the catalog/session steps below, so start them now and await them + // at their original positions — the round-trips overlap the local + // catalog scans instead of queuing after them. Browser session profiles + // are deliberately NOT started early: on a remote runtime they route + // through a runtime RPC that may not be connected this early, and a + // failed fetch clears the profile list. The floating .catch marks the + // rejection handled if an earlier awaited step throws first; each await + // still rethrows its own failure. const keybindingsPromise = timeRendererStartupStep('fetch-keybindings', () => actions.fetchKeybindings() ) @@ -874,36 +891,11 @@ function App(): React.JSX.Element { window.api.onboarding.get() ) onboardingPromise.catch(() => {}) - // Why: load local + every configured runtime environment (not just the - // active one) so a cold start that restored a remote workspace doesn't - // hide local repos. The sidebar "All hosts" scope then shows them all. - await timeRendererStartupStep('fetch-repos', () => actions.fetchReposForAllHosts()) - // Why: project-groups/folder-workspaces read neither the repos store nor - // worktrees, so once repos land they can overlap the per-repo - // `git worktree list` fan-out (#7225) instead of queuing ahead of it — a - // slow remote host's 15s scope RPCs no longer block the scan. folders - // still follow project-groups (they read projectGroups); all settle - // before the hydrate steps below. - const projectScopeChain = (async () => { - await timeRendererStartupStep('fetch-project-groups', () => - actions.fetchProjectGroupsForAllHosts() - ) - await timeRendererStartupStep('fetch-folder-workspaces', () => - actions.fetchFolderWorkspacesForAllHosts() - ) - })() - // Why: worktrees + lineage both fan out `git worktree list` per repo on - // the main process. Running them concurrently lets it share one - // in-flight scan per repo instead of paying the process-spawn fan-out - // twice back-to-back — the dominant renderer-chain cost on Windows - // (issue #7225). Lineage only reads settings + its own slice, so it - // does not depend on the worktrees fetch having landed. - await Promise.all([ - projectScopeChain, - timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees()), - timeRendererStartupStep('fetch-worktree-lineage', () => actions.fetchWorktreeLineage()) - ]) - const persistedUI = await uiGetPromise + // Why: hydrate persisted UI immediately after ui.get() so first paint + // reflects saved view settings before the catalog scans below. ui.get() + // is awaited (not overlapped) because the hydrate must land before the + // local-first catalog/session steps run. + const persistedUI = await timeRendererStartupStep('ui-get', () => window.api.ui.get()) uiHydrated = timeRendererStartupSyncStep('hydrate-persisted-ui', () => hydratePersistedUIAfterStartupRead({ persistedUI, @@ -911,20 +903,49 @@ function App(): React.JSX.Element { hydratePersistedUI: actions.hydratePersistedUI }) ) + const startupRuntimeHostIds = await timeRendererStartupStep( + 'list-runtime-session-hosts', + listRuntimeSessionHostIdsForStartup + ) + // Why: first paint needs local data and persisted view settings, but + // saved remote runtimes can spend the full connect timeout. Load only + // the local catalog here; remotes refresh after hydration below. + await timeRendererStartupStep('fetch-repos-local', () => + actions.fetchReposForAllHosts({ remoteHosts: 'skip' }) + ) + await timeRendererStartupStep('fetch-project-groups-local', () => + actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' }) + ) + await timeRendererStartupStep('fetch-folder-workspaces-local', () => + actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) + ) + await timeRendererStartupStep('fetch-worktrees', () => + actions.fetchAllWorktrees({ hydrationPurge: 'defer' }) + ) // Why: runtime-owned worktree slices live in per-host partitions. - // Repos were fetched above, so the known runtime hosts are derivable - // here; merge their slices into the unified session the hydrators - // expect. An unreadable host partition is skipped (fail-soft). - const session = await timeRendererStartupStep('session-get', () => - fetchWorkspaceSessionFromHosts(window.api.session, useAppStore.getState().repos) + // Remote catalogs now load after first paint, so include saved runtime + // host ids from local settings to restore their persisted session slices + // without waiting on network reachability. Unreadable partitions skip. + const sessionRead = await timeRendererStartupStep('session-get', () => + fetchWorkspaceSessionWithRuntimeHostOwners( + window.api.session, + useAppStore.getState().repos, + startupRuntimeHostIds + ) ) await keybindingsPromise if (!cancelled) { + const sessionHydrationOptions = { + additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session) + } timeRendererStartupSyncStep('hydrate-session-stores', () => { - actions.hydrateWorkspaceSession(session) - actions.hydrateTabsSession(session) - actions.hydrateEditorSession(session) - actions.hydrateBrowserSession(session) + actions.hydrateWorkspaceSession(sessionRead.session, { + ...sessionHydrationOptions, + runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey + }) + actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions) + actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) + actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions) }) // Why: prune lastVisitedAtByWorktreeId entries whose worktrees // no longer exist. Must run AFTER hydration — before this point, @@ -952,7 +973,7 @@ function App(): React.JSX.Element { // tabs through pty.attach on the relay. Passphrase-protected targets // are deferred to tab focus to avoid stacking credential dialogs at // startup before the user has context. - const connectionIds = session.activeConnectionIdsAtShutdown ?? [] + const connectionIds = sessionRead.session.activeConnectionIdsAtShutdown ?? [] if (connectionIds.length > 0) { try { const SSH_RECONNECT_TIMEOUT_MS = 15_000 @@ -1064,6 +1085,23 @@ function App(): React.JSX.Element { logRendererStartupDiagnostic('startup-hydration-done', { durationMs: Math.round(performance.now() - startupStartedAt) }) + void (async () => { + try { + await timeRendererStartupStep('remote-catalog-refresh', async () => { + await actions.fetchReposForAllHosts() + await actions.fetchProjectGroupsForAllHosts() + await actions.fetchFolderWorkspacesForAllHosts() + }) + if (!cancelled) { + await timeRendererStartupStep('remote-worktree-refresh', async () => { + await actions.fetchAllWorktrees() + await actions.fetchWorktreeLineage() + }) + } + } catch (err) { + console.warn('Remote startup catalog refresh failed:', err) + } + })() } } catch (error) { // Why (issue #1158): previously this catch called hydrateWorkspaceSession @@ -1462,6 +1500,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) { @@ -2103,6 +2143,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. */} @@ -2112,6 +2153,25 @@ function App(): React.JSX.Element { {hasCustomTitleBar &&
} ) + const workspaceProfileSwitcher = + showProfileSwitcherInTopRight && + workspaceChromeActive && + leftTitlebarChromeLayout.shouldMount && + !stackedSidebarOpen ? ( +
+ +
+ ) : null return (
)} + {workspaceProfileSwitcher}
{shouldMountTerminalWorkbench ? (
{ - it('loads settings before repo and worktree hydration', () => { + it('hydrates persisted UI before local catalog and worktree hydration', () => { const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') const startupBlockStart = source.indexOf('void (async () => {') - const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise') + const startupBlockEnd = source.indexOf("timeRendererStartupStep('session-get'") const startupBlock = source.slice(startupBlockStart, startupBlockEnd) const settingsIndex = startupBlock.indexOf('actions.fetchSettings()') - expect(settingsIndex).toBeGreaterThanOrEqual(0) - expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchReposForAllHosts()')) - expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchAllWorktrees()')) - }) - - it('overlaps sidebar scope loads with worktree hydration before session hydration', () => { - const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') - const startupBlockStart = source.indexOf('void (async () => {') - const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise') - const startupBlock = source.slice(startupBlockStart, startupBlockEnd) - - const reposIndex = startupBlock.indexOf('actions.fetchReposForAllHosts()') - const scopeChainIndex = startupBlock.indexOf('const projectScopeChain = (async () => {') - const projectGroupsIndex = startupBlock.indexOf('actions.fetchProjectGroupsForAllHosts()') - const folderWorkspacesIndex = startupBlock.indexOf('actions.fetchFolderWorkspacesForAllHosts()') - const promiseAllIndex = startupBlock.indexOf('await Promise.all([') - const awaitedScopeChainIndex = startupBlock.indexOf('projectScopeChain', promiseAllIndex) - const worktreesIndex = startupBlock.indexOf('actions.fetchAllWorktrees()') + const uiGetIndex = startupBlock.indexOf("timeRendererStartupStep('ui-get'") + const hydrateUiIndex = startupBlock.indexOf( + "timeRendererStartupSyncStep('hydrate-persisted-ui'" + ) + const localReposIndex = startupBlock.indexOf( + "actions.fetchReposForAllHosts({ remoteHosts: 'skip' })" + ) + const localGroupsIndex = startupBlock.indexOf( + "actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })" + ) + const localFoldersIndex = startupBlock.indexOf( + "actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })" + ) + const localWorktreesIndex = startupBlock.indexOf( + "actions.fetchAllWorktrees({ hydrationPurge: 'defer' })" + ) const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()') - expect(reposIndex).toBeGreaterThanOrEqual(0) - expect(scopeChainIndex).toBeGreaterThan(reposIndex) - expect(projectGroupsIndex).toBeGreaterThan(scopeChainIndex) - expect(folderWorkspacesIndex).toBeGreaterThan(projectGroupsIndex) - expect(promiseAllIndex).toBeGreaterThan(scopeChainIndex) - expect(awaitedScopeChainIndex).toBeGreaterThan(promiseAllIndex) - expect(worktreesIndex).toBeGreaterThan(promiseAllIndex) - expect(lineageIndex).toBeGreaterThan(promiseAllIndex) + expect(settingsIndex).toBeGreaterThanOrEqual(0) + expect(startupBlockEnd).toBeGreaterThan(startupBlockStart) + expect(settingsIndex).toBeLessThan(uiGetIndex) + expect(uiGetIndex).toBeLessThan(hydrateUiIndex) + expect(hydrateUiIndex).toBeLessThan(localReposIndex) + expect(localReposIndex).toBeLessThan(localGroupsIndex) + expect(localGroupsIndex).toBeLessThan(localFoldersIndex) + expect(localFoldersIndex).toBeLessThan(localWorktreesIndex) + expect(lineageIndex).toBe(-1) + }) + + it('refreshes remote catalogs after startup hydration succeeds', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const hydrationDoneIndex = source.indexOf( + "logRendererStartupDiagnostic('startup-hydration-done'" + ) + const remoteCatalogIndex = source.indexOf("timeRendererStartupStep('remote-catalog-refresh'") + const remoteWorktreeIndex = source.indexOf("timeRendererStartupStep('remote-worktree-refresh'") + const lineageIndex = source.indexOf('actions.fetchWorktreeLineage()') + + expect(hydrationDoneIndex).toBeGreaterThanOrEqual(0) + expect(hydrationDoneIndex).toBeLessThan(remoteCatalogIndex) + expect(remoteCatalogIndex).toBeLessThan(remoteWorktreeIndex) + expect(remoteWorktreeIndex).toBeLessThan(lineageIndex) + expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain( + 'actions.fetchReposForAllHosts()' + ) + expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain( + 'actions.fetchProjectGroupsForAllHosts()' + ) + expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain( + 'actions.fetchFolderWorkspacesForAllHosts()' + ) }) it('waits for first-window startup services before terminal reconnect', () => { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 08f39a8b61c..44671b28a50 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; } @@ -618,6 +628,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 +957,11 @@ color: var(--muted-foreground); } +.titlebar-profile-switcher { + -webkit-app-region: no-drag; + flex-shrink: 0; +} + .editor-header { display: flex; align-items: center; @@ -1904,16 +1965,27 @@ text-underline-offset: 2px; } -/* Why: Monaco owns Peek References scrollables and auto-hides faded needed - vertical thumbs while the widget stays open; revalidate these classes on upgrade. */ +/* Why: Monaco's faded scrollbar uses the class `invisible`, which collides with + Tailwind's `.invisible` utility (visibility: hidden) — so also restore visibility. */ .monaco-editor .reference-zone-widget .monaco-scrollable-element > .scrollbar.vertical.invisible.fade { opacity: 1; + visibility: visible; 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/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index fa644f79df5..960703e8759 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -1,7 +1,9 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { useAppStore } from '../store' +import { selectCodexRestartInputs } from './codex-restart-chip-inputs' import { translate } from '@/i18n/i18n' import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus' import { @@ -73,8 +75,14 @@ export default function CodexRestartChip({ worktreeId: string }): React.JSX.Element | null { const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS) - const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) - const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId) + // Why: both of these maps churn on unrelated pty lifecycle events (ptyIdsByTabId + // on attach/detach; codexRestartNoticeByPtyId is re-spread even when empty on + // pty teardown), so subscribe to them only while a restart notice actually + // exists. Otherwise this per-worktree chip re-rendered on every pty event to + // compute "no notice → render nothing". See codex-restart-chip-inputs. + const { ptyIdsByTabId, codexRestartNoticeByPtyId } = useAppStore( + useShallow(selectCodexRestartInputs) + ) const staleWorktreePtyIds = useMemo( () => collectStalePtyIdsForTabs({ 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 ( -