diff --git a/.github/actions/install-signpath-module/action.yml b/.github/actions/install-signpath-module/action.yml new file mode 100644 index 00000000000..3f41daca3ab --- /dev/null +++ b/.github/actions/install-signpath-module/action.yml @@ -0,0 +1,197 @@ +name: Install SignPath PowerShell module +description: >- + Installs the SignPath PowerShell module (Get-SignedArtifact) from PSGallery, + falling back to a pinned, hash-verified nupkg from the gallery CDN when the + gallery's package API is unavailable. + +inputs: + fallback-version: + description: Module version fetched directly from the CDN when the gallery API is unreachable. + required: false + default: 4.4.6 + fallback-sha256: + description: >- + SHA-256 of the pinned fallback nupkg. The CDN path bypasses the gallery's own + package validation, so this hash is the only integrity check on that route. + required: false + default: 2487357a9a02c7d985baaf9ebd9158b4ce877316a2d9de3a6e9af1b263c0a32d + +runs: + using: composite + steps: + - name: Install SignPath PowerShell module + shell: pwsh + env: + SIGNPATH_FALLBACK_VERSION: ${{ inputs.fallback-version }} + SIGNPATH_FALLBACK_SHA256: ${{ inputs.fallback-sha256 }} + run: | + $ErrorActionPreference = 'Stop' + # Why: force TLS 1.2 so gallery downloads work on older hosted images. + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + # Why: on some hosted Windows images `Register-PSRepository -Default` + # fails inside the legacy nuget.exe provider with "Missing option value + # for: '-source'", so PSGallery is never registered and the install + # below dies with "No repository with the name 'PSGallery'". PSResourceGet + # (bundled with PowerShell 7.4+) has PSGallery registered by default and + # avoids that code path, so prefer it and fall back to PowerShellGet only + # when it is absent. + $useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue) + + try { + if ($useResourceGet) { + if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSResourceRepository -PSGallery -Trusted + } else { + Set-PSResourceRepository -Name PSGallery -Trusted + } + } else { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null + if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSRepository -Default -InstallationPolicy Trusted + } + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + } + } catch { + # Why: repository registration also talks to the gallery, so a gallery + # outage can fail here before a single install is attempted. The CDN + # fallback below does not need a registered repository, so keep going. + Write-Warning "PSGallery repository registration failed: $_" + } + + $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + $documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars) + $currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator | + Where-Object { + if ([string]::IsNullOrWhiteSpace($_)) { + $false + } else { + $candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars) + $candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase) + } + } | + Select-Object -First 1 + + if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) { + throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.' + } + + $signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath' + + function Test-SignPathModule { + Import-Module SignPath -ErrorAction Stop + Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop + } + + function Remove-SignPathModuleDirectory { + if (Test-Path -LiteralPath $signPathModulePath) { + Write-Warning "Removing current-user SignPath module directory: $signPathModulePath" + Remove-Item -LiteralPath $signPathModulePath -Recurse -Force + } + } + + $installed = $false + + for ($attempt = 1; $attempt -le 3; $attempt++) { + if ($attempt -eq 2) { + Start-Sleep -Seconds 15 + } elseif ($attempt -eq 3) { + Start-Sleep -Seconds 30 + } + + try { + if ($useResourceGet) { + 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 + } + Test-SignPathModule + $installed = $true + break + } catch { + Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_" + Remove-SignPathModuleDirectory + } + } + + # Why: the gallery's package API (OData search + repository metadata) sits + # behind Azure Front Door and has returned 403/502/504 for every install + # attempt during gallery incidents, which hard-failed the whole Windows + # release job. The CDN that serves the nupkg itself is a separate origin + # and stays up through those incidents, so fall back to a pinned version + # fetched straight from it. The hash pin is mandatory: this route skips the + # gallery's package validation, so an unexpected payload must fail loudly. + if (-not $installed) { + $version = $env:SIGNPATH_FALLBACK_VERSION + $expectedHash = $env:SIGNPATH_FALLBACK_SHA256 + Write-Warning "PSGallery install failed; falling back to pinned SignPath $version from the gallery CDN." + + $nupkg = Join-Path -Path $env:RUNNER_TEMP -ChildPath "signpath-$version.zip" + if (Test-Path -LiteralPath $nupkg) { + Remove-Item -LiteralPath $nupkg -Force + } + + # Why two URLs: the /api/v2/package route 302s to the CDN and can serve + # while the OData search endpoint is failing; the CDN URL is the same + # redirect target reached directly when the api host is down entirely. + $sources = @( + "https://www.powershellgallery.com/api/v2/package/SignPath/$version", + "https://cdn.powershellgallery.com/packages/signpath.$version.nupkg" + ) + + $downloaded = $false + foreach ($source in $sources) { + for ($attempt = 1; $attempt -le 3; $attempt++) { + if ($attempt -gt 1) { + Start-Sleep -Seconds (10 * $attempt) + } + + try { + Invoke-WebRequest -Uri $source -OutFile $nupkg -MaximumRedirection 5 -UseBasicParsing -ErrorAction Stop + $actualHash = (Get-FileHash -LiteralPath $nupkg -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash.ToUpperInvariant()) { + throw "SHA-256 mismatch for $source (expected $expectedHash, got $actualHash)." + } + $downloaded = $true + Write-Host "Downloaded and verified SignPath $version from $source" + break + } catch { + Write-Warning "SignPath CDN download attempt $attempt from $source failed: $_" + if (Test-Path -LiteralPath $nupkg) { + Remove-Item -LiteralPath $nupkg -Force + } + } + } + + if ($downloaded) { + break + } + } + + if (-not $downloaded) { + throw "Unable to install the SignPath PowerShell module: PSGallery installs failed and the pinned $version nupkg could not be downloaded from any source." + } + + Remove-SignPathModuleDirectory + # Why a version-named subdirectory: PowerShell only treats a nested folder + # as a side-by-side module version when the name matches the manifest's + # ModuleVersion, which is what makes `Import-Module SignPath` resolve it. + $versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version + New-Item -ItemType Directory -Path $versionRoot -Force | Out-Null + Expand-Archive -LiteralPath $nupkg -DestinationPath $versionRoot -Force + + # Why: strip nupkg packaging entries so only the module files remain. + foreach ($entry in @('_rels', 'package', '[Content_Types].xml', 'SignPath.nuspec')) { + $path = Join-Path -Path $versionRoot -ChildPath $entry + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Recurse -Force + } + } + + $manifest = Join-Path -Path $versionRoot -ChildPath 'SignPath.psd1' + if (-not (Test-Path -LiteralPath $manifest)) { + throw "Pinned SignPath nupkg did not contain SignPath.psd1 at $versionRoot." + } + + Test-SignPathModule + } diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4888dd6f3b3..2f0e19980a6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -273,6 +273,55 @@ jobs: - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked + package_windows: + name: package (windows) + runs-on: windows-2022 + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + 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-windows-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-windows- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build package inputs + run: pnpm run build:release + + - name: Prepare Electron native runtime + run: node config/scripts/ensure-native-runtime.mjs --runtime=electron + + - name: Package unpacked app + env: + ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' + run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir + + - name: Smoke packaged CLI + run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked + # Why: regression specs under tests/e2e/** used to merge green without ever # running — e2e.yml only fired on schedule/release (#10518). Path-filter so # ordinary PRs stay light; any E2E suite change still gets a full shard run. @@ -331,6 +380,7 @@ jobs: - shell_contracts - test - package + - package_windows runs-on: ubuntu-latest steps: @@ -350,6 +400,7 @@ jobs: SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} TEST: ${{ needs.test.result }} PACKAGE: ${{ needs.package.result }} + PACKAGE_WINDOWS: ${{ needs.package_windows.result }} run: | for result in \ "$STATIC_ANALYSIS" \ @@ -357,7 +408,8 @@ jobs: "$GIT_COMPATIBILITY" \ "$SHELL_CONTRACTS" \ "$TEST" \ - "$PACKAGE"; do + "$PACKAGE" \ + "$PACKAGE_WINDOWS"; do if [ "$result" != "success" ]; then exit 1 fi diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 7cb1e055e7e..1840514e5fd 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -72,6 +72,11 @@ jobs: tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }} should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }} latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }} + # Why: downstream SignPath Slack pings need the cut source (not only the + # new tag) so approvers know what they're signing and who cut it. + source_ref: ${{ steps.resolve.outputs.ref }} + source_sha: ${{ steps.resolve.outputs.sha }} + source_short_sha: ${{ steps.resolve.outputs.short_sha }} steps: # Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs): # this job runs with contents:write and secret scope, so avoid executing @@ -122,9 +127,12 @@ jobs: SERVER_URL: ${{ github.server_url }} run: | set -euo pipefail + input_ref="${INPUT_REF:-main}" sha="$(git rev-parse HEAD)" short_sha="$(git rev-parse --short=12 HEAD)" + echo "ref=$input_ref" >>"$GITHUB_OUTPUT" echo "sha=$sha" >>"$GITHUB_OUTPUT" + echo "short_sha=$short_sha" >>"$GITHUB_OUTPUT" # Why: only push the version-bump commit back to main when the # caller is releasing the exact tip of main. For any older or @@ -142,7 +150,6 @@ jobs: # `ref` input alone is ambiguous (branch vs tag vs SHA); for SHA # inputs it also hides the human-readable names operators need # when auditing RC cuts. - input_ref="${INPUT_REF:-main}" repo_url="${SERVER_URL}/${REPO}" branches="$( @@ -1176,82 +1183,7 @@ jobs: - name: Install SignPath PowerShell module if: matrix.platform == 'win' - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - # Why: force TLS 1.2 so gallery downloads work on older hosted images. - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 - - # Why: on some hosted Windows images `Register-PSRepository -Default` - # fails inside the legacy nuget.exe provider with "Missing option value - # for: '-source'", so PSGallery is never registered and the install - # below dies with "No repository with the name 'PSGallery'". PSResourceGet - # (bundled with PowerShell 7.4+) has PSGallery registered by default and - # avoids that code path, so prefer it and fall back to PowerShellGet only - # when it is absent. - $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 - } - } else { - Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null - if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { - Register-PSRepository -Default -InstallationPolicy Trusted - } - Set-PSRepository -Name PSGallery -InstallationPolicy Trusted - } - - $trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) - $documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars) - $currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator | - Where-Object { - if ([string]::IsNullOrWhiteSpace($_)) { - $false - } else { - $candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars) - $candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase) - } - } | - Select-Object -First 1 - - if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) { - throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.' - } - - $signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath' - - for ($attempt = 1; $attempt -le 3; $attempt++) { - if ($attempt -eq 2) { - Start-Sleep -Seconds 15 - } elseif ($attempt -eq 3) { - Start-Sleep -Seconds 30 - } - - try { - if ($useResourceGet) { - 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 - break - } catch { - if ($attempt -eq 3) { - throw - } - - Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_" - if (Test-Path -LiteralPath $signPathModulePath) { - Write-Warning "Removing current-user SignPath module directory before retry: $signPathModulePath" - Remove-Item -LiteralPath $signPathModulePath -Recurse -Force - } - } - } + uses: ./.github/actions/install-signpath-module # ── Windows inner-binary signing (issue #7785) ───────────────────── # Why: SignPath cannot deep-sign inside NSIS installers, so inner PE @@ -1339,6 +1271,12 @@ jobs: 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 }} + SOURCE_REF: ${{ needs.cut.outputs.source_ref }} + SOURCE_SHA: ${{ needs.cut.outputs.source_sha }} + SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }} + # Prefer triggering_actor so re-runs name who re-ran; fall back to actor. + CUT_BY: ${{ github.triggering_actor || github.actor }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) { @@ -1350,7 +1288,26 @@ jobs: $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>" + # Why: approvers need tag + source ref/commit + who cut, not only the tag. + $sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' } + $shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) { + $env:SOURCE_SHORT_SHA + } elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + $env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length)) + } else { + 'unknown' + } + $commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + "<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>" + } else { + "``$shortSha``" + } + $cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) { + "" + } else { + 'unknown' + } + $message = "Orca Windows release ``$($env:TAG)`` inner-binaries signing request (1 of 2) is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>" $payload = @{ text = $message blocks = @( @@ -1523,6 +1480,12 @@ jobs: SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }} SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }} TAG: ${{ needs.cut.outputs.tag }} + SOURCE_REF: ${{ needs.cut.outputs.source_ref }} + SOURCE_SHA: ${{ needs.cut.outputs.source_sha }} + SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }} + # Prefer triggering_actor so re-runs name who re-ran; fall back to actor. + CUT_BY: ${{ github.triggering_actor || github.actor }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }} run: | @@ -1537,7 +1500,26 @@ jobs: # 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>" + # Why: approvers need tag + source ref/commit + who cut, not only the tag. + $sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' } + $shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) { + $env:SOURCE_SHORT_SHA + } elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + $env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length)) + } else { + 'unknown' + } + $commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) { + "<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>" + } else { + "``$shortSha``" + } + $cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) { + "" + } else { + 'unknown' + } + $message = "Orca Windows release ``$($env:TAG)`` $stage is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>" $payload = @{ text = $message blocks = @( diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 73d66e466ec..508ac85575b 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -141,23 +141,7 @@ jobs: 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 + uses: ./.github/actions/install-signpath-module - name: Submit inner binaries signing request id: submit-inner-signing diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index a2d385bd698..e7a80704c14 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -14,6 +14,7 @@ const { const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs') const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') +const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' @@ -143,16 +144,12 @@ module.exports = { 'out/main/claude/**', 'out/main/claude-accounts/keychain.js', 'out/main/codex/**', - 'out/main/codex-cli/command.js', 'out/main/copilot/**', 'out/main/cursor/**', 'out/main/droid/**', 'out/main/gemini/**', 'out/main/grok/**', 'out/main/hermes/**', - 'out/main/ipc/local-agent-install-dir-detection.js', - 'out/main/ipc/tui-agent-detection-commands.js', - 'out/main/win32-utils.js', 'out/main/daemon-entry.js', 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', @@ -183,7 +180,7 @@ module.exports = { ) : join(context.appOutDir, 'resources') if (!existsSync(resourcesDir)) { - return + throw new Error(`Missing packaged resources directory: ${resourcesDir}`) } if (context.electronPlatformName === 'darwin') { const architectureByEnum = { 1: 'x64', 3: 'arm64' } @@ -213,7 +210,16 @@ module.exports = { // 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) { + const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4 + verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, { + executeCommands: canExecuteTargetArch + }) + if (!canExecuteTargetArch) { + console.log( + `[verify-skills-cli-runtime] skipped command probes on cross-arch slice (target ${context.arch}, host ${process.arch})` + ) + } + if (canExecuteTargetArch) { verifyPackagedDaemonEntryBoots(resourcesDir) } else { // Why: a cross-arch slice can't be booted by the host Node, but the diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 01efbe01e25..ca094dce75d 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3079,6 +3079,7 @@ "paired remote terminal ordinary parking", "paired remote terminal bounded scrollback restore", "stalled paired terminal stream recovery", + "snapshot-probe sequence gap recovery", "hidden remote worktree retention", "remote terminal reveal and input", "manual server disconnect" @@ -3087,13 +3088,13 @@ "providers": ["paired-runtime", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["paired-runtime"], - "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.", + "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", - "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", @@ -3101,6 +3102,7 @@ "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", @@ -3128,12 +3130,17 @@ "src/shared/remote-runtime-shared-control-connection.test.ts", "tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts", "tests/e2e/paired-remote-terminal-stall-recovery.spec.ts", + "tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts", "tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts", "tests/e2e/paired-remote-terminal-retention-memory.spec.ts", "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts", "tests/e2e/terminal-parked-memory.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts", + "assertions": ["replaces a stale paired stream when the PTY snapshot advanced"] + }, { "file": "tests/e2e/paired-remote-terminal-stall-recovery.spec.ts", "assertions": [ @@ -3231,6 +3238,9 @@ "restarts only the stream whose renderer delivery credit never settles", "probes then restarts a stream when an entered command receives no frames", "keeps a silent responsive stream after its authoritative snapshot probe", + "restarts a stream when the authoritative snapshot advanced without live output", + "establishes a probe baseline before recovering an unsequenced stream", + "keeps a responsive stream when its probe confirms zero output high-water", "classifies a capacity rejection followed by end as recoverable transport pressure" ] }, @@ -3245,6 +3255,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 17.3, + "summary": "The headed paired oracle dropped and acknowledged output only for the original client stream, proved the fixture consumed input while the host model advanced and the client stayed stale, then replaced the stream, repainted exact fixture output, preserved authoritative PTY identity and target-tab cardinality, and resumed live I/O." + }, { "date": "2026-07-29", "runner": "local", @@ -3328,7 +3347,7 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases." + "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases." }, "performanceBudget": { "required": true, @@ -3346,7 +3365,7 @@ "The scaled paired scenarios use six worktrees and ordinary-park five; they prove real host-backed eviction and buffer release, not a 100-worktree soak.", "Display-off reveal latency is a separate atlas and viewport-reflow class and is not covered by this gate.", "Historical field trace archives are still required to order replay-wedge events against renderer heartbeat loss and deduplicate archived records.", - "The live windows-issues incident proves a healthy host PTY and dead existing client stream, but lacks per-stream ACK counters; ACK starvation is reproduced, not yet proven as that field incident's unique boundary." + "The live windows-issues incident recorded two fast snapshot-probe successes while fresh output remained stale, but that release did not log the returned snapshot sequence; the deterministic sequence-gap oracle matches the observed false-health boundary without proving the field transport's lower-level drop mechanism." ], "demotionRule": "Keep experimental or demote to protection none if paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy host bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." }, @@ -6501,6 +6520,12 @@ "malformed frames, malformed transformed output, disposal, late parse, renderer delivery failure, and ACK transport failure release credit or close the owning stream without reordering output" ] }, + { + "file": "src/renderer/src/runtime/runtime-terminal-stream.test.ts", + "assertions": [ + "drops output only from the armed stream when its replacement reuses the stream ID" + ] + }, { "file": "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts", "assertions": [ diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index f90d6e49b5a..7d761c70af7 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -158,11 +158,7 @@ describe('electron-builder config', () => { 'out/package.json', 'out/cli/**', 'out/shared/**', - 'out/main/claude-accounts/keychain.js', - 'out/main/codex-cli/command.js', - 'out/main/ipc/local-agent-install-dir-detection.js', - 'out/main/ipc/tui-agent-detection-commands.js', - 'out/main/win32-utils.js' + 'out/main/claude-accounts/keychain.js' ]) ) }) @@ -486,6 +482,20 @@ describe('electron-builder config', () => { } }) + it('fails when the packaged resources directory is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-')) + try { + await expect( + electronBuilderConfig.afterPack({ + appOutDir: root, + electronPlatformName: 'win32' + }) + ).rejects.toThrow(/Missing packaged resources directory/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it.skipIf(process.platform === 'win32')( 'marks packaged Unix CLI launchers executable', async () => { @@ -509,6 +519,19 @@ describe('electron-builder config', () => { 'console.error("Usage: daemon-entry "); process.exit(1)\n', 'utf8' ) + const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli') + await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true }) + await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8') + await writeFile( + join(unpackedCliDir, 'index.js'), + [ + 'const args = process.argv.slice(2)', + "if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))", + "else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)", + 'else console.log(JSON.stringify({ executed: false }))' + ].join('\n'), + 'utf8' + ) await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) await electronBuilderConfig.afterPack({ diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 74ad245ec77..516aac21a23 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -297,116 +297,6 @@ describe('Electron runtime package contract', () => { expect(releaseMacWorkflowText).not.toContain('SIGNPATH_') }) - it('preflights SignPath module install before Windows signing side effects', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - const steps = parsedWorkflow.jobs.build.steps - const stepNames = steps.map((step) => step.name) - const installStepIndexes = stepNames.flatMap((name, index) => - name === 'Install SignPath PowerShell module' ? [index] : [] - ) - const buildIndex = stepNames.indexOf('Build Windows release artifacts') - const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime') - const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath') - const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath') - - expect(verifyNodePtyIndex).toBe(buildIndex + 1) - expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1]) - expect(installStepIndexes[0]).toBeLessThan(uploadIndex) - - expect(steps[verifyNodePtyIndex].run).toContain( - 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' - ) - expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll') - - const uploadThroughDownloadScript = steps - .slice(uploadIndex, downloadIndex + 1) - .map((step) => step.run ?? '') - .join('\n') - - expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath') - - const installStep = steps[installStepIndexes[0]] - const installRun = installStep.run - const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map( - ([, seconds]) => seconds - ) - - expect(installStep.if).toBe("matrix.platform == 'win'") - expect(installStep.shell).toBe('pwsh') - expect(installRun).toContain( - 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))' - ) - expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted') - expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') - expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/) - expect(installRun).toContain( - "$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'" - ) - expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/) - expect(sleepSeconds).toEqual(['15', '30']) - expect(installRun).toContain( - 'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop' - ) - expect(installRun).toContain('Import-Module SignPath') - expect(installRun).toContain( - 'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop' - ) - expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force') - expect(installRun).not.toContain('SignPath*') - expect(installRun.indexOf('if ($attempt -eq 3)')).toBeLessThan( - installRun.indexOf('Remove-Item -LiteralPath $signPathModulePath') - ) - expect(installRun).toMatch(/if \(\$attempt -eq 3\) {\s+throw\s+}/) - expect(installRun).not.toMatch(/throw\s+\$_/) - }) - - it('verifies Windows inner binary signatures fail-open before publishing', () => { - const releaseWorkflow = readFileSync( - join(projectDir, '.github/workflows/release-cut.yml'), - 'utf8' - ) - const parsedWorkflow = parse(releaseWorkflow) - 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 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(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', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 6c758a74fe1..dbece23cafd 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -177,7 +177,8 @@ describe('PR workflow parallelism', () => { 'git_compatibility', 'shell_contracts', 'test', - 'package' + 'package', + 'package_windows' ]) }) }) diff --git a/config/scripts/release-cut-signpath-slack.test.mjs b/config/scripts/release-cut-signpath-slack.test.mjs new file mode 100644 index 00000000000..0795ac8895a --- /dev/null +++ b/config/scripts/release-cut-signpath-slack.test.mjs @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') + +describe('release-cut SignPath Slack approval pings', () => { + it('includes cut source ref/commit and who triggered the cut', () => { + const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') + ) + + const cutOutputs = workflow.jobs.cut.outputs + expect(cutOutputs.source_ref).toContain('steps.resolve.outputs.ref') + expect(cutOutputs.source_sha).toContain('steps.resolve.outputs.sha') + expect(cutOutputs.source_short_sha).toContain('steps.resolve.outputs.short_sha') + + const steps = workflow.jobs.build.steps + const notifySteps = steps.filter( + (step) => + step.name === 'Notify Slack that inner-binary signing is waiting for approval' || + step.name === 'Notify Slack that Windows signing is waiting for approval' + ) + expect(notifySteps).toHaveLength(2) + + for (const step of notifySteps) { + expect(step.env.SOURCE_REF).toContain('needs.cut.outputs.source_ref') + expect(step.env.SOURCE_SHA).toContain('needs.cut.outputs.source_sha') + expect(step.env.SOURCE_SHORT_SHA).toContain('needs.cut.outputs.source_short_sha') + expect(step.env.CUT_BY).toMatch(/github\.(triggering_actor|actor)/) + expect(step.run).toContain('Source:') + expect(step.run).toContain('cut by') + } + }) +}) diff --git a/config/scripts/skills-cli-package-workflow.test.mjs b/config/scripts/skills-cli-package-workflow.test.mjs new file mode 100644 index 00000000000..72e6cf25829 --- /dev/null +++ b/config/scripts/skills-cli-package-workflow.test.mjs @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' + +const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) + +describe('packaged skills CLI PR gates', () => { + it('builds and executes the Windows packaged CLI', () => { + const job = workflow.jobs.package_windows + const buildStep = job.steps.find((step) => step.name === 'Build package inputs') + const prepareStep = job.steps.find((step) => step.name === 'Prepare Electron native runtime') + const packageStep = job.steps.find((step) => step.name === 'Package unpacked app') + const smokeStep = job.steps.find((step) => step.name === 'Smoke packaged CLI') + + expect(job['runs-on']).toBe('windows-2022') + expect(buildStep.run).toBe('pnpm run build:release') + expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron') + expect(packageStep.run).toContain('electron-builder') + expect(packageStep.run).toContain('--dir') + expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') + expect(smokeStep.run).toBe( + 'node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked' + ) + + const aggregateStep = workflow.jobs.verify.steps.find( + (step) => step.name === 'Require successful checks' + ) + expect(aggregateStep.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}') + expect(aggregateStep.run).toContain('"$PACKAGE_WINDOWS"') + }) +}) diff --git a/config/scripts/smoke-packaged-cli.mjs b/config/scripts/smoke-packaged-cli.mjs index 269118d867e..4cecf49ad4b 100644 --- a/config/scripts/smoke-packaged-cli.mjs +++ b/config/scripts/smoke-packaged-cli.mjs @@ -3,6 +3,7 @@ import { execFile } from 'node:child_process' import { tmpdir } from 'node:os' import { basename, join, resolve } from 'node:path' import { promisify } from 'node:util' +import assert from 'node:assert/strict' const execFileAsync = promisify(execFile) @@ -37,10 +38,41 @@ const copiedAppDir = join(tempRoot, basename(appDir)) try { await cp(appDir, copiedAppDir, { recursive: true, verbatimSymlinks: true }) const cliPath = getPackagedCliPath(copiedAppDir) - await execFileAsync(cliPath, ['--help'], { - env: { ...process.env, NODE_PATH: '' } - }) - console.log(`[packaged-cli-smoke] ${cliPath} --help succeeded outside the repo`) + const env = { ...process.env, NODE_PATH: '' } + delete env.ORCA_CLI_CWD + const run = (args) => + execFileAsync(cliPath, args, { + env, + killSignal: 'SIGKILL', + maxBuffer: 16 * 1024 * 1024, + timeout: 30_000 + }) + + await run(['--help']) + const list = JSON.parse((await run(['skills', 'list', '--json'])).stdout) + assert(list.topics.some((topic) => topic.name === 'orca-cli')) + assert.match((await run(['skills', 'get', 'orca-cli'])).stdout, /name: orca-cli/) + assert.match((await run(['skills', 'get', 'computer-use'])).stdout, /name: computer-use/) + const install = JSON.parse( + ( + await run([ + 'skills', + 'install', + '--skill', + 'orca-cli', + '--agent', + 'codex', + '--dry-run', + '--json' + ]) + ).stdout + ) + const update = JSON.parse( + (await run(['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json'])).stdout + ) + assert.equal(install.executed, false) + assert.equal(update.executed, false) + console.log(`[packaged-cli-smoke] help and skills commands passed via ${cliPath}`) } finally { await rm(tempRoot, { recursive: true, force: true }) } diff --git a/config/scripts/verify-skills-cli-runtime.cjs b/config/scripts/verify-skills-cli-runtime.cjs new file mode 100644 index 00000000000..0a76078d318 --- /dev/null +++ b/config/scripts/verify-skills-cli-runtime.cjs @@ -0,0 +1,229 @@ +const { existsSync, readFileSync, realpathSync } = require('node:fs') +const { builtinModules, createRequire, isBuiltin } = require('node:module') +const { dirname, isAbsolute, join, relative, resolve, sep } = require('node:path') +const { spawnSync } = require('node:child_process') +const ts = require('typescript-api') + +const BUILTINS = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])) +const CLI_COMMAND_TIMEOUT_MS = 30_000 + +function artifactPath(outDir, file) { + return relative(outDir, file).split(sep).join('/') +} + +function runtimeImportSpecifiers(source, file) { + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.JS + ) + const specifiers = [] + + function visit(node) { + if (ts.isCallExpression(node) && node.arguments.length > 0) { + const [argument] = node.arguments + const expression = node.expression + const isRequire = ts.isIdentifier(expression) && expression.text === 'require' + const isRequireResolve = + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.expression) && + expression.expression.text === 'require' && + expression.name.text === 'resolve' + const isDynamicImport = expression.kind === ts.SyntaxKind.ImportKeyword + + if ((isRequire || isRequireResolve || isDynamicImport) && ts.isStringLiteralLike(argument)) { + specifiers.push(argument.text) + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return specifiers +} + +function isOutsideRoot(root, target) { + const pathFromRoot = relative(root, target) + return isAbsolute(pathFromRoot) || pathFromRoot === '..' || pathFromRoot.startsWith(`..${sep}`) +} + +function isOptionalPackageImport(artifactRoot, importer, specifier) { + if (specifier.startsWith('.') || isAbsolute(specifier)) { + return false + } + const segments = specifier.split('/') + const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] + let directory = realpathSync(dirname(importer)) + + while (!isOutsideRoot(artifactRoot, directory)) { + const packageJson = join(directory, 'package.json') + if (existsSync(packageJson)) { + try { + const manifest = JSON.parse(readFileSync(packageJson, 'utf8')) + return ( + Object.hasOwn(manifest.optionalDependencies ?? {}, packageName) || + manifest.peerDependenciesMeta?.[packageName]?.optional === true + ) + } catch { + return false + } + } + if (directory === artifactRoot) { + break + } + directory = dirname(directory) + } + return false +} + +function resolveRuntimeImport(outDir, artifactRoot, importer, specifier) { + if (BUILTINS.has(specifier) || isBuiltin(specifier)) { + return null + } + let resolved + try { + resolved = createRequire(importer).resolve(specifier) + } catch (error) { + if (isOptionalPackageImport(artifactRoot, importer, specifier)) { + return null + } + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `[verify-skills-cli-runtime] missing runtime import "${specifier}" from ` + + `${artifactPath(outDir, importer)}: ${detail}` + ) + } + if (isOutsideRoot(artifactRoot, resolved)) { + throw new Error( + `[verify-skills-cli-runtime] import "${specifier}" from ` + + `${artifactPath(outDir, importer)} resolved outside ${artifactRoot}: ${resolved}` + ) + } + return resolved +} + +function collectRuntimeClosure(outDir, artifactRoot = dirname(outDir)) { + outDir = realpathSync(outDir) + artifactRoot = realpathSync(artifactRoot) + if (isOutsideRoot(artifactRoot, outDir)) { + throw new Error(`[verify-skills-cli-runtime] ${outDir} is outside ${artifactRoot}`) + } + const entry = resolve(outDir, 'cli', 'index.js') + if (!existsSync(entry)) { + throw new Error(`[verify-skills-cli-runtime] missing entry ${entry}`) + } + const pending = [entry] + const visited = new Set() + + while (pending.length > 0) { + const file = pending.pop() + if (!file || visited.has(file)) { + continue + } + visited.add(file) + const source = readFileSync(file, 'utf8') + for (const specifier of runtimeImportSpecifiers(source, file)) { + const resolved = resolveRuntimeImport(outDir, artifactRoot, file, specifier) + if (resolved && !isOutsideRoot(artifactRoot, resolved) && /\.(?:c|m)?js$/.test(resolved)) { + pending.push(resolved) + } + } + } + + return [...visited].sort() +} + +function runCli(outDir, args, timeoutMs = CLI_COMMAND_TIMEOUT_MS) { + const entry = resolve(outDir, 'cli', 'index.js') + const env = { ...process.env, NODE_PATH: '' } + delete env.ORCA_CLI_CWD + const result = spawnSync(process.execPath, [entry, ...args], { + cwd: dirname(outDir), + encoding: 'utf8', + env, + killSignal: 'SIGKILL', + maxBuffer: 16 * 1024 * 1024, + timeout: timeoutMs + }) + if (result.error || result.signal || result.status !== 0) { + const detail = [ + result.error?.message, + result.signal ? `terminated by ${result.signal}` : null, + result.stdout, + result.stderr + ] + .filter(Boolean) + .join('\n') + throw new Error( + `[verify-skills-cli-runtime] ${args.join(' ')} exited ${String(result.status)}\n${detail}` + ) + } + return result.stdout +} + +function parseJson(label, output) { + try { + return JSON.parse(output) + } catch { + throw new Error(`[verify-skills-cli-runtime] ${label} emitted invalid JSON:\n${output}`) + } +} + +function verifySkillsCliRuntime(outDir, artifactRoot = dirname(outDir), options = {}) { + const absoluteOutDir = resolve(outDir) + const closure = collectRuntimeClosure(absoluteOutDir, resolve(artifactRoot)) + if (options.executeCommands === false) { + return { closureFiles: closure.length, commands: 0 } + } + const list = parseJson('skills list', runCli(absoluteOutDir, ['skills', 'list', '--json'])) + const topicNames = new Set(list.topics?.map((topic) => topic.name)) + for (const topic of ['orca-cli', 'computer-use']) { + if (!topicNames.has(topic)) { + throw new Error(`[verify-skills-cli-runtime] skills list omitted ${topic}`) + } + const guide = runCli(absoluteOutDir, ['skills', 'get', topic]) + if (!guide.includes(`name: ${topic}`)) { + throw new Error(`[verify-skills-cli-runtime] skills get ${topic} returned the wrong guide`) + } + } + + const install = parseJson( + 'skills install --dry-run', + runCli(absoluteOutDir, [ + 'skills', + 'install', + '--skill', + 'orca-cli', + '--agent', + 'codex', + '--dry-run', + '--json' + ]) + ) + const update = parseJson( + 'skills update --dry-run', + runCli(absoluteOutDir, ['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json']) + ) + if (install.executed !== false || update.executed !== false) { + throw new Error('[verify-skills-cli-runtime] a dry-run reported execution') + } + + return { closureFiles: closure.length, commands: 5 } +} + +if (require.main === module) { + try { + const result = verifySkillsCliRuntime(process.argv[2] ?? 'out') + console.log( + `[verify-skills-cli-runtime] ${result.closureFiles} closure files and ` + + `${result.commands} commands passed` + ) + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } +} + +module.exports = { collectRuntimeClosure, runCli, verifySkillsCliRuntime } diff --git a/config/scripts/verify-skills-cli-runtime.test.mjs b/config/scripts/verify-skills-cli-runtime.test.mjs new file mode 100644 index 00000000000..f60adfd38ac --- /dev/null +++ b/config/scripts/verify-skills-cli-runtime.test.mjs @@ -0,0 +1,230 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + collectRuntimeClosure, + runCli, + verifySkillsCliRuntime +} = require('./verify-skills-cli-runtime.cjs') + +async function writeSkillsCliFixture(outDir, handlerSource) { + const cliDir = join(outDir, 'cli') + const handlerDir = join(cliDir, 'handlers') + await mkdir(handlerDir, { recursive: true }) + await writeFile(join(cliDir, 'index.js'), "require('./handlers/skills')\n", 'utf8') + await writeFile(join(handlerDir, 'skills.js'), handlerSource, 'utf8') +} + +describe('skills CLI runtime closure', () => { + it('runs after Electron composes the final output', async () => { + const packageJson = JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8') + ) + for (const scriptName of ['build:desktop', 'build:release']) { + const script = packageJson.scripts[scriptName] + expect(script.indexOf('build:electron-vite')).toBeLessThan( + script.indexOf('verify:built-skills-cli') + ) + } + }) + + it('reports the missing final-artifact import and its owner', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture(root, "require('../../main/codex-cli/command')\n") + + expect(() => collectRuntimeClosure(root)).toThrow( + /missing runtime import "\.\.\/\.\.\/main\/codex-cli\/command" from cli\/handlers\/skills\.js/ + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('walks static and dynamic relative imports', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const sharedDir = join(root, 'shared') + await mkdir(sharedDir, { recursive: true }) + await writeSkillsCliFixture( + root, + "require('../../shared/first.js'); import('../../shared/second.js')\n" + ) + await writeFile(join(sharedDir, 'first.js'), '', 'utf8') + await writeFile(join(sharedDir, 'second.js'), '', 'utf8') + + expect( + collectRuntimeClosure(root) + .map((file) => relative(realpathSync(root), file)) + .sort() + ).toEqual(['cli/handlers/skills.js', 'cli/index.js', 'shared/first.js', 'shared/second.js']) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('ignores import-shaped text in comments and strings', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture( + root, + [ + "// require('../../missing-comment.js')", + 'const message = "import(\'../../missing-string.js\')"', + "const template = `require.resolve('../../missing-template.js')`" + ].join('\n') + ) + + expect(collectRuntimeClosure(root)).toHaveLength(2) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('can inspect a cross-arch artifact without executing it', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture(root, '') + + expect(verifySkillsCliRuntime(root, undefined, { executeCommands: false })).toEqual({ + closureFiles: 2, + commands: 0 + }) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('bounds command execution time', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + await writeSkillsCliFixture(root, 'setInterval(() => {}, 1_000)\n') + + expect(() => runCli(root, [], 50)).toThrow(/ETIMEDOUT|terminated by SIGKILL/) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects bare imports resolved outside the artifact', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const externalPackageDir = join(root, 'node_modules', 'external-package') + await mkdir(externalPackageDir, { recursive: true }) + await writeSkillsCliFixture(outDir, "require('external-package')\n") + await writeFile( + join(externalPackageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8') + + expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow( + /external-package.*resolved outside/s + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects package dependencies resolved outside the artifact', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const packageDir = join(artifactRoot, 'node_modules', 'inside-package') + const externalPackageDir = join(root, 'node_modules', 'ancestor-dependency') + await writeSkillsCliFixture(outDir, "require('inside-package')\n") + await mkdir(packageDir, { recursive: true }) + await mkdir(externalPackageDir, { recursive: true }) + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(packageDir, 'index.js'), "require('ancestor-dependency')\n", 'utf8') + await writeFile( + join(externalPackageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8') + + expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow( + /ancestor-dependency.*resolved outside/s + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('allows absent dependencies declared optional by their package', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const packageDir = join(artifactRoot, 'node_modules', 'inside-package') + await writeSkillsCliFixture(outDir, "require('inside-package')\n") + await mkdir(packageDir, { recursive: true }) + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ + main: 'index.js', + peerDependencies: { 'optional-native': '*' }, + peerDependenciesMeta: { 'optional-native': { optional: true } } + }), + 'utf8' + ) + await writeFile( + join(packageDir, 'index.js'), + "try { require('optional-native') } catch {}\n", + 'utf8' + ) + + expect(collectRuntimeClosure(outDir, artifactRoot)).toHaveLength(3) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects optional dependencies resolved only outside the artifact', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-')) + try { + const artifactRoot = join(root, 'artifact') + const outDir = join(artifactRoot, 'out') + const packageDir = join(artifactRoot, 'node_modules', 'inside-package') + const externalPackageDir = join(root, 'node_modules', 'optional-native') + await writeSkillsCliFixture(outDir, "require('inside-package')\n") + await mkdir(packageDir, { recursive: true }) + await mkdir(externalPackageDir, { recursive: true }) + await writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ + main: 'index.js', + optionalDependencies: { 'optional-native': '*' } + }), + 'utf8' + ) + await writeFile(join(packageDir, 'index.js'), "require('optional-native')\n", 'utf8') + await writeFile( + join(externalPackageDir, 'package.json'), + JSON.stringify({ main: 'index.js' }), + 'utf8' + ) + await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8') + + expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow( + /optional-native.*resolved outside/s + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs new file mode 100644 index 00000000000..fc2087379f8 --- /dev/null +++ b/config/scripts/windows-signing-workflow-contract.test.mjs @@ -0,0 +1,161 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' + +const projectDir = resolve(import.meta.dirname, '../..') + +const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8')) + +describe('Windows signing workflow contract', () => { + it('preflights SignPath module install before Windows signing side effects', () => { + const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') + const steps = parsedWorkflow.jobs.build.steps + const stepNames = steps.map((step) => step.name) + const installStepIndexes = stepNames.flatMap((name, index) => + name === 'Install SignPath PowerShell module' ? [index] : [] + ) + const buildIndex = stepNames.indexOf('Build Windows release artifacts') + const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime') + const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath') + const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath') + + expect(verifyNodePtyIndex).toBe(buildIndex + 1) + expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1]) + expect(installStepIndexes[0]).toBeLessThan(uploadIndex) + + expect(steps[verifyNodePtyIndex].run).toContain( + 'dist/win-unpacked/resources/node_modules/node-pty/build/Release' + ) + expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll') + + const uploadThroughDownloadScript = steps + .slice(uploadIndex, downloadIndex + 1) + .map((step) => step.run ?? '') + .join('\n') + + expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath') + + const installStep = steps[installStepIndexes[0]] + + expect(installStep.if).toBe("matrix.platform == 'win'") + expect(installStep.uses).toBe('./.github/actions/install-signpath-module') + expect(installStep.run).toBeUndefined() + + const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml') + const actionStep = installAction.runs.steps[0] + const installRun = actionStep.run + const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map( + ([, seconds]) => seconds + ) + + expect(installAction.runs.using).toBe('composite') + expect(actionStep.shell).toBe('pwsh') + expect(installRun).toContain( + 'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))' + ) + expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted') + expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted') + expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/) + expect(installRun).toContain( + "$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'" + ) + expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/) + expect(sleepSeconds).toContain('15') + expect(sleepSeconds).toContain('30') + expect(installRun).toContain( + 'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop' + ) + expect(installRun).toContain('Import-Module SignPath -ErrorAction Stop') + expect(installRun).toContain( + 'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop' + ) + expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force') + expect(installRun).not.toContain('SignPath*') + expect(installRun).not.toMatch(/throw\s+\$_/) + }) + + it('falls back to a hash-pinned SignPath nupkg when the gallery API is down', () => { + const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml') + const installRun = installAction.runs.steps[0].run + + // Why: the gallery API 403s during Azure Front Door incidents while its CDN + // stays up, so a pinned nupkg is the fallback. The hash pin is the only + // integrity check on that route — losing it would let any payload install. + const { 'fallback-version': version, 'fallback-sha256': sha256 } = installAction.inputs + expect(version.default).toMatch(/^4\.\d+\.\d+$/) + expect(sha256.default).toMatch(/^[0-9a-f]{64}$/) + expect(installRun).toContain('Get-FileHash -LiteralPath $nupkg -Algorithm SHA256') + expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()') + expect(installRun).toContain('throw "SHA-256 mismatch for $source') + expect(installRun).toContain( + 'https://cdn.powershellgallery.com/packages/signpath.$version.nupkg' + ) + + // The module only resolves by name when the folder matches its ModuleVersion. + expect(installRun).toContain( + '$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version' + ) + // The fallback only runs after the gallery route is exhausted, and still + // fails the job when neither route produced a usable module. + expect(installRun.indexOf('$installed = $true')).toBeLessThan( + installRun.indexOf('if (-not $installed)') + ) + expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module') + }) + + it('shares one SignPath module install path between release and rehearsal', () => { + const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml') + const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name) + const installIndex = stepNames.indexOf('Install SignPath PowerShell module') + + // Why: the rehearsal exists to prove the real signing flow, so it must + // install the module exactly the way the release job does. + expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].uses).toBe( + './.github/actions/install-signpath-module' + ) + expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].run).toBeUndefined() + expect(installIndex).toBeLessThan( + stepNames.indexOf('Download signed inner binaries from SignPath') + ) + }) + + it('verifies Windows inner binary signatures fail-open before publishing', () => { + const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') + 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 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(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) + } + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 6bbfa8814f3..f4247281300 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,8 +12,6 @@ "../src/main/agent-hooks/local-agent-cli-presence.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", - "../src/main/ipc/local-agent-install-dir-detection.ts", - "../src/main/ipc/tui-agent-detection-commands.ts", "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index f4fb2195223..39756947028 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -11,6 +11,8 @@ "../src/main/ipc/worktree-linked-work-item-metadata.ts", "../src/main/ipc/worktree-metadata-merge.ts", "../src/main/ipc/worktree-path-comparison.ts", + "../src/main/wsl-distro-list-output.ts", + "../src/main/wsl-distro-retry.ts", "../src/main/wsl.ts" ], "compilerOptions": { diff --git a/docs/agent-status-over-wsl.md b/docs/agent-status-over-wsl.md index e5a6ac9aa3b..5ea8992eda1 100644 --- a/docs/agent-status-over-wsl.md +++ b/docs/agent-status-over-wsl.md @@ -286,9 +286,12 @@ Four independent review lenses over the full diff; confirmed findings fixed: Accepted gaps (reviewed, deliberately not addressed here): old version-namespaced install dirs accrete across upgrades (~200KB each); an outdated running daemon /p-translates the guest endpoint path until it restarts (hook scripts fall back to env -coords, which same-port binding keeps correct); `wslDistroCache` caches a transient -empty list for the app run (pre-existing semantics, now load-bearing for default-distro -resolution); default-distro resolution caches the first answer for the app run. +coords, which same-port binding keeps correct); default-distro resolution caches the +first non-empty answer for the app run. + +`wslDistroCache` no longer caches a transient empty list for the app run — an empty +result is re-probed after a short window, so a distro registered mid-session is picked +up. `isWslAvailable` likewise re-probes instead of latching a failure. ## 2026-07-09 round-4 external adversarial review diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 562d6e9db91..4a4cd6c8eba 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -260,16 +260,8 @@ export const electronViteConfig: UserConfig = { 'agent-hooks/managed-agent-hook-controls': resolve( 'src/main/agent-hooks/managed-agent-hook-controls.ts' ), - 'ipc/local-agent-install-dir-detection': resolve( - 'src/main/ipc/local-agent-install-dir-detection.ts' - ), - 'ipc/tui-agent-detection-commands': resolve( - 'src/main/ipc/tui-agent-detection-commands.ts' - ), - // Why: same rule — `orca account add` / `account list` import these. - 'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts'), - 'codex-cli/command': resolve('src/main/codex-cli/command.ts'), - 'win32-utils': resolve('src/main/win32-utils.ts') + // Why: account import mutates the user's macOS Keychain from the CLI. + 'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts') }, // Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers // consume these stable CommonJS paths. diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 918e6e92f93..cee492dfd09 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -14,6 +14,7 @@ const secureStoreMock = vi.hoisted(() => ({ })) const scheduleCleanupMock = vi.hoisted(() => vi.fn()) +const platformMock = vi.hoisted(() => ({ OS: 'ios' })) vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock @@ -25,7 +26,7 @@ vi.mock('expo-secure-store', () => ({ })) vi.mock('react-native', () => ({ - Platform: { OS: 'ios' } + Platform: platformMock })) vi.mock('./host-credential-cleanup', () => ({ @@ -70,6 +71,7 @@ describe('host-store list mutations', () => { beforeEach(() => { vi.clearAllMocks() resetHostStoreForTests() + platformMock.OS = 'ios' resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) @@ -179,6 +181,25 @@ describe('host-store list mutations', () => { expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() }) + it('keeps the normal iOS save on the existing default keychain service', async () => { + await saveHost({ + id: 'host-new', + name: 'New Host', + endpoint: 'ws://127.0.0.1:3', + publicKeyB64: 'key-new', + deviceToken: 'new-token', + lastConnected: 0 + }) + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-new', + 'new-token', + { + keychainAccessible: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' + } + ) + }) + it('commits the removal when credential cleanup scheduling rejects', async () => { scheduleCleanupMock.mockRejectedValue(new Error('intent storage unavailable')) @@ -328,3 +349,104 @@ describe('host-store list mutations', () => { expect(storedHostsRaw).toBe('{') }) }) + +describe('host-store pairing save after an Android encryption rejection', () => { + const NEW_HOST = { + id: 'host-1782629088232', + name: 'Host 1', + endpoint: 'ws://192.168.0.56:6769', + publicKeyB64: 'desktop-key', + lastConnected: 0, + deviceToken: 'device-token' + } + // Why: the verbatim Android rejection from #6600 — expo maps a null-message GeneralSecurityException to this. + const ENCRYPT_REJECTION = new Error( + "Could not encrypt the value for key 'orca.host-token.host-1782629088232' under keychain 'key_v1'. Caused by: unknown" + ) + const GENERATION_KEY = 'orca:pairing-keychain-generation' + let storedHostsRaw: string + let storedGenerationRaw: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetHostStoreForTests() + platformMock.OS = 'android' + resetMobileRelayHostOverlayStoreForTests() + scheduleCleanupMock.mockReset() + scheduleCleanupMock.mockResolvedValue(undefined) + storedHostsRaw = '[]' + storedGenerationRaw = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === HOSTS_STORAGE_KEY) { + return storedHostsRaw + } + // Why: the generation record is durable on device; a forgetful mock would fake a broken read path. + return key === GENERATION_KEY ? storedGenerationRaw : null + }) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === HOSTS_STORAGE_KEY) { + storedHostsRaw = raw + } else if (key === GENERATION_KEY) { + storedGenerationRaw = raw + } + }) + secureStoreMock.deleteItemAsync.mockResolvedValue(undefined) + secureStoreMock.getItemAsync.mockResolvedValue(null) + }) + + it('saves the host when the reported Android failure is alias-local (#6600)', async () => { + const written = new Map() + // Why: simulate the unverified alias-local case; no affected physical device was available. + secureStoreMock.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw ENCRYPT_REJECTION + } + written.set(options.keychainService, value) + } + ) + + await expect(saveHost(NEW_HOST)).resolves.toBeUndefined() + + expect(written.get('orca.pairing.v1')).toBe('device-token') + expect(JSON.parse(storedHostsRaw)).toEqual([ + { + id: NEW_HOST.id, + name: NEW_HOST.name, + endpoint: NEW_HOST.endpoint, + publicKeyB64: NEW_HOST.publicKeyB64, + lastConnected: NEW_HOST.lastConnected + } + ]) + }) + + it('still surfaces the failure when no keystore alias can accept the token', async () => { + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(saveHost(NEW_HOST)).rejects.toBe(ENCRYPT_REJECTION) + }) + + it('serves the rotated token to loadHosts so the saved host survives a relaunch', async () => { + const written = new Map() + secureStoreMock.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw ENCRYPT_REJECTION + } + written.set(options.keychainService, value) + } + ) + await saveHost(NEW_HOST) + // Why: a fresh process has no token cache, so the host list has to come back off the rotated alias. + resetHostStoreForTests() + secureStoreMock.getItemAsync.mockImplementation( + async (_key: string, options?: { keychainService?: string }) => + written.get(options?.keychainService) ?? null + ) + + const hosts = await loadHosts() + + expect(hosts).toHaveLength(1) + expect(hosts[0]!.deviceToken).toBe('device-token') + }) +}) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 61ed6742d12..9d16235481c 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -1,5 +1,4 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { HostProfileSchema, @@ -8,6 +7,12 @@ import { type StoredHostProfile } from './types' import { getNextHostNameFromHosts } from './host-names' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' import { retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup @@ -27,12 +32,6 @@ const STORAGE_KEY = 'orca:hosts' const TOKEN_KEY_PREFIX = 'orca.host-token.' const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' -// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off iCloud Keychain and out of backup restores onto another device. -// Reads/writes stay silent (no biometric prompt) because we don't request access control flags. -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function tokenKey(hostId: string): string { return `${TOKEN_KEY_PREFIX}${hostId}` } @@ -46,7 +45,7 @@ async function readDeviceToken(hostId: string): Promise { if (Platform.OS === 'web') { return AsyncStorage.getItem(webTokenKey(hostId)) } - return SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + return readPairingKeychainItem(tokenKey(hostId)) } async function writeDeviceToken(hostId: string, token: string): Promise { @@ -54,7 +53,7 @@ async function writeDeviceToken(hostId: string, token: string): Promise { await AsyncStorage.setItem(webTokenKey(hostId), token) return } - await SecureStore.setItemAsync(tokenKey(hostId), token, KEYCHAIN_OPTIONS) + await writePairingKeychainItem(tokenKey(hostId), token) } async function deleteDeviceToken(hostId: string): Promise { @@ -62,7 +61,7 @@ async function deleteDeviceToken(hostId: string): Promise { await AsyncStorage.removeItem(webTokenKey(hostId)) return } - await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(tokenKey(hostId)) } async function deleteHostCredentials(hostId: string): Promise { @@ -342,4 +341,5 @@ export function resetHostStoreForTests(): void { hostListMutation = Promise.resolve() tokenCache.clear() inflightLoad = null + resetPairingKeychainForTests() } diff --git a/mobile/src/transport/mobile-relay-credential-bundle.test.ts b/mobile/src/transport/mobile-relay-credential-bundle.test.ts index 4df9d44430b..185d5410980 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.test.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn() +})) const secureStore = vi.hoisted(() => ({ getItemAsync: vi.fn(), setItemAsync: vi.fn(), @@ -7,6 +11,7 @@ const secureStore = vi.hoisted(() => ({ })) const platform = vi.hoisted(() => ({ OS: 'ios' })) +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', ...secureStore @@ -20,6 +25,7 @@ import { writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { resetPairingKeychainForTests } from './pairing-keychain' const journal = { metadata: { @@ -62,8 +68,11 @@ describe('mobile relay credential bundle', () => { beforeEach(() => { vi.clearAllMocks() + resetPairingKeychainForTests() platform.OS = 'ios' stored = null + asyncStorage.getItem.mockResolvedValue(null) + asyncStorage.setItem.mockResolvedValue(undefined) secureStore.getItemAsync.mockImplementation(async () => stored) secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { stored = value diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts index 87f6f3826f4..a08fa052459 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -1,8 +1,12 @@ -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { z } from 'zod' import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) const ResumeCredentialSchema = z @@ -38,10 +42,6 @@ export const MobileRelayCredentialBundleSchema = z export type MobileRelayCredentialBundle = z.infer -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function credentialKey(hostId: string): string { return `orca.mobile-relay.credentials.${hostId}` } @@ -74,7 +74,7 @@ export async function readMobileRelayCredentialBundle( hostId: string ): Promise { requireNativeSecretStore() - const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) + const raw = await readPairingKeychainItem(credentialKey(hostId)) if (raw === null) { return null } @@ -91,18 +91,14 @@ export async function writeMobileRelayCredentialBundle( ): Promise { requireNativeSecretStore() const validated = MobileRelayCredentialBundleSchema.parse(bundle) - await SecureStore.setItemAsync( - credentialKey(validated.hostId), - JSON.stringify(validated), - KEYCHAIN_OPTIONS - ) + await writePairingKeychainItem(credentialKey(validated.hostId), JSON.stringify(validated)) } export async function deleteMobileRelayCredentialBundle(hostId: string): Promise { if (Platform.OS === 'web') { return } - await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(credentialKey(hostId)) } function requireNativeSecretStore(): void { diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts index 19775270b45..2493c9e78af 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -1,7 +1,11 @@ -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { z } from 'zod' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) @@ -17,10 +21,6 @@ export const MobileRelayDirectUpgradeJournalSchema = z export type MobileRelayDirectUpgradeJournal = z.infer -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function journalKey(hostId: string): string { return `orca.mobile-relay.direct-upgrade.${hostId}` } @@ -43,7 +43,7 @@ export async function readMobileRelayDirectUpgradeJournal( hostId: string ): Promise { requireNativeSecretStore() - const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) + const raw = await readPairingKeychainItem(journalKey(hostId)) if (!raw) { return null } @@ -60,18 +60,14 @@ export async function writeMobileRelayDirectUpgradeJournal( ): Promise { requireNativeSecretStore() const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) - await SecureStore.setItemAsync( - journalKey(parsed.hostId), - JSON.stringify(parsed), - KEYCHAIN_OPTIONS - ) + await writePairingKeychainItem(journalKey(parsed.hostId), JSON.stringify(parsed)) } export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise { if (Platform.OS === 'web') { return } - await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(journalKey(hostId)) } function encodeBase64Url(value: Uint8Array): string { diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts index 5790eb456aa..cf249a90506 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts @@ -31,6 +31,8 @@ import { import type { PairingOffer } from './types' const now = Date.UTC(2026, 6, 13) +const GENERATION_KEY = 'orca:pairing-keychain-generation' +const JOURNAL_PRESENCE_KEY = 'orca:pairing-keychain-presence:orca.mobile-relay.pairing-journal.v1' const offer = { v: 2, endpoint: 'ws://192.168.1.10:6768', @@ -51,6 +53,8 @@ const offer = { describe('mobile relay pairing journal store', () => { let metadataRaw: string | null let secretRaw: string | null + let generationRaw: string | null + let presenceRaw: string | null beforeEach(() => { vi.clearAllMocks() @@ -58,12 +62,32 @@ describe('mobile relay pairing journal store', () => { platform.OS = 'ios' metadataRaw = null secretRaw = null - asyncStorage.getItem.mockImplementation(async () => metadataRaw) - asyncStorage.setItem.mockImplementation(async (_key: string, value: string) => { - metadataRaw = value + generationRaw = null + presenceRaw = null + asyncStorage.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + return generationRaw + } + if (key === JOURNAL_PRESENCE_KEY) { + return presenceRaw + } + return metadataRaw }) - asyncStorage.removeItem.mockImplementation(async () => { - metadataRaw = null + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + if (key === GENERATION_KEY) { + generationRaw = value + } else if (key === JOURNAL_PRESENCE_KEY) { + presenceRaw = value + } else { + metadataRaw = value + } + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + if (key === JOURNAL_PRESENCE_KEY) { + presenceRaw = null + } else { + metadataRaw = null + } }) secureStore.getItemAsync.mockImplementation(async () => secretRaw) secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { @@ -94,6 +118,36 @@ describe('mobile relay pairing journal store', () => { await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal) }) + it('retries the journal under a distinct alias before relay pairing connects (#6600)', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + now, + randomBytes: (length) => new Uint8Array(length).fill(length) + }) + secureStore.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw new Error( + "Could not encrypt the value for key 'orca.mobile-relay.pairing-journal.v1' under keychain 'key_v1'. Caused by: unknown" + ) + } + secretRaw = value + } + ) + platform.OS = 'android' + + await expect(saveMobileRelayPairingJournal(journal)).resolves.toBeUndefined() + + expect(generationRaw).toBe('1') + expect(secureStore.setItemAsync).toHaveBeenLastCalledWith( + 'orca.mobile-relay.pairing-journal.v1', + expect.any(String), + expect.objectContaining({ keychainService: 'orca.pairing.v1' }) + ) + }) + it('records a provisional winner only for the active journal identity', async () => { const journal = createMobileRelayPairingJournal({ offer: offer as PairingOffer & { relay: NonNullable }, @@ -223,7 +277,7 @@ describe('mobile relay pairing journal store', () => { hostName: 'Red Panda', randomBytes: (length) => new Uint8Array(length).fill(12) }) - secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable')) + secureStore.setItemAsync.mockRejectedValue(new Error('keychain unavailable')) await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/keychain/) await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.ts index b7044830da5..d02ebc72284 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.ts @@ -1,5 +1,4 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { MobileRelayPairingJournalMetadataSchema, @@ -7,12 +6,15 @@ import { type MobileRelayPairingJournal, type MobileRelayPairingJournalMetadata } from './mobile-relay-pairing-journal' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1' const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1' -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} let journalMutation: Promise = Promise.resolve() export async function saveMobileRelayPairingJournal( @@ -39,7 +41,7 @@ export async function saveMobileRelayPairingJournal( // Why: metadata-first makes a crash before the keychain write recover as // an incomplete journal, never as an untracked bearer secret. await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata)) - await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, JSON.stringify(secrets), KEYCHAIN_OPTIONS) + await writePairingKeychainItem(JOURNAL_SECRET_KEY, JSON.stringify(secrets)) }) journalMutation = mutation.catch(() => {}) return mutation @@ -50,7 +52,7 @@ export async function loadMobileRelayPairingJournal(): Promise { const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) if (rawMetadata === null) { - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY).catch(() => {}) return null } const metadata = parseMetadata(rawMetadata) @@ -58,7 +60,7 @@ export async function loadMobileRelayPairingJournal(): Promise { // Why: metadata is the discoverable cleanup pointer; remove it before the // native secret so a second crash can only leave a self-cleaning orphan. await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY).catch(() => {}) } export async function updateMobileRelayPairingJournal( @@ -114,7 +116,7 @@ export async function clearMobileRelayPairingJournal(journalId: string): Promise throw new Error('stale mobile relay pairing journal') } await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY) }) journalMutation = mutation.catch(() => {}) return mutation @@ -147,4 +149,5 @@ function requireNativeSecretStore(): void { /** Test-only: drain the module mutation chain between cases. */ export function resetMobileRelayPairingJournalStoreForTests(): void { journalMutation = Promise.resolve() + resetPairingKeychainForTests() } diff --git a/mobile/src/transport/pairing-keychain.test.ts b/mobile/src/transport/pairing-keychain.test.ts new file mode 100644 index 00000000000..fda46d0a328 --- /dev/null +++ b/mobile/src/transport/pairing-keychain.test.ts @@ -0,0 +1,403 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +const secureStoreMock = vi.hoisted(() => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn() +})) +const platformMock = vi.hoisted(() => ({ OS: 'android' })) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock })) + +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + ...secureStoreMock +})) +vi.mock('react-native', () => ({ Platform: platformMock })) + +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' + +const GENERATION_KEY = 'orca:pairing-keychain-generation' +const TOKEN_KEY = 'orca.host-token.host-1782629088232' +const TOKEN_PRESENCE_KEY = `orca:pairing-keychain-presence:${TOKEN_KEY}` + +// Why: the exact Android failure from #6600 — expo maps a null-message GeneralSecurityException to this. +const ENCRYPT_REJECTION = new Error( + `Could not encrypt the value for key '${TOKEN_KEY}' under keychain 'key_v1'. Caused by: unknown` +) + +type Options = { keychainService?: string } | undefined + +function serviceOf(options: Options): string | undefined { + return options?.keychainService +} + +describe('pairing keychain', () => { + let generationRecord: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetPairingKeychainForTests() + platformMock.OS = 'android' + generationRecord = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => + key === GENERATION_KEY ? generationRecord : null + ) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + } + }) + secureStoreMock.setItemAsync.mockResolvedValue(undefined) + secureStoreMock.deleteItemAsync.mockResolvedValue(undefined) + secureStoreMock.getItemAsync.mockResolvedValue(null) + }) + + it('writes under the default keychain service so existing installs keep their tokens', async () => { + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + const [key, value, options] = secureStoreMock.setItemAsync.mock.calls[0]! + expect(key).toBe(TOKEN_KEY) + expect(value).toBe('token') + // Why: passing any keychainService would change the keystore alias and orphan every already-stored token. + expect(serviceOf(options as Options)).toBeUndefined() + expect(generationRecord).toBeNull() + }) + + it('recovers when the reported Android encryption failure is alias-local', async () => { + // Why: simulate the unverified alias-local case; no affected physical device was available. + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + const rotated = secureStoreMock.setItemAsync.mock.calls.at(-1)! + expect(serviceOf(rotated[2] as Options)).toBe('orca.pairing.v1') + expect(rotated[1]).toBe('token') + expect(generationRecord).toBe('1') + }) + + it('surfaces the original error when a rotated write also fails', async () => { + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + expect(generationRecord).toBe('1:pending') + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('retries an unconfirmed alias without consuming another generation', async () => { + generationRecord = '1:pending' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBe('1:pending') + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(serviceOf(secureStoreMock.setItemAsync.mock.calls[0]![2] as Options)).toBe( + 'orca.pairing.v1' + ) + }) + + it('confirms an unconfirmed alias after its first successful write', async () => { + generationRecord = '1:pending' + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(generationRecord).toBe('1') + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(serviceOf(secureStoreMock.setItemAsync.mock.calls[0]![2] as Options)).toBe( + 'orca.pairing.v1' + ) + }) + + it('does not rotate on an iOS keychain failure', async () => { + platformMock.OS = 'ios' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBeNull() + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + }) + + it('never stores a token under a generation it could not durably record', async () => { + // Why: reads only walk back from the recorded generation, so a token written under an + // unrecorded one is silently unreachable after a relaunch and the host vanishes. + asyncStorageMock.setItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + throw new Error('storage full') + } + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + const rotatedWrites = secureStoreMock.setItemAsync.mock.calls.filter( + (call) => serviceOf(call[2] as Options) !== undefined + ) + expect(rotatedWrites).toHaveLength(0) + expect(generationRecord).toBeNull() + }) + + it('records the rotation before storing the token so a relaunch can still find it', async () => { + const order: string[] = [] + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + order.push(`record:${raw}`) + } + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + const service = serviceOf(options) + if (service === undefined) { + throw ENCRYPT_REJECTION + } + order.push(`store:${service}`) + } + ) + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(order).toEqual(['record:1:pending', 'store:orca.pairing.v1', 'record:1']) + }) + + it('keeps a successful rotated write reachable when confirmation storage fails', async () => { + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key !== GENERATION_KEY) { + return + } + if (raw === '1') { + throw new Error('storage unavailable') + } + generationRecord = raw + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBe('1:pending') + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v1' ? 'token' : null + ) + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('token') + }) + + it('reads through the rotated service once a rotation has been committed', async () => { + generationRecord = '1' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v1' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + }) + + it('falls back to a retired service so rotation does not orphan a still-readable token', async () => { + generationRecord = '2' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('legacy-token') + // Why: probes must walk v2 -> v1 -> default rather than stopping at the current generation. + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(3) + }) + + it('does not return a stale older value when the current alias throws', async () => { + generationRecord = '1' + const currentError = new Error('Could not decrypt the value') + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => { + if (serviceOf(options) === 'orca.pairing.v1') { + throw currentError + } + return 'legacy-token' + }) + + await expect(readPairingKeychainItem(TOKEN_KEY)).rejects.toBe(currentError) + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1) + }) + + it('does not return a stale older value when a recorded current item decrypts as null', async () => { + generationRecord = '1' + let presenceRecord: string | null = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + return generationRecord + } + return key === TOKEN_PRESENCE_KEY ? presenceRecord : null + }) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + } + if (key === TOKEN_PRESENCE_KEY) { + presenceRecord = raw + } + }) + + await writePairingKeychainItem(TOKEN_KEY, 'rotated-token') + expect(presenceRecord).toBe('1') + expect(asyncStorageMock.setItem.mock.invocationCallOrder[0]).toBeLessThan( + secureStoreMock.setItemAsync.mock.invocationCallOrder[0]! + ) + + resetPairingKeychainForTests() + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).rejects.toThrow(/recorded generation/) + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1) + }) + + it('reads an older value while a newly recorded alias has no item yet', async () => { + generationRecord = '1:pending' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('legacy-token') + }) + + it('probes every bounded service when the generation record is temporarily unreadable', async () => { + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + throw new Error('storage unavailable') + } + return null + }) + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v2' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(7) + }) + + it('does not rotate from a guessed generation when the record is unreadable', async () => { + const storageError = new Error('storage unavailable') + asyncStorageMock.getItem.mockRejectedValueOnce(storageError) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(storageError) + + expect(asyncStorageMock.setItem).not.toHaveBeenCalled() + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + }) + + it('deletes the token under every generation so rotation cannot strand a live credential', async () => { + generationRecord = '2' + + await deletePairingKeychainItem(TOKEN_KEY) + + const services = secureStoreMock.deleteItemAsync.mock.calls.map((call) => + serviceOf(call[1] as Options) + ) + expect(services).toEqual(['orca.pairing.v2', 'orca.pairing.v1', undefined]) + expect(asyncStorageMock.removeItem).toHaveBeenCalledWith(TOKEN_PRESENCE_KEY) + }) + + it('reports a partial delete failure after attempting every generation', async () => { + generationRecord = '2' + const deleteError = new Error('delete failed') + secureStoreMock.deleteItemAsync.mockImplementation(async (_k: string, options: Options) => { + if (serviceOf(options) === 'orca.pairing.v2') { + throw deleteError + } + }) + + await expect(deletePairingKeychainItem(TOKEN_KEY)).rejects.toBe(deleteError) + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledTimes(3) + expect(asyncStorageMock.removeItem).not.toHaveBeenCalled() + }) + + it('serializes writes that share the global generation', async () => { + let releaseFirst!: () => void + const firstWrite = new Promise((resolve) => { + releaseFirst = resolve + }) + secureStoreMock.setItemAsync.mockImplementationOnce(async () => firstWrite) + + const first = writePairingKeychainItem(TOKEN_KEY, 'first') + const second = writePairingKeychainItem(`${TOKEN_KEY}.second`, 'second') + await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1)) + + releaseFirst() + await Promise.all([first, second]) + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(2) + }) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'probes rotated services when generation record %j is malformed', + async (raw) => { + generationRecord = raw + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v2' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + } + ) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'refuses to write from malformed generation record %j', + async (raw) => { + generationRecord = raw + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toThrow( + /generation record is invalid/ + ) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + } + ) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'deletes every bounded service when generation record %j is malformed', + async (raw) => { + generationRecord = raw + + await deletePairingKeychainItem(TOKEN_KEY) + + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledTimes(9) + } + ) + + it('stops rotating at the generation cap rather than probing unbounded services', async () => { + generationRecord = '8' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(generationRecord).toBe('8') + }) +}) diff --git a/mobile/src/transport/pairing-keychain.ts b/mobile/src/transport/pairing-keychain.ts new file mode 100644 index 00000000000..bf3b1810807 --- /dev/null +++ b/mobile/src/transport/pairing-keychain.ts @@ -0,0 +1,257 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import * as SecureStore from 'expo-secure-store' +import { Platform } from 'react-native' + +// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps pairing credentials off iCloud Keychain and backup restores. +const BASE_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} + +const GENERATION_STORAGE_KEY = 'orca:pairing-keychain-generation' +const PRESENCE_STORAGE_PREFIX = 'orca:pairing-keychain-presence:' +const SERVICE_PREFIX = 'orca.pairing.v' +// Why: every generation adds one read probe per miss; bound pathological recovery cost. +const MAX_GENERATION = 8 + +/** + * Why: expo-secure-store shares one Android keystore alias across every + * unauthenticated item under a keychain service. The #6600 rejection proves + * encryption reached expo's null-message GeneralSecurityException branch; a + * fresh service selects a distinct alias and can recover an alias-local fault. + */ +function serviceForGeneration(generation: number): string | undefined { + // Why: generation 0 must keep expo's default service so existing credentials stay readable. + return generation <= 0 ? undefined : `${SERVICE_PREFIX}${generation}` +} + +function optionsForGeneration(generation: number): SecureStore.SecureStoreOptions { + const keychainService = serviceForGeneration(generation) + return keychainService ? { ...BASE_OPTIONS, keychainService } : BASE_OPTIONS +} + +type LoadedGeneration = + | { generation: number; pending: boolean; reliable: true } + | { generation: 0; pending: false; reliable: false; error: unknown } + +type GenerationState = { generation: number; pending: boolean } +type PresenceChange = { storageKey: string; previousRaw: string | null } + +let cachedGeneration: GenerationState | null = null +let keychainMutation: Promise = Promise.resolve() + +function parseGeneration(raw: string | null): LoadedGeneration { + if (raw === null) { + return { generation: 0, pending: false, reliable: true } + } + const pending = raw.endsWith(':pending') + const generationRaw = pending ? raw.slice(0, -':pending'.length) : raw + const parsed = Number(generationRaw) + if ( + !Number.isInteger(parsed) || + parsed < 0 || + parsed > MAX_GENERATION || + (pending && parsed === 0) || + String(parsed) !== generationRaw + ) { + return { + generation: 0, + pending: false, + reliable: false, + error: new Error('pairing keychain generation record is invalid') + } + } + return { generation: parsed, pending, reliable: true } +} + +function parsePresenceGeneration(raw: string | null): number | null { + if (raw === null) { + return null + } + const parsed = Number(raw) + if ( + !Number.isInteger(parsed) || + parsed < 0 || + parsed > MAX_GENERATION || + String(parsed) !== raw + ) { + throw new Error('pairing keychain presence record is invalid') + } + return parsed +} + +function presenceStorageKey(key: string): string { + return `${PRESENCE_STORAGE_PREFIX}${key}` +} + +async function loadPresenceGeneration(key: string): Promise { + if (Platform.OS !== 'android') { + return null + } + return parsePresenceGeneration(await AsyncStorage.getItem(presenceStorageKey(key))) +} + +async function loadGeneration(): Promise { + if (cachedGeneration !== null) { + return { ...cachedGeneration, reliable: true } + } + try { + const loaded = parseGeneration(await AsyncStorage.getItem(GENERATION_STORAGE_KEY)) + if (loaded.reliable) { + cachedGeneration = { generation: loaded.generation, pending: loaded.pending } + } + return loaded + } catch (error) { + // Why: don't cache or rotate from a guess; a later read may recover the durable pointer. + return { generation: 0, pending: false, reliable: false, error } + } +} + +// Why: reads only walk back from the recorded generation, so persist before writing under it. +async function commitGeneration(generation: number, pending: boolean): Promise { + const raw = pending ? `${generation}:pending` : String(generation) + await AsyncStorage.setItem(GENERATION_STORAGE_KEY, raw) + cachedGeneration = { generation, pending } +} + +function enqueueKeychainMutation(operation: () => Promise): Promise { + const mutation = keychainMutation.then(operation) + keychainMutation = mutation.catch(() => {}) + return mutation +} + +async function preparePresenceWrite( + key: string, + generation: number +): Promise { + if (Platform.OS !== 'android') { + return null + } + // Why: Expo Android returns null for both absent and undecryptable entries. + const storageKey = presenceStorageKey(key) + const previousRaw = await AsyncStorage.getItem(storageKey) + parsePresenceGeneration(previousRaw) + await AsyncStorage.setItem(storageKey, String(generation)) + return { storageKey, previousRaw } +} + +async function restorePresence(change: PresenceChange | null): Promise { + if (!change) { + return + } + if (change.previousRaw === null) { + await AsyncStorage.removeItem(change.storageKey) + return + } + await AsyncStorage.setItem(change.storageKey, change.previousRaw) +} + +async function setItemAtGeneration(key: string, value: string, generation: number): Promise { + const presenceChange = await preparePresenceWrite(key, generation) + try { + await SecureStore.setItemAsync(key, value, optionsForGeneration(generation)) + } catch (error) { + // Why: a failed rollback leaves reads fail-closed at the attempted generation. + await restorePresence(presenceChange).catch(() => {}) + throw error + } +} + +export async function readPairingKeychainItem(key: string): Promise { + await keychainMutation + const presenceGeneration = await loadPresenceGeneration(key) + if (presenceGeneration !== null) { + const value = await SecureStore.getItemAsync(key, optionsForGeneration(presenceGeneration)) + if (value === null) { + throw new Error('pairing keychain item is unavailable at its recorded generation') + } + return value + } + const loaded = await loadGeneration() + const firstCandidate = loaded.reliable ? loaded.generation : MAX_GENERATION + for (let candidate = firstCandidate; candidate >= 0; candidate -= 1) { + const value = await SecureStore.getItemAsync(key, optionsForGeneration(candidate)) + if (value !== null) { + return value + } + } + return null +} + +async function writePairingKeychainItemImpl(key: string, value: string): Promise { + const loaded = await loadGeneration() + if (!loaded.reliable) { + throw loaded.error + } + const generation = loaded.generation + let firstError: unknown + try { + await setItemAtGeneration(key, value, generation) + if (loaded.pending) { + await commitGeneration(generation, false) + } + return + } catch (error) { + firstError = error + } + + // Why: retry an unconfirmed alias instead of exhausting generations on a device-wide failure. + if (loaded.pending || !isAndroidEncryptionFailure(firstError)) { + throw firstError + } + const rotated = generation + 1 + if (rotated > MAX_GENERATION) { + throw firstError + } + try { + await commitGeneration(rotated, true) + } catch { + throw firstError + } + try { + await setItemAtGeneration(key, value, rotated) + await commitGeneration(rotated, false) + } catch { + throw firstError + } +} + +function isAndroidEncryptionFailure(error: unknown): boolean { + if (Platform.OS !== 'android' || !error || typeof error !== 'object') { + return false + } + const message = 'message' in error ? error.message : null + return typeof message === 'string' && message.includes('Could not encrypt the value for key') +} + +export function writePairingKeychainItem(key: string, value: string): Promise { + return enqueueKeychainMutation(() => writePairingKeychainItemImpl(key, value)) +} + +async function deletePairingKeychainItemImpl(key: string): Promise { + const loaded = await loadGeneration() + const firstCandidate = loaded.reliable ? loaded.generation : MAX_GENERATION + let firstError: unknown + for (let candidate = firstCandidate; candidate >= 0; candidate -= 1) { + try { + await SecureStore.deleteItemAsync(key, optionsForGeneration(candidate)) + } catch (error) { + firstError ??= error + } + } + if (firstError !== undefined) { + throw firstError + } + if (Platform.OS === 'android') { + await AsyncStorage.removeItem(presenceStorageKey(key)) + } +} + +export function deletePairingKeychainItem(key: string): Promise { + return enqueueKeychainMutation(() => deletePairingKeychainItemImpl(key)) +} + +/** Test-only: drop cached generation and mutation state between cases. */ +export function resetPairingKeychainForTests(): void { + cachedGeneration = null + keychainMutation = Promise.resolve() +} diff --git a/package.json b/package.json index e7effafc8fc..4c1e8474517 100644 --- a/package.json +++ b/package.json @@ -58,19 +58,21 @@ "smoke:computer": "node config/scripts/computer-use-smoke.mjs", "verify:computer-native": "node config/scripts/verify-computer-native.mjs", "verify:cli-bin": "node config/scripts/verify-cli-bin.mjs", + "verify:built-skills-cli": "node config/scripts/verify-skills-cli-runtime.cjs out", "verify:localization-catalog": "node config/scripts/verify-localization-catalog.mjs", "sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix", "verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs", "verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check", "audit:localization": "node config/scripts/audit-localization-coverage.mjs", "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs", + "test:repro:skills-cli-runtime": "pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", "build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs", "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", "build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs", - "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer", + "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer", "build": "pnpm run build:desktop && pnpm run build:native", - "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer", + "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer", "postinstall": "node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "rebuild:node": "pnpm rebuild node-pty", diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index 97f7bee9c92..d39a5f33293 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -41,7 +41,7 @@ vi.mock('../../main/claude-accounts/keychain', () => ({ readActiveClaudeKeychainCredentialsStrict: readKeychainMock, writeActiveClaudeKeychainCredentials: writeKeychainMock })) -vi.mock('../../main/codex-cli/command', () => ({ +vi.mock('../../shared/node-cli-command-resolution', () => ({ getVersionManagerBinPaths: getVersionManagerBinPathsMock, resolveCliCommand: resolveCliCommandMock })) @@ -49,7 +49,7 @@ vi.mock('../../main/codex-cli/command', () => ({ import { ACCOUNT_HANDLERS } from './account' import type { HandlerContext } from '../dispatch' import type { RuntimeClient } from '../runtime-client' -import { getCmdExePath } from '../../main/win32-utils' +import { getCmdExePath } from '../../shared/windows-batch-spawn' import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' function successfulChild(): EventEmitter { diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 0a7a3212d66..0ad19aa240d 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -12,8 +12,11 @@ import { readActiveClaudeKeychainCredentialsStrict, writeActiveClaudeKeychainCredentials } from '../../main/claude-accounts/keychain' -import { getVersionManagerBinPaths, resolveCliCommand } from '../../main/codex-cli/command' -import { getSpawnArgsForWindows } from '../../main/win32-utils' +import { + getVersionManagerBinPaths, + resolveCliCommand +} from '../../shared/node-cli-command-resolution' +import { getSpawnArgsForWindows } from '../../shared/windows-batch-spawn' import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import type { RuntimeStatus } from '../../shared/runtime-types' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types' diff --git a/src/cli/handlers/skills.ts b/src/cli/handlers/skills.ts index 26e9ea5a734..7c911ea1751 100644 --- a/src/cli/handlers/skills.ts +++ b/src/cli/handlers/skills.ts @@ -3,14 +3,17 @@ import type { CommandHandler } from '../dispatch' import { RuntimeClientError } from '../runtime-client' import { delimiter, dirname } from 'node:path' import { getRepeatedStringFlag } from '../flags' -import { resolveCliCommand } from '../../main/codex-cli/command' -import { detectCommandsInInstallDirs } from '../../main/ipc/local-agent-install-dir-detection' +import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection' import { getTuiAgentDetectionProbeCommands, KNOWN_TUI_AGENT_DETECTION_COMMANDS, resolveDetectedTuiAgentIds -} from '../../main/ipc/tui-agent-detection-commands' -import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError } from '../../main/win32-utils' +} from '../../shared/tui-agent-detection-commands' +import { + getSpawnArgsForWindows, + UnsafeWindowsBatchArgumentsError +} from '../../shared/windows-batch-spawn' import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys' import { buildAgentFeatureSkillInstallArgs, diff --git a/src/cli/main-module-bundle-parity.test.ts b/src/cli/main-module-bundle-parity.test.ts index 8d00749f65b..a109fcb9fdb 100644 --- a/src/cli/main-module-bundle-parity.test.ts +++ b/src/cli/main-module-bundle-parity.test.ts @@ -41,8 +41,8 @@ function findElectronViteMainEntries(): Set { describe('CLI imports of main-process modules', () => { // Why: electron-vite cleans out/main and emits only its declared entries, so a // `src/main/*` module the CLI imports but the config omits is deleted by the - // build that runs after `build:cli` — the command then dies at require time - // with "Cannot find module". Nothing else catches this before packaging. + // build that runs after `build:cli` — keep source-level feedback ahead of the + // final-artifact runtime verifier. it('has an electron-vite entry for every main module the CLI imports', () => { const entries = findElectronViteMainEntries() const missing = findMainImports().filter(({ module }) => !entries.has(module)) @@ -52,7 +52,7 @@ describe('CLI imports of main-process modules', () => { it('finds the imports it is meant to guard', () => { // Why: a broken matcher would make the guard above vacuously pass. - expect(findMainImports().length).toBeGreaterThanOrEqual(4) - expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(4) + expect(findMainImports().length).toBeGreaterThanOrEqual(2) + expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2) }) }) diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 1cdee5aad4b..11caa30d704 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { delimiter } from 'node:path' -import type * as CodexCliCommandModule from '../main/codex-cli/command' +import type * as CodexCliCommandModule from '../shared/node-cli-command-resolution' const { detectCommandsMock, @@ -19,12 +19,12 @@ const { // Why: agent detection probes the real machine, so pin it or every install // assertion depends on what the test runner happens to have installed. -vi.mock('../main/ipc/local-agent-install-dir-detection', () => ({ +vi.mock('../shared/local-agent-install-dir-detection', () => ({ detectCommandsInInstallDirs: detectCommandsMock })) // Why: override only the npx lookup so the real Windows .cmd rail still runs. -vi.mock('../main/codex-cli/command', async (importOriginal) => ({ +vi.mock('../shared/node-cli-command-resolution', async (importOriginal) => ({ ...(await importOriginal()), resolveCliCommand: resolveCliCommandMock })) diff --git a/src/main/codex-cli/command.ts b/src/main/codex-cli/command.ts index 177b7e0ea9c..61e08eb457e 100644 --- a/src/main/codex-cli/command.ts +++ b/src/main/codex-cli/command.ts @@ -1,226 +1 @@ -import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs' -import { homedir } from 'node:os' -import { delimiter, dirname, join } from 'node:path' - -type ResolveCommandOptions = { - pathEnv?: string | null - platform?: NodeJS.Platform - homePath?: string -} - -function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] { - if (platform === 'win32') { - return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName] - } - - return [commandName] -} - -function splitPath(pathEnv: string | null | undefined): string[] { - if (!pathEnv) { - return [] - } - - return pathEnv - .split(delimiter) - .map((entry) => entry.trim()) - .filter(Boolean) -} - -function parseVersionSegment(raw: string): number[] { - return raw - .replace(/^v/i, '') - .split('.') - .map((segment) => Number.parseInt(segment, 10)) - .map((segment) => (Number.isFinite(segment) ? segment : 0)) -} - -function compareVersionDesc(left: string, right: string): number { - const leftParts = parseVersionSegment(left) - const rightParts = parseVersionSegment(right) - const length = Math.max(leftParts.length, rightParts.length) - - for (let index = 0; index < length; index += 1) { - const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0) - if (delta !== 0) { - return delta - } - } - - return right.localeCompare(left) -} - -function findFirstExecutable( - platform: NodeJS.Platform, - directories: string[], - executableNames: string[] -): string | null { - for (const directory of directories) { - for (const executableName of executableNames) { - const candidate = join(directory, executableName) - if (isRunnableCommand(platform, candidate)) { - return candidate - } - } - } - - return null -} - -function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean { - try { - const stats = statSync(candidate) - if (!stats.isFile()) { - return false - } - if (platform === 'win32') { - return true - } - // Why: GUI fallback probing should skip placeholders/directories so spawn - // can continue to a runnable CLI instead of failing later with EACCES/EISDIR. - accessSync(candidate, constants.X_OK) - return true - } catch { - return false - } -} - -function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] { - const directories = [ - join(homePath, '.volta', 'bin'), - join(homePath, '.asdf', 'shims'), - join(homePath, '.fnm', 'aliases', 'default', 'bin'), - // Why: mise (formerly rtx) exposes managed tool binaries via a shims - // directory, similar to asdf. Without this, users who installed node - // or CLI tools through mise can't be found by the fallback probe. - join(homePath, '.local', 'share', 'mise', 'shims') - ] - - if (platform === 'win32') { - // Why: Anthropic's native Windows installer places claude.exe here, and - // GUI-launched Orca may not inherit the user's PATH entry for it. - directories.push(join(homePath, '.local', 'bin')) - directories.push(join(homePath, 'AppData', 'Roaming', 'npm')) - directories.push(join(homePath, 'AppData', 'Local', 'pnpm')) - directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin')) - } else { - directories.push(join(homePath, '.local', 'bin')) - // Why: pnpm uses platform-specific global bin directories that differ from - // npm's ~/.local/bin. macOS follows the ~/Library convention while Linux - // uses the XDG-compatible ~/.local/share path. Without these, users who - // installed via `pnpm add -g` can't be found by the fallback probe. - if (platform === 'darwin') { - directories.push(join(homePath, 'Library', 'pnpm')) - } else { - directories.push(join(homePath, '.local', 'share', 'pnpm')) - } - directories.push(join(homePath, '.yarn', 'bin')) - } - - // Why: bun uses ~/.bun/bin on all platforms for globally installed packages. - directories.push(join(homePath, '.bun', 'bin')) - - return directories -} - -function getNvmVersionDirectories(homePath: string): string[] { - const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node') - if (!existsSync(nvmVersionsDir)) { - return [] - } - - return readdirSync(nvmVersionsDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(compareVersionDesc) - .map((entry) => join(nvmVersionsDir, entry, 'bin')) -} - -function getVersionManagerDirectories( - platform: NodeJS.Platform, - homePath: string, - executableNames: string[] -): string[] { - const directories = getBaseVersionManagerDirectories(platform, homePath) - - // Why: GUI-launched Electron apps do not inherit shell init from nvm, so - // command resolution probes the newest installed Node versions explicitly. - const firstNvmMatch = findFirstExecutable( - platform, - getNvmVersionDirectories(homePath), - executableNames - ) - if (firstNvmMatch) { - directories.unshift(dirname(firstNvmMatch)) - } - - return directories -} - -export function resolveCliCommand( - commandName: string, - options: ResolveCommandOptions = {} -): string { - const platform = options.platform ?? process.platform - const executableNames = getExecutableNames(platform, commandName) - const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null - const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames) - if (pathCandidate) { - return pathCandidate - } - - const homePath = options.homePath ?? homedir() - const versionManagerCandidate = findFirstExecutable( - platform, - getVersionManagerDirectories(platform, homePath, executableNames), - executableNames - ) - return versionManagerCandidate ?? commandName -} - -export function resolveCliCommands( - commandNames: readonly string[], - options: ResolveCommandOptions = {} -): Map { - const platform = options.platform ?? process.platform - const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null - const pathDirectories = splitPath(pathEnv) - const homePath = options.homePath ?? homedir() - // Why: agent detection probes many CLIs at once; compute expensive install - // directories, especially nvm versions, once per detection pass. - const installDirectories = [ - ...getNvmVersionDirectories(homePath), - ...getBaseVersionManagerDirectories(platform, homePath) - ] - const resolved = new Map() - - for (const commandName of new Set(commandNames)) { - const executableNames = getExecutableNames(platform, commandName) - const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames) - const installCandidate = - pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames) - resolved.set(commandName, installCandidate ?? commandName) - } - - return resolved -} - -export function resolveCodexCommand(options: ResolveCommandOptions = {}): string { - return resolveCliCommand('codex', options) -} - -export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string { - return resolveCliCommand('claude', options) -} - -// Why: GUI-launched Electron apps inherit a minimal PATH that excludes Node -// version manager directories. CLI tools like codex/claude are Node scripts -// with #!/usr/bin/env node shebangs — they need `node` in PATH to execute, -// not just to be *found*. This function returns the version manager bin paths -// so the caller can augment process.env.PATH at startup. -export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] { - const platform = options.platform ?? process.platform - const homePath = options.homePath ?? homedir() - const nodeNames = getExecutableNames(platform, 'node') - return getVersionManagerDirectories(platform, homePath, nodeNames) -} +export * from '../../shared/node-cli-command-resolution' diff --git a/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts b/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts index 4a23aaa68ee..e8c8fe8ee21 100644 --- a/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts +++ b/src/main/crash-reporting/gpu-fallback-restart-prompt.test.ts @@ -22,13 +22,13 @@ describe('promptForGpuFallbackRestart', () => { await expect(promptForGpuFallbackRestart(parentWindow as never)).resolves.toBe('restart') expect(showMessageBoxMock).toHaveBeenCalledWith(parentWindow, { type: 'warning', - buttons: ['Restart with Software Rendering', 'Keep Running'], + buttons: ['Restart in Safe Graphics Mode', 'Keep Running'], defaultId: 0, cancelId: 1, - title: 'Restart Orca?', + title: 'Restart Orca in Safe Graphics Mode?', message: "Orca's graphics process has crashed repeatedly.", detail: - 'Restart to switch to software rendering and reduce the chance of the app window crashing. If you keep running, Orca may become unstable.' + 'Safe graphics mode disables hardware acceleration and WebGL for this Orca version. Terminals and 3D content may render more slowly. Keep Running leaves graphics settings unchanged.' }) }) diff --git a/src/main/crash-reporting/gpu-fallback-restart-prompt.ts b/src/main/crash-reporting/gpu-fallback-restart-prompt.ts index 52235faecf3..3c53089867b 100644 --- a/src/main/crash-reporting/gpu-fallback-restart-prompt.ts +++ b/src/main/crash-reporting/gpu-fallback-restart-prompt.ts @@ -4,13 +4,13 @@ export type GpuFallbackRestartDecision = 'restart' | 'continue' const GPU_FALLBACK_RESTART_OPTIONS: MessageBoxOptions = { type: 'warning', - buttons: ['Restart with Software Rendering', 'Keep Running'], + buttons: ['Restart in Safe Graphics Mode', 'Keep Running'], defaultId: 0, cancelId: 1, - title: 'Restart Orca?', + title: 'Restart Orca in Safe Graphics Mode?', message: "Orca's graphics process has crashed repeatedly.", detail: - 'Restart to switch to software rendering and reduce the chance of the app window crashing. If you keep running, Orca may become unstable.' + 'Safe graphics mode disables hardware acceleration and WebGL for this Orca version. Terminals and 3D content may render more slowly. Keep Running leaves graphics settings unchanged.' } export async function promptForGpuFallbackRestart( diff --git a/src/main/index.ts b/src/main/index.ts index 5dbd92065f0..59eeccbbad7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -127,6 +127,7 @@ import { type GpuFallbackEnvironment, type WindowsGpuFallbackEnvironment } from './startup/gpu-fallback-marker' +import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, @@ -1518,10 +1519,13 @@ function maybeApplyGpuFallbackForThisLaunch(): void { return } app.disableHardwareAcceleration() - app.commandLine.appendSwitch('disable-gpu') + const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) gpuFallbackActiveThisLaunch = true + // Why: with no GPU child left, child-process-gone can't report a GPU fault, so + // name the applied switches in the trail any later crash report carries. recordCrashBreadcrumb('gpu_fallback_applied', { - crashesInWindow: marker.crashesInWindow + crashesInWindow: marker.crashesInWindow, + switches: appliedSwitches.join(',') }) } @@ -1541,23 +1545,6 @@ async function handleGpuChildCrash(reason: string, exitCode: number | null): Pro crashesInWindow: result.crashesInWindow }) const engagedAt = Date.now() - const environment = getWindowsGpuFallbackEnvironment() - if (!environment) { - return - } - try { - writeGpuFallbackMarker( - app.getPath('userData'), - { - engagedAt, - crashesInWindow: result.crashesInWindow - }, - environment - ) - } catch (error) { - console.warn('[gpu-fallback] failed to persist marker:', error) - return - } const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined let restartDecision: GpuFallbackRestartDecision try { @@ -1578,6 +1565,23 @@ async function handleGpuChildCrash(reason: string, exitCode: number | null): Pro recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData) return } + const environment = getWindowsGpuFallbackEnvironment() + if (!environment) { + return + } + try { + writeGpuFallbackMarker( + app.getPath('userData'), + { + engagedAt, + crashesInWindow: result.crashesInWindow + }, + environment + ) + } catch (error) { + console.warn('[gpu-fallback] failed to persist marker:', error) + return + } isQuitting = true relaunchApp('gpu-fallback', fallbackData) // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. diff --git a/src/main/ipc/local-agent-install-dir-detection.ts b/src/main/ipc/local-agent-install-dir-detection.ts index 1364b5bc7d7..7493affdb47 100644 --- a/src/main/ipc/local-agent-install-dir-detection.ts +++ b/src/main/ipc/local-agent-install-dir-detection.ts @@ -1,18 +1 @@ -import path from 'node:path' -import { resolveCliCommands } from '../codex-cli/command' - -// Why: local agent detection may run before shell-PATH hydration, but the -// fallback must stay bounded because it runs on the main process. -export function detectCommandsInInstallDirs(commands: readonly string[]): Set { - if (commands.length === 0) { - return new Set() - } - try { - const resolvedCommands = resolveCliCommands(commands) - return new Set( - commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command)) - ) - } catch { - return new Set() - } -} +export { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection' diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index fead0c97c86..c959bda20a1 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -54,7 +54,7 @@ vi.mock('../startup/hydrate-shell-path', () => ({ mergePathSegments: mergePathSegmentsMock })) -vi.mock('../codex-cli/command', () => ({ +vi.mock('../../shared/node-cli-command-resolution', () => ({ resolveCliCommands: resolveCliCommandsMock })) diff --git a/src/main/ipc/ssh-pty-output-model-migration.test.ts b/src/main/ipc/ssh-pty-output-model-migration.test.ts index e8007a0c274..ae56e2e45fc 100644 --- a/src/main/ipc/ssh-pty-output-model-migration.test.ts +++ b/src/main/ipc/ssh-pty-output-model-migration.test.ts @@ -1,10 +1,93 @@ import { describe, expect, it, vi } from 'vitest' +import type { SshPtyOutputDataEvent } from './ssh-pty-output-intake' import { createSshPtyOutputIntakeHarness as createHarness, sshPtyOutputEvent as event } from './ssh-pty-output-intake-test-harness' +// Production-shaped ids: bare relay id on the wire side, prefixed app id in intake. +const APP_ID = 'ssh:conn@@pty-1' +const prodEvent = (overrides: Partial = {}): SshPtyOutputDataEvent => + event({ + id: APP_ID, + source: { + relayPtyId: 'pty-1', + spanId: 'span-a', + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'delivery-token-1', + sourceStartSu: 0, + sourceEndSu: 4 + }, + ...overrides + }) + describe('SshPtyOutputModelMigration', () => { + it('fences an in-flight admission when relay and app pty ids differ', async () => { + const harness = createHarness() + const first = harness.intake.acceptData(prodEvent({ data: 'aaaa' })) + harness.completions[0]!.resolve() + await first + const second = harness.intake.acceptData( + prodEvent({ + data: 'bbbb', + source: { + relayPtyId: 'pty-1', + spanId: 'span-b', + clientGeneration: 3, + ownerGeneration: 4, + deliveryToken: 'delivery-token-1', + sourceStartSu: 4, + sourceEndSu: 8 + } + }) + ) + + const migration = harness.intake.beginGenerationMigration(1) + expect([...migration.byPty.keys()]).toEqual([APP_ID]) + const result = migration.byPty.get(APP_ID) + await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toBe('pending') + await expect(Promise.race([migration.completion, Promise.resolve('pending')])).resolves.toBe( + 'pending' + ) + expect(harness.intake.getAcceptedSourceCheckpoints(1)[0]).toMatchObject({ + id: APP_ID, + acceptedSourceEndSu: 4 + }) + + harness.completions[1]!.resolve() + await expect(second).resolves.toMatchObject({ sequence: 8 }) + await expect(result).resolves.toMatchObject({ + status: 'settled', + checkpoint: { id: APP_ID, acceptedSourceEndSu: 8 } + }) + await migration.completion + }) + + it('targets the real pty on migration timeout with production-shaped ids', async () => { + vi.useFakeTimers() + try { + const resetModelForMigration = vi.fn() + const harness = createHarness({ resetModelForMigration }) + const receipt = harness.intake.acceptData(prodEvent()) + const migration = harness.intake.beginGenerationMigration(1, 10_000) + + await vi.advanceTimersByTimeAsync(10_000) + + await expect(migration.byPty.get(APP_ID)).resolves.toEqual({ + status: 'checkpoint-unavailable', + reason: 'timeout' + }) + await expect(receipt).rejects.toThrow('ssh_model_migration_timeout') + expect(resetModelForMigration).toHaveBeenCalledOnce() + expect(resetModelForMigration).toHaveBeenCalledWith(1, APP_ID) + harness.completions[0]!.resolve() + await migration.completion + } finally { + vi.useRealTimers() + } + }) + it('fences a running source span before exporting its migration checkpoint', async () => { const harness = createHarness() const first = harness.intake.acceptData( diff --git a/src/main/ipc/ssh-pty-output-source-obligations.ts b/src/main/ipc/ssh-pty-output-source-obligations.ts index 28f13dc2d73..410b8c05644 100644 --- a/src/main/ipc/ssh-pty-output-source-obligations.ts +++ b/src/main/ipc/ssh-pty-output-source-obligations.ts @@ -41,7 +41,12 @@ export class SshPtyOutputSourceObligations { private readonly coordinator: SshPtySourceObligationCoordinator private readonly remoteConsumers: SshPtyRemoteSourceRangeConsumers private readonly openedTokens = new Set() - private readonly identityByPty = new Map() + // Why: fence/recovery consumers key checkpoints by app pty id, but the wire + // ACK path needs the relay id kept on identity.id — record both. + private readonly identityByPty = new Map< + string, + Readonly<{ appPtyId: string; identity: PtySourceDeliveryIdentity }> + >() constructor(publish: SshPtyOutputDataEventPublisher | undefined) { this.coordinator = new SshPtySourceObligationCoordinator({ @@ -68,7 +73,7 @@ export class SshPtyOutputSourceObligations { if (!this.openedTokens.has(tokenKey)) { this.coordinator.open(identity, span.sourceStartSu) this.openedTokens.add(tokenKey) - this.identityByPty.set(this.ptyKey(event), identity) + this.identityByPty.set(this.ptyKey(event), Object.freeze({ appPtyId: event.id, identity })) } return Object.freeze({ span, @@ -132,21 +137,21 @@ export class SshPtyOutputSourceObligations { } sealPty(event: SshPtyOutputExitEvent): void { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (identity) { this.coordinator.seal(identity) } } markExitPublished(event: SshPtyOutputExitEvent): void { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (identity) { this.coordinator.markExitPublished(identity) } } whenPtyTerminal(event: SshPtyOutputExitEvent): Promise { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity return identity ? this.coordinator.whenTerminal(identity) : Promise.resolve() } @@ -154,7 +159,7 @@ export class SshPtyOutputSourceObligations { event: SshPtyOutputExitEvent, cancel: (request: SshPtySourceCancellationRequest) => Promise ): Promise { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (!identity) { return null } @@ -171,7 +176,7 @@ export class SshPtyOutputSourceObligations { event: SshPtyOutputExitEvent, proof: SshPtySourceCancellationProof ): boolean { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (!identity) { return false } @@ -183,7 +188,7 @@ export class SshPtyOutputSourceObligations { event: SshPtyOutputExitEvent, proof: SshPtySourceCancellationProof ): void { - const identity = this.identityByPty.get(this.ptyKey(event)) + const identity = this.identityByPty.get(this.ptyKey(event))?.identity if (identity) { this.coordinator.applyRecoveryCancellationProof(identity, proof) } @@ -198,8 +203,8 @@ export class SshPtyOutputSourceObligations { this.openedTokens.delete(key) } } - for (const [key, identity] of this.identityByPty) { - if (identity.providerGeneration === providerGeneration) { + for (const [key, record] of this.identityByPty) { + if (record.identity.providerGeneration === providerGeneration) { this.identityByPty.delete(key) } } @@ -207,19 +212,20 @@ export class SshPtyOutputSourceObligations { acceptedCheckpoints(providerGeneration: number): readonly SshPtyAcceptedSourceCheckpoint[] { const checkpoints: SshPtyAcceptedSourceCheckpoint[] = [] - for (const identity of this.identityByPty.values()) { - if (identity.providerGeneration !== providerGeneration) { + for (const record of this.identityByPty.values()) { + if (record.identity.providerGeneration !== providerGeneration) { continue } checkpoints.push( Object.freeze({ - id: identity.id, - providerGeneration: identity.providerGeneration, - clientGeneration: identity.clientGeneration, - ownerGeneration: identity.ownerGeneration, - ptyIncarnation: identity.ptyIncarnation, - deliveryToken: identity.deliveryToken, - acceptedSourceEndSu: this.coordinator.modelAcceptedEnd(identity) + // Why: fence/recovery keys are app pty ids; the bare relay id stays on identity.id. + id: record.appPtyId, + providerGeneration: record.identity.providerGeneration, + clientGeneration: record.identity.clientGeneration, + ownerGeneration: record.identity.ownerGeneration, + ptyIncarnation: record.identity.ptyIncarnation, + deliveryToken: record.identity.deliveryToken, + acceptedSourceEndSu: this.coordinator.modelAcceptedEnd(record.identity) }) ) } @@ -284,8 +290,8 @@ export class SshPtyOutputSourceObligations { private removeIdentity(identity: PtySourceDeliveryIdentity): void { this.openedTokens.delete(ptySourceDeliveryKey(identity)) - for (const [key, candidate] of this.identityByPty) { - if (samePtySourceDelivery(candidate, identity)) { + for (const [key, record] of this.identityByPty) { + if (samePtySourceDelivery(record.identity, identity)) { this.identityByPty.delete(key) } } diff --git a/src/main/ipc/tui-agent-detection-commands.ts b/src/main/ipc/tui-agent-detection-commands.ts index dc5d5909543..88d65c1fa34 100644 --- a/src/main/ipc/tui-agent-detection-commands.ts +++ b/src/main/ipc/tui-agent-detection-commands.ts @@ -1,77 +1 @@ -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 -} +export * from '../../shared/tui-agent-detection-commands' diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 4a4dcd04bfe..3be2a173ee1 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -17,6 +17,7 @@ import type { LocalBaseRefUpdateSuggestion, Repo, Worktree, + WorktreeCreateBaseFallback, WorktreeHeadIdentity, WorktreeMeta } from '../../shared/types' @@ -1575,8 +1576,32 @@ export async function createRemoteWorktree( 'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.' ) } - const { baseBranch } = basePlan + let { baseBranch } = basePlan let { remoteTrackingBase } = basePlan + let baseFallback: WorktreeCreateBaseFallback | undefined + + if (remoteTrackingBase) { + const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh( + provider, + repo.path, + remoteTrackingBase.ref + ) + const hasNamedLocalBaseRef = await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch) + const hasFallbackLocalBaseRef = + !hasNamedLocalBaseRef && + (await hasRemoteWorktreeBaseRef(provider, repo.path, remoteTrackingBase.branch)) + if (!hasRemoteTrackingBaseRef && (hasNamedLocalBaseRef || hasFallbackLocalBaseRef)) { + // Why: branch reuse and conflict checks must see the local fallback too. + if (hasFallbackLocalBaseRef) { + baseBranch = remoteTrackingBase.branch + } + baseFallback = { + requestedRef: remoteTrackingBase.base, + localRef: baseBranch + } + remoteTrackingBase = null + } + } let branchName = '' let checkoutExistingBranch = false @@ -1683,19 +1708,6 @@ export async function createRemoteWorktree( // Why: addWorktree/setup probes run inside the new path; older relays need that root registered before accepting git/fs ops there. await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [remotePath]) - if (remoteTrackingBase) { - const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh( - provider, - repo.path, - remoteTrackingBase.ref - ) - const hasLocalBaseRef = - hasRemoteTrackingBaseRef || (await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch)) - if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) { - remoteTrackingBase = null - } - } - if (remoteTrackingBase) { try { await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase) @@ -1933,6 +1945,7 @@ export async function createRemoteWorktree( ...(defaultTabs ? { defaultTabs } : {}), ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}), + ...(baseFallback ? { baseFallback } : {}), timing: timing.finish() } } @@ -1971,7 +1984,7 @@ export async function createLocalWorktree( ? await resolveLocalGitUsername(repo.path) : '' - const baseBranch = await resolveWorktreeCreateBase({ + let baseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: args.baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(localGitExecOptions), @@ -2010,6 +2023,7 @@ export async function createLocalWorktree( } let remoteTrackingBase: RemoteTrackingBase | null = null + let baseFallback: WorktreeCreateBaseFallback | undefined let remoteTrackingRefresh: { base: RemoteTrackingBase hadLocalBaseRef: boolean @@ -2029,10 +2043,29 @@ export async function createLocalWorktree( remoteTrackingBase, ...localWorktreeGitOptionArgs ) + const hasNamedLocalBaseRef = await hasLocalWorktreeBaseRefWithOptions( + repo.path, + baseBranch, + localGitExecOptions + ) + const hasFallbackLocalBaseRef = + !hasNamedLocalBaseRef && + (await hasLocalWorktreeBaseRefWithOptions( + repo.path, + remoteTrackingBase.branch, + localGitExecOptions + )) const hasLocalBaseRef = - hasRemoteTrackingBaseRef || - (await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localGitExecOptions)) + hasRemoteTrackingBaseRef || hasNamedLocalBaseRef || hasFallbackLocalBaseRef if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) { + // Why: use the usable local branch when offline refresh cannot create its tracking ref. + if (hasFallbackLocalBaseRef) { + baseBranch = remoteTrackingBase.branch + } + baseFallback = { + requestedRef: remoteTrackingBase.base, + localRef: baseBranch + } remoteTrackingBase = null } else { emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId) @@ -2583,6 +2616,7 @@ export async function createLocalWorktree( ? { localBaseRefUpdateSuggestion: addResult.localBaseRefUpdateSuggestion } : {}), ...(stagedStartup.startupTerminal ? { startupTerminal: stagedStartup.startupTerminal } : {}), + ...(baseFallback ? { baseFallback } : {}), ...(stagedStartup.warning ? { warning: appendWorktreeCreateWarning(includeCopyWarning, stagedStartup.warning) } : includeCopyWarning diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index d8f363a3fc4..2dec930beaf 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -5572,10 +5572,10 @@ describe('registerWorktreeHandlers', () => { getActiveMultiplexerMock.mockReturnValue(mux) store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) - await handlers['worktrees:create'](null, { + const result = (await handlers['worktrees:create'](null, { repoId: 'repo-ssh', name: 'slash-local-base' - }) + })) as CreateWorktreeResult expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( '/remote/repo', @@ -5591,6 +5591,10 @@ describe('registerWorktreeHandlers', () => { base: 'team/feature' } ) + expect(result.baseFallback).toEqual({ + requestedRef: 'team/feature', + localRef: 'team/feature' + }) }) it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => { @@ -6396,13 +6400,16 @@ describe('registerWorktreeHandlers', () => { return { stdout: 'created-sha\n', stderr: '' } }) - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-1', - name: 'slash-local-base' - }) - ).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'slash-local-base' + }) + expect(result).toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + expect((result as CreateWorktreeResult).baseFallback).toEqual({ + requestedRef: 'team/feature', + localRef: 'team/feature' + }) expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled() expect(addWorktreeMock).toHaveBeenCalledWith( '/workspace/repo', @@ -6413,6 +6420,58 @@ describe('registerWorktreeHandlers', () => { ) }) + it('uses a local branch when its missing remote-tracking base cannot refresh', async () => { + const remoteBase = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/offline-local-main', + head: 'created-sha', + branch: 'offline-local-main', + isBare: false, + isMainWorktree: false + } + ]) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { + throw new Error('missing remote-tracking ref') + } + if (args[0] === 'rev-parse' && args.includes('refs/heads/main^{commit}')) { + return { stdout: 'main-sha\n', stderr: '' } + } + if (args[0] === 'rev-parse') { + return { stdout: '', stderr: '' } + } + return { stdout: 'created-sha\n', stderr: '' } + }) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'offline-local-main', + baseBranch: 'origin/main' + }) + + expect(result).toEqual(expect.objectContaining({ worktree: expect.any(Object) })) + expect((result as CreateWorktreeResult).baseFallback).toEqual({ + requestedRef: 'origin/main', + localRef: 'main' + }) + expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled() + expect(addWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/offline-local-main', + 'offline-local-main', + 'main', + false + ) + }) + it('keeps an explicit base strict when the pre-create refresh fails', async () => { const remoteBase = { remote: 'origin', diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index d7a8c5dd802..ba69a92c524 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4930,6 +4930,59 @@ describe('Store', () => { expect(store.getWorktreeMeta('wt-malformed')?.linkedTaskSourceContext).toBeNull() }) + it('drops corrupt worktreeMeta entries while still normalizing valid siblings', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeLineageById: { 'r1::/tmp/wt': { parentWorktreeId: 'wt-sibling' } }, + workspaceLineageByChildKey: { + 'worktree:r1::/tmp/wt': { parentWorkspaceKey: 'worktree:wt-sibling' } + }, + worktreeMeta: { + 'r1::/tmp/wt': null, + 'r1::/tmp/scalar': 5, + 'wt-sibling': { + linkedWorkItem: { + provider: 'jira', + type: 'issue', + number: 0, + title: 'ORCA-123 Link Jira', + url: 'https://company.atlassian.net/browse/ORCA-123', + jiraIdentifier: 'ORCA-123' + }, + linkedTaskSourceContext: { + kind: 'task-source', + provider: 'jira', + projectId: 'project-1', + hostId: 'local', + accountLabel: 44, + providerIdentity: { + provider: 'jira', + siteId: 'site-1', + siteUrl: 'https://company.atlassian.net', + projectKey: 'ORCA' + } + } + } + } + }) + + const store = await createStore() + + expect(store.getWorktreeMeta('wt-sibling')?.linkedWorkItem?.jiraIdentifier).toBe('ORCA-123') + expect(store.getWorktreeMeta('wt-sibling')?.linkedTaskSourceContext).toBeNull() + // Corrupt entries must not survive: gcStaleWorktreeMeta keeps timestamp-less keys, and downstream + // consumers deref worktreeMeta values unguarded (also keeps a rollback to an older build loadable). + expect(store.getAllWorktreeMeta()).not.toHaveProperty('r1::/tmp/wt') + expect(store.getAllWorktreeMeta()).not.toHaveProperty('r1::/tmp/scalar') + expect(store.getWorktreeLineage('r1::/tmp/wt')).toBeUndefined() + store.flush() + const persisted = readDataFile() as PersistedState + expect(persisted.worktreeMeta).not.toHaveProperty('r1::/tmp/wt') + expect(persisted.worktreeMeta).not.toHaveProperty('r1::/tmp/scalar') + expect(persisted.workspaceLineageByChildKey).not.toHaveProperty('worktree:r1::/tmp/wt') + }) + it('creates and updates folder workspaces from folder-backed project groups', async () => { const store = await createStore() const group = store.createProjectGroup({ @@ -6046,10 +6099,12 @@ describe('Store', () => { }) }) - it('tolerates a null worktreeMeta map in the durable file', async () => { - writeDataFile({ worktreeMeta: null }) + it.each([null, [], 5])('repairs a corrupt worktreeMeta map (%#)', async (worktreeMeta) => { + writeDataFile({ worktreeMeta }) const store = await createStore() expect(store.getAllWorktreeMeta()).toEqual({}) + store.flush() + expect((readDataFile() as PersistedState).worktreeMeta).toEqual({}) }) // ── GitHub cache sidecar ─────────────────────────────────────────── @@ -7196,6 +7251,7 @@ describe('Store', () => { 'ci', 'issue', 'linear-issue', + 'jira-issue', 'pr', 'comment', 'ports', @@ -7232,6 +7288,7 @@ describe('Store', () => { 'ci', 'issue', 'linear-issue', + 'jira-issue', 'pr', 'comment', 'ports', @@ -7292,6 +7349,7 @@ describe('Store', () => { 'ci', 'issue', 'linear-issue', + 'jira-issue', 'pr', 'ports', 'inline-agents' @@ -7308,7 +7366,8 @@ describe('Store', () => { ui: { worktreeCardProperties: ['status', 'pr'], _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} @@ -7367,7 +7426,8 @@ describe('Store', () => { 'ports' ], _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} @@ -7388,6 +7448,107 @@ describe('Store', () => { expect(store.getUI().worktreeCardProperties).not.toContain('inline-agents') }) + it('backfills jira-issue once for profiles stamped before it joined the defaults', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { compactWorktreeCards: false }, + ui: { + worktreeCardProperties: [ + 'status', + 'unread', + 'issue', + 'linear-issue', + 'pr', + 'ports', + 'inline-agents' + ], + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect(store.getUI().worktreeCardProperties).toEqual([ + 'status', + 'unread', + 'issue', + 'linear-issue', + 'jira-issue', + 'pr', + 'ports', + 'inline-agents' + ]) + expect( + store.getUI().worktreeCardProperties?.filter((property) => property === 'jira-issue') + ).toHaveLength(1) + expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) + }) + + it('preserves a deliberate jira-issue removal after the backfill has run', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { compactWorktreeCards: false }, + ui: { + worktreeCardProperties: ['status', 'unread', 'issue', 'linear-issue', 'pr'], + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect(store.getUI().worktreeCardProperties).toEqual([ + 'status', + 'unread', + 'issue', + 'linear-issue', + 'pr' + ]) + expect(store.getUI().worktreeCardProperties).not.toContain('jira-issue') + }) + + it('leaves fresh default profiles with a single jira-issue entry', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect( + store.getUI().worktreeCardProperties?.filter((property) => property === 'jira-issue') + ).toHaveLength(1) + expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) + }) + + it('skips the jira-issue backfill when card properties are malformed', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { compactWorktreeCards: false }, + ui: { worktreeCardProperties: 'not-an-array' }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + + expect(store.getUI().worktreeCardProperties).not.toContain('jira-issue') + expect(store.getUI()._jiraIssueWorktreeCardPropertyDefaulted).toBe(true) + }) + it('uses the compact preset when card properties are missing in compact mode', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 85de59a8bda..e3c0fa329af 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -425,7 +425,27 @@ function gcStaleWorktreeMeta(state: PersistedState): number { function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { let changed = false - for (const meta of Object.values(state.worktreeMeta ?? {})) { + const rawWorktreeMeta = state.worktreeMeta as unknown + if ( + typeof rawWorktreeMeta !== 'object' || + rawWorktreeMeta === null || + Array.isArray(rawWorktreeMeta) + ) { + state.worktreeMeta = {} + changed = rawWorktreeMeta !== undefined + } + for (const [key, meta] of Object.entries(state.worktreeMeta)) { + // Why: hand-corrupted non-object entries are a real input class; drop them here because gcStaleWorktreeMeta + // keeps timestamp-less keys forever and every downstream consumer trusts the Record type. + if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { + delete state.worktreeMeta[key] + // Companions go with it, matching gcStaleWorktreeMeta/removeWorktreeMeta; a stranded lineage row would + // otherwise re-attach to a worktree recreated at the same repoId::path. + delete state.worktreeLineageById[key] + delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(key)] + changed = true + continue + } const linkedWorkItem = normalizeWorkspaceLinkedItem(meta.linkedWorkItem) const sourceContext = normalizeStoredTaskSourceContext(meta.linkedTaskSourceContext) const linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( @@ -3284,6 +3304,8 @@ export class Store { const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true const expandedCardPropsMigrated = parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true + const jiraIssueCardPropDefaulted = + parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true const hadExperimentOn = readDeprecatedExperimentFlag(parsed) const deliberateUncheck = hadExperimentOn && @@ -3322,7 +3344,12 @@ export class Store { } return next })() - const normalized = normalizeWorktreeCardProperties(expandedCandidate) + // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. + const jiraCandidate = + jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') + ? expandedCandidate + : [...expandedCandidate, 'jira-issue' as const] + const normalized = normalizeWorktreeCardProperties(jiraCandidate) const changed = normalized.length !== rawCardProps.length || normalized.some((property, index) => property !== rawCardProps[index]) @@ -3331,7 +3358,8 @@ export class Store { if ( migratedCardProps !== undefined || !inlineAgentsMigrated || - !expandedCardPropsMigrated + !expandedCardPropsMigrated || + !jiraIssueCardPropDefaulted ) { this.loadNeedsSave = true } @@ -3399,7 +3427,8 @@ export class Store { // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. _inlineAgentsDefaultedForExperiment: true, _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true } })(), // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 2fd94df751f..8c41f25690a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8468,6 +8468,81 @@ describe('OrcaRuntimeService', () => { expect(events).toHaveLength(1) }) + it('omits terminalSideEffects from non-consuming listeners while other events still flow', () => { + const runtime = new OrcaRuntimeService(store) + const desktopEvents: RuntimeClientEvent[] = [] + const mobileEvents: RuntimeClientEvent[] = [] + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.onClientEvent((event) => desktopEvents.push(event)) + runtime.onClientEvent((event) => mobileEvents.push(event), { + consumesTerminalSideEffects: false + }) + + runtime.onPtyData('pty-remote', '\x1b]0;Codex working\x07', 100) + runtime.notifyBranchRenamed(TEST_REPO_ID) + + expect(desktopEvents.map((event) => event.type)).toEqual([ + 'terminalSideEffects', + 'worktreesChanged' + ]) + expect(mobileEvents.map((event) => event.type)).toEqual(['worktreesChanged']) + }) + + it('keeps a phone-only host producing title state without emitting batches to it', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-a` + const runtime = new OrcaRuntimeService(store) + const mobileEvents: RuntimeClientEvent[] = [] + const trackerEntries = ( + runtime as unknown as { + ptyTitleTrackersByPtyId: Map + } + ).ptyTitleTrackersByPtyId + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.onClientEvent((event) => mobileEvents.push(event), { + consumesTerminalSideEffects: false + }) + const unsubscribeDesktop = runtime.onClientEvent(() => {}) + + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyId, 'output without a title\r\n', 101) + // The phone is still subscribed: disposing trackers on this edge would cancel + // its armed stale-working-title timer and strand a 'working' spinner (#1437). + unsubscribeDesktop() + + await vi.advanceTimersByTimeAsync(3_000) + + expect(trackerEntries.has(ptyId)).toBe(true) + expect(trackerEntries.get(ptyId)?.commandCodeDetector).toBeNull() + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ title: 'Codex' }) + expect(mobileEvents.some((event) => event.type === 'terminalSideEffects')).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('skips a listener unsubscribed mid-fan-out even with mobile exclusions active', () => { + const runtime = new OrcaRuntimeService(store) + const lateEvents: RuntimeClientEvent[] = [] + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.onClientEvent(() => {}, { consumesTerminalSideEffects: false }) + runtime.onClientEvent(() => { + unsubscribeLate() + }) + const unsubscribeLate = runtime.onClientEvent((event) => lateEvents.push(event)) + + runtime.onPtyData('pty-remote', '\x1b]0;Codex working\x07', 100) + + expect(lateEvents).toEqual([]) + }) + it('emits one batched event per chunk with facts in byte order and attribution', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b505b778e4a..1328dc5c6ed 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2725,6 +2725,10 @@ export class OrcaRuntimeService { private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() + // Why: mobile subscribers discard terminalSideEffects; exclude them from batch delivery and production. + private terminalSideEffectExcludedClientEventListeners = new Set< + (event: RuntimeClientEvent) => void + >() private nativeChatLaunchDraftResolutionByTabId = new Map< string, NativeChatLaunchDraftResolutionTombstone @@ -4681,15 +4685,26 @@ export class OrcaRuntimeService { } } - onClientEvent(listener: (event: RuntimeClientEvent) => void): () => void { + onClientEvent( + listener: (event: RuntimeClientEvent) => void, + options?: { consumesTerminalSideEffects?: boolean } + ): () => void { this.clientEventListeners.add(listener) + if (options?.consumesTerminalSideEffects === false) { + this.terminalSideEffectExcludedClientEventListeners.add(listener) + } this.refreshTerminalSideEffectConsumerAvailability() return () => { this.clientEventListeners.delete(listener) + this.terminalSideEffectExcludedClientEventListeners.delete(listener) this.refreshTerminalSideEffectConsumerAvailability() } } + private countTerminalSideEffectConsumingClientEventListeners(): number { + return this.clientEventListeners.size - this.terminalSideEffectExcludedClientEventListeners.size + } + getTerminalSleepClientEventSnapshot(): RuntimeClientEvent[] { const events: RuntimeClientEvent[] = [] const sleepStates = [...this.terminalSleepStateByWorktreeId.values()].sort((a, b) => @@ -4747,9 +4762,25 @@ export class OrcaRuntimeService { } private emitClientEvent(event: RuntimeClientEvent): void { + // Why: mobile streams discard terminalSideEffects; skip excluded listeners so + // paired phones never receive the per-OSC batch frames over the relay. Filtered + // inside the delivery callback to keep live-Set iteration (a listener that + // unsubscribes mid-fan-out must not be delivered to) and stay allocation-free. + const skipExcluded = + event.type === 'terminalSideEffects' && + this.terminalSideEffectExcludedClientEventListeners.size > 0 // Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the // per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart. - notifyRuntimeListeners(this.clientEventListeners, (listener) => listener(event), 'client-event') + notifyRuntimeListeners( + this.clientEventListeners, + (listener) => { + if (skipExcluded && this.terminalSideEffectExcludedClientEventListeners.has(listener)) { + return + } + listener(event) + }, + 'client-event' + ) } notifyNativeChatLaunchDraftResolved( @@ -9417,7 +9448,7 @@ export class OrcaRuntimeService { console.error('[runtime] terminal side-effect listener threw', { ptyId, err }) } } - if (this.clientEventListeners.size > 0) { + if (this.countTerminalSideEffectConsumingClientEventListeners() > 0) { this.emitClientEvent({ type: 'terminalSideEffects', batch }) } } @@ -9585,33 +9616,23 @@ export class OrcaRuntimeService { this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) }, - // Why: headless serve still scans command completion to retire agent - // launch authority; other transient facts remain desktop-only. - ...(this.terminalSideEffectConsumerAvailable - ? { - onBell: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) - }, - onPrLink: (link: TerminalGitHubPRLink) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) - }, - // Why: hidden-delivery-gated views never see the bytes, so main - // surfaces DECSET 2031 subscribes as facts; the theme reply is - // still sent by the renderer (query authority stays with the view). - onMode2031Subscribe: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) - }, - // Why: the gated view never sees the withdrawal bytes either, so the - // subscription registry it keeps for theme flips needs this fact to - // stay truthful. - onMode2031Unsubscribe: () => { - this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) - } - } - : {}) + onBell: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + }, + onPrLink: (link: TerminalGitHubPRLink) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) + }, + // Why: hidden-delivery-gated views never see 2031 bytes; facts keep their theme registry truthful. + onMode2031Subscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + }, + onMode2031Unsubscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-unsubscribe' }) + } }, initialTitle !== null ? { initialTitle } : {} ) + tracker.setTransientSideEffectScanningEnabled(this.terminalSideEffectConsumerAvailable) const entry: RuntimePtyTitleTrackerEntry = { tracker, applyingChunk: false, @@ -9624,15 +9645,7 @@ export class OrcaRuntimeService { // self-arms on the Command Code banner; the spawn command (when main // saw one) mirrors the renderer detector's startupCommand fast-arm. commandCodeDetector: this.terminalSideEffectConsumerAvailable - ? createCommandCodeOutputStatusDetector({ - startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, - onWorking: (prompt) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) - }, - onDone: (prompt) => { - this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) - } - }) + ? this.createTerminalSideEffectCommandCodeDetector(ptyId) : null } this.ptyTitleTrackersByPtyId.set(ptyId, entry) @@ -9752,18 +9765,34 @@ export class OrcaRuntimeService { private refreshTerminalSideEffectConsumerAvailability(): void { const nextAvailable = - this.terminalSideEffectLocalConsumerAvailable || this.clientEventListeners.size > 0 + this.terminalSideEffectLocalConsumerAvailable || + this.countTerminalSideEffectConsumingClientEventListeners() > 0 if (nextAvailable === this.terminalSideEffectConsumerAvailable) { return } this.terminalSideEffectConsumerAvailable = nextAvailable - // Why: optional bell/command/link scanners are selected when a tracker is - // created. Rebuild at the window boundary so pure headless output stays cheap. - for (const ptyId of [...this.ptyTitleTrackersByPtyId.keys()]) { - this.disposePtyTitleTracker(ptyId) + for (const [ptyId, entry] of this.ptyTitleTrackersByPtyId) { + entry.tracker.setTransientSideEffectScanningEnabled(nextAvailable) + entry.commandCodeDetector = nextAvailable + ? this.createTerminalSideEffectCommandCodeDetector(ptyId) + : null } } + private createTerminalSideEffectCommandCodeDetector( + ptyId: string + ): NonNullable { + return createCommandCodeOutputStatusDetector({ + startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, + onWorking: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) + }, + onDone: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) + } + }) + } + private extractLastOsc7CwdForPty( ptyId: string, data: string diff --git a/src/main/runtime/rpc/methods/client-events.test.ts b/src/main/runtime/rpc/methods/client-events.test.ts new file mode 100644 index 00000000000..bf5026c819e --- /dev/null +++ b/src/main/runtime/rpc/methods/client-events.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeClientEvent } from '../../../../shared/runtime-client-events' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { isStreamingMethod, type RpcContext, type RpcStreamingMethod } from '../core' +// Why: importing client-events directly trips its module-init cycle through ipc/ssh; the index resolves it. +import { ALL_RPC_METHODS } from './index' + +const subscribeMethod = ALL_RPC_METHODS.find( + (method) => method.name === 'runtime.clientEvents.subscribe' && isStreamingMethod(method) +) as RpcStreamingMethod + +function makeRuntime(): { + runtime: OrcaRuntimeService + onClientEvent: ReturnType + cleanups: (() => void)[] +} { + const cleanups: (() => void)[] = [] + const onClientEvent = vi.fn( + ( + _listener: (event: RuntimeClientEvent) => void, + _options?: { consumesTerminalSideEffects?: boolean } + ) => + () => {} + ) + const runtime = { + onClientEvent, + registerSubscriptionCleanup: (_id: string, cleanup: () => void) => { + cleanups.push(cleanup) + } + } as unknown as OrcaRuntimeService + return { runtime, onClientEvent, cleanups } +} + +describe('runtime.clientEvents.subscribe', () => { + it('registers mobile subscriptions as non-consumers of terminal side effects', async () => { + const { runtime, onClientEvent, cleanups } = makeRuntime() + + const done = subscribeMethod.handler( + undefined, + { runtime, connectionId: 'conn-1', clientKind: 'mobile' } as RpcContext, + () => {} + ) + + expect(onClientEvent).toHaveBeenCalledWith(expect.any(Function), { + consumesTerminalSideEffects: false + }) + cleanups.forEach((cleanup) => cleanup()) + await done + }) + + it('keeps non-mobile subscriptions consuming terminal side effects', async () => { + const { runtime, onClientEvent, cleanups } = makeRuntime() + + const done = subscribeMethod.handler( + undefined, + { runtime, connectionId: 'conn-1' } as RpcContext, + () => {} + ) + + expect(onClientEvent).toHaveBeenCalledWith(expect.any(Function), { + consumesTerminalSideEffects: true + }) + cleanups.forEach((cleanup) => cleanup()) + await done + }) +}) diff --git a/src/main/runtime/rpc/methods/client-events.ts b/src/main/runtime/rpc/methods/client-events.ts index 956594dd5e3..81ee32d1988 100644 --- a/src/main/runtime/rpc/methods/client-events.ts +++ b/src/main/runtime/rpc/methods/client-events.ts @@ -16,11 +16,16 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [ defineStreamingMethod({ name: 'runtime.clientEvents.subscribe', params: null, - handler: async (_params, { runtime, connectionId }, emit) => { + handler: async (_params, { runtime, connectionId, clientKind }, emit) => { await new Promise((resolve) => { - const unsubscribe = runtime.onClientEvent((event) => { - emit(event) - }) + // Why: mobile discards terminalSideEffects; excluding it stops the + // per-OSC batch frames from crossing the relay. + const unsubscribe = runtime.onClientEvent( + (event) => { + emit(event) + }, + { consumesTerminalSideEffects: clientKind !== 'mobile' } + ) const seq = ++clientEventSubscriptionSeq const subscriptionId = `runtime-client-events-${connectionId ?? 'inproc'}-${seq}` diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts index bbdece031d0..a1ccf86c254 100644 --- a/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity-checks.ts @@ -11,6 +11,7 @@ type MainOwnedUIState = | 'trayMinimizeNoticeShown' | 'dashboardPopoutBounds' | '_expandedWorktreeCardPropertiesDefaulted' + | '_jiraIssueWorktreeCardPropertyDefaulted' | 'starNagBaselineAgents' | 'starNagAppVersion' | 'starNagNextThreshold' diff --git a/src/main/ssh/ssh-relay-session-recovery-races.test.ts b/src/main/ssh/ssh-relay-session-recovery-races.test.ts index 711f926e056..43354ebb90c 100644 --- a/src/main/ssh/ssh-relay-session-recovery-races.test.ts +++ b/src/main/ssh/ssh-relay-session-recovery-races.test.ts @@ -166,6 +166,25 @@ describe('SshRelaySession recovery race fencing', () => { }) } + function pendingRecovery(recoveryEndSu: number) { + return { + status: 'pending', + clientGeneration: 2, + ownerGeneration: 2, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'new-token', + checkpointSourceEndSu: 4, + recoveryEndSu + } + } + + function completeRecovery(params: Record): void { + const complete = onNotificationByMethodMock.mock.calls.findLast( + ([method]) => method === 'pty.recoveryComplete' + )?.[1] as ((params: Record) => void) | undefined + complete?.(params) + } + async function prepareRecovery(targetId: string): Promise<{ session: SshRelaySession deps: ReturnType @@ -242,15 +261,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }, + sourceRecovery: pendingRecovery(8), sourceActivationLease } }) @@ -320,15 +331,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }, + sourceRecovery: pendingRecovery(8), sourceActivationLease } }) @@ -401,10 +404,7 @@ describe('SshRelaySession recovery race fencing', () => { const { session, deps } = await prepareRecovery(targetId) attachForReconnectMock.mockImplementation(async () => { queueMicrotask(() => { - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ + completeRecovery({ id: 'pty-1', clientGeneration: 2, ownerGeneration: 2, @@ -416,15 +416,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 4 - } + sourceRecovery: pendingRecovery(4) } }) await session.reconnect(deps.mockConn) @@ -463,10 +455,7 @@ describe('SshRelaySession recovery race fencing', () => { sourceStartSu: 5, sourceEndSu: 8 }) - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ + completeRecovery({ id: 'pty-1', clientGeneration: 2, ownerGeneration: 2, @@ -478,15 +467,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - } + sourceRecovery: pendingRecovery(8) } }) await session.reconnect(deps.mockConn) @@ -559,15 +540,7 @@ describe('SshRelaySession recovery race fencing', () => { } attachForReconnectMock.mockResolvedValue({ incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 12 - }, + sourceRecovery: pendingRecovery(12), sourceActivationLease }) @@ -651,15 +624,7 @@ describe('SshRelaySession recovery race fencing', () => { }) return { incarnationId: 'incarnation-1', - sourceRecovery: { - status: 'pending', - clientGeneration: 2, - ownerGeneration: 2, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'new-token', - checkpointSourceEndSu: 4, - recoveryEndSu: 8 - }, + sourceRecovery: pendingRecovery(8), sourceActivationLease: activationLease } }) @@ -704,6 +669,51 @@ describe('SshRelaySession recovery race fencing', () => { } ) + it('overwrites the migration checkpoint with the post-recovery one for the same pty', async () => { + const targetId = 'post-recovery-checkpoint-rekey' + const { session, deps } = await prepareRecovery(targetId) + attachForReconnectMock.mockImplementation(async () => { + queueMicrotask(() => { + emitSourceFrame({ + targetId, + token: 'new-token', + clientGeneration: 2, + ownerGeneration: 2, + sourceStartSu: 4, + sourceEndSu: 8 + }) + completeRecovery({ + id: 'pty-1', + clientGeneration: 2, + ownerGeneration: 2, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'new-token', + checkpointSourceEndSu: 4, + recoveryEndSu: 4 + }) + }) + return { incarnationId: 'incarnation-1', sourceRecovery: pendingRecovery(4) } + }) + await session.reconnect(deps.mockConn) + expect(acceptOutputDataMock).toHaveBeenCalledOnce() + + // The pre-migration identity is gone from the intake, so only the checkpoint + // recorded after recovery can answer the next reconnect. + vi.mocked(getSshPtyAcceptedSourceCheckpoints).mockReturnValue([]) + attachForReconnectMock.mockResolvedValue({ + incarnationId: 'incarnation-1', + sourceRecovery: { status: 'restoreRequired', reason: 'checkpointUnavailable' } + }) + await session.reconnect(deps.mockConn) + + expect(attachForReconnectMock).toHaveBeenCalledTimes(2) + expect(attachForReconnectMock.mock.calls.at(-1)?.[2]).toMatchObject({ + status: 'checkpoint', + deliveryToken: 'new-token', + acceptedSourceEndSu: 8 + }) + }) + it('keeps a stale overlapping recovery from canceling or mutating its replacement', async () => { const targetId = 'overlapping-recovery' const { session, deps } = await prepareRecovery(targetId) @@ -723,10 +733,7 @@ describe('SshRelaySession recovery race fencing', () => { const ownerGeneration = openConsumerSessionMock.mock.calls.length if (ownerGeneration === 3) { queueMicrotask(() => { - const complete = onNotificationByMethodMock.mock.calls.findLast( - ([method]) => method === 'pty.recoveryComplete' - )?.[1] as ((params: Record) => void) | undefined - complete?.({ + completeRecovery({ id: 'pty-1', clientGeneration: 3, ownerGeneration: 3, diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index aef993f8eef..d5dba94c80b 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -2119,6 +2119,8 @@ export class SshRelaySession { } const checkpoints = recovery?.checkpointsByAppPtyId const relayPtyId = toRelaySshPtyId(this.targetId, appPtyId) + // Why: every checkpoint writer records app-id keys now, so the relay-id + // lookup (and its paired delete below) is a legacy guard only. const checkpoint = checkpoints?.get(appPtyId) ?? checkpoints?.get(relayPtyId) if (!checkpoint) { return Object.freeze({ status: 'checkpointUnavailable' }) @@ -2273,10 +2275,12 @@ export class SshRelaySession { (endSu, payload) => Math.max(endSu, payload.source?.sourceEndSu ?? endSu), acceptedRecovery.recoveryEndSu ) + // Why: checkpoints are app-id keyed; a relay-id entry here would be shadowed + // by a staler app-id entry on the next sourceRecoveryRequest lookup. ptyConsumerRecoveryByTarget.get(this.targetId)?.checkpointsByAppPtyId.set( - relayPtyId, + appPtyId, Object.freeze({ - id: relayPtyId, + id: appPtyId, providerGeneration: this.activePtyProviderGeneration!, clientGeneration: acceptedRecovery.clientGeneration, ownerGeneration: acceptedRecovery.ownerGeneration, diff --git a/src/main/startup/gpu-fallback-switches.test.ts b/src/main/startup/gpu-fallback-switches.test.ts new file mode 100644 index 00000000000..b1ae28d0d5e --- /dev/null +++ b/src/main/startup/gpu-fallback-switches.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyGpuFallbackCommandLineSwitches } from './gpu-fallback-switches' + +describe('GPU fallback command-line switches', () => { + it('applies nothing outside Windows', () => { + for (const platform of ['darwin', 'linux'] as const) { + const appendSwitch = vi.fn() + expect(applyGpuFallbackCommandLineSwitches({ appendSwitch }, platform)).toEqual([]) + expect(appendSwitch).not.toHaveBeenCalled() + } + }) + + // Why: measured on Windows 11 / Electron 43.1.0 — `--disable-gpu` alone still reports + // GPU: 1 in app.getAppMetrics(); adding --in-process-gpu drops it to 0. + it('appends exactly the measured switch set to the Electron command line', () => { + const appendSwitch = vi.fn() + const appliedSwitches = applyGpuFallbackCommandLineSwitches({ appendSwitch }, 'win32') + expect(appliedSwitches).toEqual([ + 'disable-gpu', + 'disable-software-rasterizer', + 'in-process-gpu' + ]) + expect(appendSwitch.mock.calls.map(([name]) => name)).toEqual(appliedSwitches) + }) +}) diff --git a/src/main/startup/gpu-fallback-switches.ts b/src/main/startup/gpu-fallback-switches.ts new file mode 100644 index 00000000000..9c0b5ef8ff7 --- /dev/null +++ b/src/main/startup/gpu-fallback-switches.ts @@ -0,0 +1,35 @@ +/** Minimal surface of `app.commandLine` this module needs, so callers can be tested. */ +export type GpuFallbackCommandLine = { + appendSwitch(name: string, value?: string): void +} + +/** + * Why: `disableHardwareAcceleration()` + `--disable-gpu` still spawns a GPU child + * (measured on Windows 11 / Electron 43.1.0) — it only drops the backend to software + * GL, so a GPU process being killed by a bad driver or injected DLL keeps dying after + * the fallback engages. `--in-process-gpu` is the only switch that removes the child. + * SwiftShader is disabled because `--in-process-gpu` would otherwise move untrusted + * WebGL command parsing into Orca's privileged main process. Terminals safely fall + * back to the DOM renderer in this post-crash mode. + */ +const GPU_FALLBACK_COMMAND_LINE_SWITCHES = [ + 'disable-gpu', + 'disable-software-rasterizer', + 'in-process-gpu' +] as const + +/** Software rendering is a Windows-only post-crash fallback; every other platform gets nothing. */ +function resolveGpuFallbackSwitches(platform: NodeJS.Platform): readonly string[] { + return platform === 'win32' ? GPU_FALLBACK_COMMAND_LINE_SWITCHES : [] +} + +export function applyGpuFallbackCommandLineSwitches( + commandLine: GpuFallbackCommandLine, + platform: NodeJS.Platform +): readonly string[] { + const switches = resolveGpuFallbackSwitches(platform) + for (const name of switches) { + commandLine.appendSwitch(name) + } + return switches +} diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index abf0b0964be..3f719e5ae34 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -119,17 +119,29 @@ describe('getSpawnArgsForWindows', () => { it('rejects unsafe args for .cmd scripts on win32', () => { withPlatform('win32', () => { - expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', ['hello & goodbye'])).toThrow( - 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - ) + for (const argument of ['hello & goodbye', 'close)', '(open']) { + expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', [argument])).toThrow( + 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + ) + } }) }) it('rejects unsafe command paths for .cmd scripts on win32', () => { withPlatform('win32', () => { - expect(() => getSpawnArgsForWindows('C:\\bad&path\\agent.cmd', ['login'])).toThrow( - 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - ) + for (const command of ['C:\\bad&path\\agent.cmd', 'C:\\bad(path\\agent.cmd']) { + expect(() => getSpawnArgsForWindows(command, ['login'])).toThrow( + 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + ) + } + }) + }) + + it('allows punctuation that is not a cmd command operator', () => { + withPlatform('win32', () => { + expect( + getSpawnArgsForWindows('C:\\tools\\agent.cmd', ['package,name;version']).spawnArgs + ).toEqual(['/d', '/c', 'C:\\tools\\agent.cmd', 'package,name;version']) }) }) }) diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 4edc47b93a1..21f3a75853d 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -2,6 +2,14 @@ import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from ' import { delimiter, join, win32 } from 'node:path' import { existsSync } from 'node:fs' +export { + getCmdExePath, + getSpawnArgsForWindows, + isWindowsBatchScript, + WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, + UnsafeWindowsBatchArgumentsError +} from '../shared/windows-batch-spawn' + function execFileWithoutBlocking( command: string, args: string[], @@ -38,20 +46,6 @@ export function getRegExePath(env: NodeJS.ProcessEnv = process.env): string { return win32.join(root, 'System32', 'reg.exe') } -/** - * Full path to cmd.exe, respecting the ComSpec convention used elsewhere in - * the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts). - * Falls back to SystemRoot-based path if ComSpec is unset. - */ -export function getCmdExePath(): string { - return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe` -} - -/** Whether a resolved command path points to a Windows batch script (.cmd/.bat). */ -export function isWindowsBatchScript(commandPath: string): boolean { - return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath) -} - export function resolveWindowsCommand( command: string, env: NodeJS.ProcessEnv = process.env @@ -79,19 +73,6 @@ export function resolveWindowsCommand( return command } -export const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' - -export class UnsafeWindowsBatchArgumentsError extends Error { - constructor() { - super(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) - this.name = 'UnsafeWindowsBatchArgumentsError' - } -} - -function hasUnsafeWindowsBatchSyntax(value: string): boolean { - return /[&|<>^"%!\r\n]/.test(value) -} - /** Check whether an error is a Windows permission error (EACCES or EPERM). */ export function isPermissionError(error: unknown): boolean { return ( @@ -235,37 +216,3 @@ export async function grantDirAclAsync(dirPath: string): Promise { } ) } - -/** - * Resolve spawn parameters for a command that may be a Windows batch script. - * - * Why: Node's spawn() cannot execute .cmd/.bat files directly without - * shell:true, but shell:true with an args array triggers DEP0190 because - * args are concatenated, not escaped. Routing through cmd.exe /c explicitly - * avoids the deprecation warning while passing args correctly. - * - * Why /d: disables per-machine/user AutoRun registry commands so a background - * spawn cannot inherit surprising side effects from the user's shell config. - * - * SAFETY: when the .cmd/.bat branch is taken, cmd.exe re-parses the command - * line. Args with cmd metacharacters are rejected instead of escaped because - * the agent prompt may contain arbitrary staged diff text. - */ -export function getSpawnArgsForWindows( - command: string, - args: string[] -): { spawnCmd: string; spawnArgs: string[] } { - if (isWindowsBatchScript(command)) { - for (const value of [command, ...args]) { - if (hasUnsafeWindowsBatchSyntax(value)) { - throw new UnsafeWindowsBatchArgumentsError() - } - } - - // Why: when Node passes a pre-quoted command line as one argv entry, - // cmd.exe sees literal escaped quotes on Windows and refuses to run .cmd - // shims. Separate argv entries let Node quote spaces without breaking cmd. - return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] } - } - return { spawnCmd: command, spawnArgs: args } -} diff --git a/src/main/wsl-distro-list-output.test.ts b/src/main/wsl-distro-list-output.test.ts new file mode 100644 index 00000000000..aa8e52fb9aa --- /dev/null +++ b/src/main/wsl-distro-list-output.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { filterUserWslDistros, parseWslDistros } from './wsl-distro-list-output' + +describe('WSL distro list output', () => { + it('normalizes NUL-padded output and the default-distro marker', () => { + expect(parseWslDistros('*\0 \0U\0b\0u\0n\0t\0u\0\r\0\n\0')).toEqual(['Ubuntu']) + }) + + it('excludes Docker-managed distros', () => { + expect(filterUserWslDistros(['docker-desktop', 'Ubuntu', 'docker-desktop-data'])).toEqual([ + 'Ubuntu' + ]) + }) +}) diff --git a/src/main/wsl-distro-list-output.ts b/src/main/wsl-distro-list-output.ts new file mode 100644 index 00000000000..53f243d2d1f --- /dev/null +++ b/src/main/wsl-distro-list-output.ts @@ -0,0 +1,11 @@ +export function filterUserWslDistros(distros: string[]): string[] { + return distros.filter((distro) => !distro.toLowerCase().startsWith('docker-desktop')) +} + +export function parseWslDistros(output: string): string[] { + return output + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim().replace(/^\*\s*/, '')) + .filter(Boolean) +} diff --git a/src/main/wsl-distro-retry.ts b/src/main/wsl-distro-retry.ts new file mode 100644 index 00000000000..f5a9922ac68 --- /dev/null +++ b/src/main/wsl-distro-retry.ts @@ -0,0 +1,4 @@ +export function wslDistroListRetryDelayMs(emptyStreak: number): number { + // Provisioning is one-time, so bounded backoff avoids polling docker-desktop-only hosts. + return Math.min(15_000 * 2 ** (emptyStreak - 1), 5 * 60_000) +} diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index 597b1e795f4..0f618002b46 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -17,7 +17,12 @@ vi.mock('child_process', async (importOriginal) => { import { _resetWslCachesForTests, + _setWslCachesForTests, + getCachedWslAvailability, getCachedWslDistros, + hasCachedWslAvailability, + hasCachedWslDistros, + isWslAvailable, listWslDistros, listWslDistrosAsync, parseWslPath, @@ -98,6 +103,559 @@ describe('WSL distro discovery cache', () => { vi.useRealTimers() } }) + + // Why: `wsl --install` succeeds and reports zero distros while the distro is + // still being provisioned (or until the required reboot). Caching that empty + // success for the process lifetime is why users saw WSL offered during setup + // and then permanently absent from the terminal picker. + it('retries asynchronous discovery after wsl.exe reports no distros yet', async () => { + vi.useFakeTimers() + execFileMock + .mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, '') + }) + .mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, 'Ubuntu\n') + }) + + try { + await withPlatformAsync('win32', async () => { + await expect(listWslDistrosAsync()).resolves.toEqual([]) + vi.advanceTimersByTime(15_000) + await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + it('retries synchronous discovery after wsl.exe reports no distros yet', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('') + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an empty result must still not let every caller re-spawn a blocking + // wsl.exe; it reuses the same brief negative-cache window as a hard failure. + it('bounds the wsl.exe spawn rate while no distros are installed', () => { + execFileSyncMock.mockReturnValue('') + + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + it('bounds the asynchronous spawn rate while no distros are installed', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callback(null, '') + }) + + await withPlatformAsync('win32', async () => { + await expect(listWslDistrosAsync()).resolves.toEqual([]) + await expect(listWslDistrosAsync()).resolves.toEqual([]) + expect(execFileMock).toHaveBeenCalledTimes(1) + }) + }) + + it('still caches a non-empty distro list for the process lifetime', () => { + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + withPlatform('win32', () => { + expect(listWslDistros()).toEqual(['Ubuntu']) + expect(listWslDistros()).toEqual(['Ubuntu']) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + // Why: docker-desktop entries filter to zero user distros, which is the same + // "nothing installed yet" state as an empty list — a distro installed later + // must still appear. The answer stays readable as [] so a missing distro is + // still visible to `isKnownMissingDistro`. + it('re-probes a docker-desktop-only machine once a user distro can appear', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('docker-desktop\ndocker-desktop-data\n') + execFileSyncMock.mockReturnValueOnce('docker-desktop\nUbuntu\n') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(getCachedWslDistros()).toEqual([]) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: a docker-desktop-only machine filters to empty on every probe, so a flat + // window would re-spawn wsl.exe every 15s for the whole session. + it('backs off while the list keeps coming back empty', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValue('') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + // Second empty result doubles the window, so 15s more is not enough. + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(3) + }) + } finally { + vi.useRealTimers() + } + }) + + it('backs off after repeated distro-list failures', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementation(() => { + throw new Error('transient failure') + }) + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: listWslDistrosAsync has no in-flight dedupe, so two probes can resolve out of order. + // The late empty answer must not erase the list the newer probe already found. + it('does not let a late empty probe erase a list a newer probe found', async () => { + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + await withPlatformAsync('win32', async () => { + const stale = listWslDistrosAsync() + const fresh = listWslDistrosAsync() + expect(callbacks).toHaveLength(2) + + callbacks[1](null, 'Ubuntu\n') + callbacks[0](null, '') + + await expect(fresh).resolves.toEqual(['Ubuntu']) + await expect(stale).resolves.toEqual(['Ubuntu']) + expect(getCachedWslDistros()).toEqual(['Ubuntu']) + }) + }) + + it('does not let an older non-empty probe overwrite a newer list', async () => { + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + await withPlatformAsync('win32', async () => { + const stale = listWslDistrosAsync() + const fresh = listWslDistrosAsync() + callbacks[1](null, 'Ubuntu\n') + callbacks[0](null, 'Debian\n') + + await expect(fresh).resolves.toEqual(['Ubuntu']) + await expect(stale).resolves.toEqual(['Ubuntu']) + expect(getCachedWslDistros()).toEqual(['Ubuntu']) + }) + }) + + // Why: N startup callers each land with the same empty answer; counting each one + // would arm the 5min cap on the first round and hide a distro for that whole time. + it('counts one empty result per window when probes overlap', async () => { + vi.useFakeTimers() + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + try { + await withPlatformAsync('win32', async () => { + const pending = [listWslDistrosAsync(), listWslDistrosAsync(), listWslDistrosAsync()] + expect(callbacks).toHaveLength(3) + for (const callback of callbacks) { + callback(null, '') + } + await Promise.all(pending) + + // One window elapsed, not three doublings, so the next probe runs at 15s. + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an overlapping failure and empty result must count as one retry window. + it('holds the base window when a failure and an empty result overlap', async () => { + vi.useFakeTimers() + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + try { + await withPlatformAsync('win32', async () => { + const failing = listWslDistrosAsync() + const empty = listWslDistrosAsync() + expect(callbacks).toHaveLength(2) + callbacks[0](new Error('transient failure'), '') + callbacks[1](null, '') + await Promise.all([failing, empty]) + + vi.advanceTimersByTime(7_500) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).not.toHaveBeenCalled() + vi.advanceTimersByTime(7_500) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not shorten an empty-list backoff when an overlapping probe fails', async () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('') + const callbacks: ((error: Error | null, stdout: string) => void)[] = [] + execFileMock.mockImplementation((_command, _args, _options, callback) => { + callbacks.push(callback) + }) + + try { + await withPlatformAsync('win32', async () => { + expect(listWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + + const empty = listWslDistrosAsync() + const failing = listWslDistrosAsync() + callbacks[0](null, '') + callbacks[1](new Error('transient failure'), '') + await Promise.all([empty, failing]) + + vi.advanceTimersByTime(15_000) + expect(listWslDistros()).toEqual([]) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the cap is the only bound on how long a distro installed mid-session stays + // invisible, so pin it rather than letting the doubling run away. + it('caps the empty-list backoff at five minutes', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValue('') + + try { + withPlatform('win32', () => { + // Windows double to 15/30/60/120/240s, so the 6th would be 480s uncapped. + for (const delayMs of [0, 15_000, 30_000, 60_000, 120_000, 240_000]) { + vi.advanceTimersByTime(delayMs) + listWslDistros() + } + expect(execFileSyncMock).toHaveBeenCalledTimes(6) + vi.advanceTimersByTime(300_000) + listWslDistros() + expect(execFileSyncMock).toHaveBeenCalledTimes(7) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an empty list is a real probe result and must keep driving the + // `wsl-distro-missing` repair prompt even once stale. Going null instead fails + // open and silently spawns `wsl.exe -d ` for a distro Orca saw was absent. + it('keeps reporting an empty result after it goes stale', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValue('') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual([]) + expect(getCachedWslDistros()).toEqual([]) + vi.advanceTimersByTime(15_000) + expect(getCachedWslDistros()).toEqual([]) + expect(hasCachedWslDistros()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: a genuinely missing distro yields a non-empty list, which never expires, + // so repair-required still fires for the case that warrants it. + it('keeps reporting a known distro list indefinitely', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + + try { + withPlatform('win32', () => { + expect(listWslDistros()).toEqual(['Ubuntu']) + vi.advanceTimersByTime(600_000) + expect(getCachedWslDistros()).toEqual(['Ubuntu']) + }) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('WSL availability cache', () => { + afterEach(() => { + execFileMock.mockReset() + execFileSyncMock.mockReset() + _resetWslCachesForTests() + }) + + // Why: a cold WSL2 utility-VM boot on a just-installed or just-rebooted + // machine routinely exceeds the 5s probe timeout. Latching false for the + // process lifetime is what makes WSL vanish from the picker after setup. + it('retries availability after a probe timeout instead of latching false', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + execFileSyncMock.mockReturnValueOnce('') + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the probe blocks the main process for up to 5s, so a timeout must not + // let every caller re-spawn it immediately. + it('bounds the probe rate while the failure window is open', () => { + execFileSyncMock.mockImplementation(() => { + // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + it('caches a successful probe for the process lifetime', () => { + execFileSyncMock.mockReturnValueOnce('') + + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(true) + expect(isWslAvailable()).toBe(true) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + }) + }) + + // Why: the resolver reads the cached getter, not the probe. Reporting the last + // observed answer keeps the `wsl-unavailable` repair prompt reachable; going null + // on staleness would let git and PTY silently resolve to a WSL that just failed. + it('keeps reporting the last observed answer after it goes stale', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementation(() => { + // Real execFileSync timeout shape on Windows: status null, signal SIGTERM. + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(getCachedWslAvailability()).toBe(false) + expect(hasCachedWslAvailability()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an answer-shaped failure earns a long window, not a session-long latch — + // wsl.exe also exits non-zero while the WSL package is servicing or LxssManager is + // still starting, which is transient. + it.each([ + ['wsl.exe reports WSL unusable', { status: 1 }], + ['wsl.exe is not installed', { code: 'ENOENT' }] + ])('holds a definitive failure far longer than a timeout when %s', (_label, errorShape) => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('definitive failure'), errorShape) + }) + execFileSyncMock.mockReturnValueOnce('') + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(getCachedWslAvailability()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(10 * 60_000) + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the probe blocks the main process for up to 5s, so a wedged wsl.exe must not + // be re-probed on every window boundary for the rest of the session. + it('backs off after repeated probe failures', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('spawnSync ETIMEDOUT'), { + code: 'ETIMEDOUT', + status: null, + signal: 'SIGTERM' + }) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + // Second failure doubles the window, so the next boundary is not enough. + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: a seeded retryable failure must re-probe once its window lapses, the same + // as one observed live — otherwise test setup can hide the latch this fixes. + it('re-probes a seeded retryable failure once its window lapses', () => { + vi.useFakeTimers() + execFileSyncMock.mockReturnValueOnce('') + + try { + withPlatform('win32', () => { + _setWslCachesForTests({ available: false, availabilityRetryable: true }) + expect(isWslAvailable()).toBe(false) + vi.advanceTimersByTime(45_000) + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: the caches expire independently and `getWslRepairReason` checks availability + // first, so a definitive failure held for 10min would report `wsl-unavailable` over a + // WSL that just listed a distro. Finding a distro must drop the stale failure. + it.each([ + ['a definitive failure', { status: 1 }], + ['a timeout', { code: 'ETIMEDOUT', status: null, signal: 'SIGTERM' }] + ])('re-probes availability once a distro list succeeds after %s', (_label, errorShape) => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('probe failed'), errorShape) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + + // A distro turns up (WSL finished provisioning / was repaired mid-session). + execFileSyncMock.mockReturnValueOnce('Ubuntu\n') + expect(listWslDistros()).toEqual(['Ubuntu']) + + // Without dropping the stale failure this would stay false for 10min. + execFileSyncMock.mockReturnValueOnce('') + expect(isWslAvailable()).toBe(true) + }) + } finally { + vi.useRealTimers() + } + }) + + // Why: an empty list re-probes on a 15s-to-5min schedule, so clearing the availability + // failure on every empty probe would re-spawn the blocking 5s probe far too often. + it('does not drop an availability failure for an empty distro list', () => { + vi.useFakeTimers() + execFileSyncMock.mockImplementationOnce(() => { + throw Object.assign(new Error('probe failed'), { status: 1 }) + }) + + try { + withPlatform('win32', () => { + expect(isWslAvailable()).toBe(false) + execFileSyncMock.mockReturnValueOnce('') + expect(listWslDistros()).toEqual([]) + expect(getCachedWslAvailability()).toBe(false) + // Still inside the definitive window, so no re-probe was paid. + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + expect(isWslAvailable()).toBe(false) + expect(execFileSyncMock).toHaveBeenCalledTimes(2) + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps reporting unavailable off Windows without probing', () => { + withPlatform('darwin', () => { + expect(isWslAvailable()).toBe(false) + expect(getCachedWslAvailability()).toBe(false) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + }) }) describe('wsl path helpers', () => { diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 504ac981010..b45c40fe9d3 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -1,5 +1,7 @@ import { execFile, execFileSync } from 'node:child_process' import { parseWslUncPath, toWindowsWslPath } from '../shared/wsl-paths' +import { filterUserWslDistros, parseWslDistros } from './wsl-distro-list-output' +import { wslDistroListRetryDelayMs } from './wsl-distro-retry' export { toWindowsWslPath } from '../shared/wsl-paths' @@ -99,26 +101,66 @@ let wslDistroCache: string[] | null = null // otherwise hide every distro until restart), but repeated failures cannot // re-spawn a blocking wsl.exe on every caller; brief negative caching bounds // the spawn rate on machines where WSL is absent or persistently broken. -const WSL_DISTRO_LIST_FAILURE_TTL_MS = 15_000 -let wslDistroListFailedUntilMs = 0 +// Empty and failed probes back off from 15 seconds to a five-minute cap. +let wslDistroListRetryAfterMs = 0 +let wslDistroListEmptyStreak = 0 +let wslDistroProbeSequence = 0 +let wslDistroCacheSequence = 0 +// Why: availability is a separate, blocking probe. Deliberately not a multiple of the +// renderer's 30s capability TTL, so repeated refreshes don't land on this boundary and +// re-probe every cycle. +const WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS = 45_000 -function normalizeWslListOutput(output: string): string[] { - // Why: wsl.exe can emit UTF-16-looking NUL bytes when inherited through - // some Windows shells; strip them before line parsing. - return output - .replaceAll(String.fromCharCode(0), '') - .split(/\r?\n/) - .map((line) => line.trim().replace(/^\*\s*/, '')) - .filter(Boolean) +function armWslDistroListRetry(): void { + const now = Date.now() + // Concurrent completions belong to the retry window already armed by the first result. + if (now < wslDistroListRetryAfterMs) { + return + } + wslDistroListEmptyStreak += 1 + wslDistroListRetryAfterMs = now + wslDistroListRetryDelayMs(wslDistroListEmptyStreak) } -function isUserWslDistro(distro: string): boolean { - return !distro.toLowerCase().startsWith('docker-desktop') +// Why: `wsl --install` reports zero distros while one is still provisioning, so an +// empty answer is transient and must stay retryable — caching it for the process +// lifetime is what made WSL vanish from the picker after setup. It is still cached +// for reads, so a missing distro stays visible to `isKnownMissingDistro`. +function cacheWslDistroList(rawDistros: string[], probeSequence: number): string[] { + const userDistros = filterUserWslDistros(rawDistros) + // An older positive result must not replace the newer lifetime-stable list. + if ( + probeSequence < wslDistroCacheSequence && + (userDistros.length === 0 || (wslDistroCache?.length ?? 0) > 0) + ) { + return wslDistroCache ?? [] + } + // Why: probes overlap and can resolve out of order — a slow pre-registration wsl.exe + // can land after a fast one that already found the distro. A late empty answer must + // not erase that list, or provisioning reverts to "no distros" and backs off again. + if (userDistros.length === 0 && wslDistroCache !== null && wslDistroCache.length > 0) { + return wslDistroCache + } + if (userDistros.length > 0) { + dropStaleWslAvailabilityFailure() + } + wslDistroCacheSequence = probeSequence + wslDistroCache = userDistros + if (wslDistroCache.length === 0) { + armWslDistroListRetry() + } + return wslDistroCache +} + +/** A non-empty list is stable; an empty one re-probes once the retry window elapses. */ +function shouldReuseCachedWslDistros(): boolean { + return ( + wslDistroCache !== null && (wslDistroCache.length > 0 || Date.now() < wslDistroListRetryAfterMs) + ) } export function listWslDistros(): string[] { - if (wslDistroCache) { - return wslDistroCache + if (shouldReuseCachedWslDistros()) { + return wslDistroCache ?? [] } if (process.platform !== 'win32') { @@ -126,27 +168,27 @@ export function listWslDistros(): string[] { return wslDistroCache } - if (Date.now() < wslDistroListFailedUntilMs) { - return [] + if (Date.now() < wslDistroListRetryAfterMs) { + return wslDistroCache ?? [] } try { + const probeSequence = ++wslDistroProbeSequence const output = execFileSync('wsl.exe', ['--list', '--quiet'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }) - wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro) - return wslDistroCache + return cacheWslDistroList(parseWslDistros(output), probeSequence) } catch { - wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS - return [] + armWslDistroListRetry() + return wslDistroCache ?? [] } } export async function listWslDistrosAsync(): Promise { - if (wslDistroCache !== null) { - return wslDistroCache + if (shouldReuseCachedWslDistros()) { + return wslDistroCache ?? [] } if (process.platform !== 'win32') { @@ -154,17 +196,17 @@ export async function listWslDistrosAsync(): Promise { return wslDistroCache } - if (Date.now() < wslDistroListFailedUntilMs) { - return [] + if (Date.now() < wslDistroListRetryAfterMs) { + return wslDistroCache ?? [] } try { + const probeSequence = ++wslDistroProbeSequence const output = await execFileUtf8('wsl.exe', ['--list', '--quiet']) - wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro) - return wslDistroCache + return cacheWslDistroList(parseWslDistros(output), probeSequence) } catch { - wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS - return [] + armWslDistroListRetry() + return wslDistroCache ?? [] } } @@ -172,6 +214,11 @@ export function hasCachedWslDistros(): boolean { return wslDistroCache !== null } +// Why: report the last observed answer even once it is stale. An empty list is a real +// probe result, so it must keep driving the `wsl-distro-missing` repair prompt; going +// null instead fails open and silently spawns `wsl.exe -d ` for a distro Orca +// last saw was absent. Staleness self-corrects — `listWslDistros` re-probes after the +// retry window and a distro installed since clears the prompt on its own. export function getCachedWslDistros(): string[] | null { return wslDistroCache } @@ -234,20 +281,82 @@ export async function getWslHomeAsync(distro: string): Promise { } } -// Cached WSL availability check — evaluated once per process lifetime -let wslAvailableCache: boolean | null = null +type WslAvailabilityCache = + | { available: true } + /** Not Windows — never re-probed. */ + | { available: false; unsupported: true } + | { available: false; cachedAt: number; retryable: boolean; failures: number } + +function isPermanentWslAvailabilityCache(cache: WslAvailabilityCache): boolean { + // Why: re-check the platform so a cache seeded off-Windows can't suppress a real probe. + return cache.available || ('unsupported' in cache && process.platform !== 'win32') +} + +let wslAvailableCache: WslAvailabilityCache | null = null + +// Why: even a definitive-looking non-zero exit can be transient — wsl.exe reports one +// while the WSL package is servicing or LxssManager is still starting — so nothing +// latches for the whole session; it just waits much longer before paying the probe again. +const WSL_AVAILABILITY_DEFINITIVE_TTL_MS = 10 * 60_000 +const WSL_AVAILABILITY_MAX_RETRY_DELAY_MS = 30 * 60_000 + +// Why: the probe blocks the main process for up to 5s, so a host with a wedged wsl.exe +// must not pay it every window; back off per consecutive failure. +function wslAvailabilityRetryDelayMs(cache: { retryable: boolean; failures: number }): number { + const base = cache.retryable + ? WSL_AVAILABILITY_NEGATIVE_CACHE_TTL_MS + : WSL_AVAILABILITY_DEFINITIVE_TTL_MS + return Math.min(base * 2 ** (cache.failures - 1), WSL_AVAILABILITY_MAX_RETRY_DELAY_MS) +} + +// Why: a numeric `status` (wsl.exe ran and said no) or ENOENT (not installed) is +// answer-shaped, so it earns a long window rather than the short one a timeout gets. +// Same numeric-status rule as `wslUncDirectoryExists`; neither latches forever. +function isRetryableWslProbeFailure(error: unknown): boolean { + const failure = error as { status?: unknown; code?: unknown } | null + if (typeof failure?.status === 'number') { + return false + } + return failure?.code !== 'ENOENT' +} + +// Why: the two caches expire independently, and `getWslRepairReason` checks availability +// first — so a definitive failure held for 10-30min would report `wsl-unavailable` over a +// WSL that just listed a distro for us. A non-empty list proves wsl.exe ran, so drop the +// stale failure and let the next call re-probe. Non-empty lists are cached for the process +// lifetime, so this cannot re-spawn the blocking probe more than once. +function dropStaleWslAvailabilityFailure(): void { + if (wslAvailableCache && !wslAvailableCache.available && !('unsupported' in wslAvailableCache)) { + wslAvailableCache = null + } +} + +function isWslAvailabilityCacheFresh(cache: WslAvailabilityCache): boolean { + if (isPermanentWslAvailabilityCache(cache)) { + return true + } + if (!('cachedAt' in cache)) { + return false + } + return Date.now() - cache.cachedAt < wslAvailabilityRetryDelayMs(cache) +} /** * Check whether wsl.exe is available and functional on this Windows machine. - * Result is cached for the process lifetime. + * Success caches for the process lifetime; every failure is re-probed eventually, so + * a slow wsl.exe activation on a just-installed or just-rebooted machine cannot latch + * WSL off for the whole session. */ export function isWslAvailable(): boolean { - if (wslAvailableCache !== null) { - return wslAvailableCache + if (wslAvailableCache && isWslAvailabilityCacheFresh(wslAvailableCache)) { + return wslAvailableCache.available } + const previousFailures = + wslAvailableCache && 'failures' in wslAvailableCache ? wslAvailableCache.failures : 0 + if (process.platform !== 'win32') { - wslAvailableCache = false + wslAvailableCache = { available: false, unsupported: true } return false } @@ -256,35 +365,70 @@ export function isWslAvailable(): boolean { stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }) - wslAvailableCache = true - } catch { - wslAvailableCache = false + wslAvailableCache = { available: true } + } catch (error) { + wslAvailableCache = { + available: false, + cachedAt: Date.now(), + retryable: isRetryableWslProbeFailure(error), + failures: previousFailures + 1 + } } - return wslAvailableCache + return wslAvailableCache.available } export function hasCachedWslAvailability(): boolean { return wslAvailableCache !== null } +// Why: same contract as the distro getter — report the last observed answer. Going +// null on staleness would drop the `wsl-unavailable` repair prompt and let git and +// PTY silently resolve to a WSL that last failed to respond. `isWslAvailable` is what +// clears it, by re-probing once the retry window lapses. export function getCachedWslAvailability(): boolean | null { - return wslAvailableCache + return wslAvailableCache?.available ?? null } export function _resetWslCachesForTests(): void { wslHomeCache.clear() wslDistroCache = null - wslDistroListFailedUntilMs = 0 + wslDistroListRetryAfterMs = 0 + wslDistroListEmptyStreak = 0 + wslDistroProbeSequence = 0 + wslDistroCacheSequence = 0 wslAvailableCache = null } +// Why: seeded state expires like real state — an `available: false` seed is re-probed +// once its window lapses, and a `distros` seed is filtered and arms the retry window. +// Tests that advance timers past either window must mock child_process. export function _setWslCachesForTests(args: { available?: boolean | null distros?: string[] | null + availabilityRetryable?: boolean }): void { - wslAvailableCache = args.available ?? null - wslDistroCache = args.distros ?? null + wslAvailableCache = + args.available === true + ? { available: true } + : args.available === false + ? { + available: false, + cachedAt: Date.now(), + retryable: args.availabilityRetryable ?? false, + failures: 1 + } + : null + // Why: seed through the real cache path so an empty seed arms the retry window + // too — otherwise a seeded [] lets the next call spawn a real 5s wsl.exe. + wslDistroListRetryAfterMs = 0 + wslDistroListEmptyStreak = 0 + wslDistroProbeSequence = 0 + wslDistroCacheSequence = 0 + wslDistroCache = null + if (args.distros) { + cacheWslDistroList(args.distros, ++wslDistroProbeSequence) + } } function execFileUtf8(command: string, args: string[]): Promise { diff --git a/src/relay/dispatcher-frame-guard-regressions.test.ts b/src/relay/dispatcher-frame-guard-regressions.test.ts new file mode 100644 index 00000000000..71859e05c2f --- /dev/null +++ b/src/relay/dispatcher-frame-guard-regressions.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { RelayDispatcher } from './dispatcher' +import type { JsonRpcNotification } from './protocol' + +type DispatcherInternals = { + primaryClient: object + estimateFrameBytes: (msg: JsonRpcNotification) => number + enqueueFrame: (client: object, msg: JsonRpcNotification, lane: string) => boolean +} + +describe('RelayDispatcher frame guards', () => { + it('returns zero for invalid active-client limits without encoding', () => { + const dispatcher = new RelayDispatcher(() => true, { + writableHighWaterMark: () => 1024 * 1024, + writableLength: () => 0 + }) + try { + const spy = vi.spyOn(dispatcher as unknown as DispatcherInternals, 'estimateFrameBytes') + for (const limit of [0, -1, Number.NaN]) { + expect(dispatcher.maxLegacyPtyDataChars({ id: 'pty-1' }, 'hello', limit)).toBe(0) + } + expect(spy).not.toHaveBeenCalled() + } finally { + dispatcher.dispose() + } + }) + + it('does not estimate frames after disposal', () => { + const dispatcher = new RelayDispatcher(() => true) + const internals = dispatcher as unknown as DispatcherInternals + const spy = vi.spyOn(internals, 'estimateFrameBytes') + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { + data: { + toJSON: () => { + throw new Error('must not serialize') + } + } + } + } + dispatcher.dispose() + expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(false) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index aaa083b7743..3ec57dcf899 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' +import { relayWriterControlReserve } from './dispatcher-writer-admission' import { encodeJsonRpcFrame, encodeKeepAliveFrame, @@ -716,4 +717,196 @@ describe('RelayDispatcher', () => { } }) }) + + describe('legacy PTY chunk sizing', () => { + type DispatcherInternals = { + primaryClient: object + estimateFrameBytes: (msg: JsonRpcNotification) => number + enqueueFrame: ( + client: object, + msg: JsonRpcNotification, + lane: string, + onSettled?: (result: SinkWriteSettlement) => void, + estimatedBytes?: number + ) => boolean + } + + // Pre-optimization sizing loop, kept verbatim for differential verification. + function referenceMaxChars( + capacities: number[], + params: Record, + data: string, + limit: number + ): number { + if (capacities.length === 0) { + return Math.min(data.length, limit) + } + let low = 0 + let high = Math.min(data.length, limit) + while (low < high) { + const mid = Math.ceil((low + high) / 2) + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { ...params, data: data.slice(0, mid) } + } + const bytes = encodeJsonRpcFrame(msg, 0, 0).length + if (capacities.every((capacity) => bytes <= capacity)) { + low = mid + } else { + high = mid - 1 + } + } + return low + } + + function makeDispatcher(highWaterMarks: number[]): { + sized: RelayDispatcher + capacities: number[] + } { + const [primaryHwm, ...rest] = highWaterMarks + const sized = new RelayDispatcher(() => true, { + writableHighWaterMark: () => primaryHwm, + writableLength: () => 0 + }) + for (const hwm of rest) { + sized.attachClient(() => true, { + writableHighWaterMark: () => hwm, + writableLength: () => 0 + }) + } + return { + sized, + capacities: highWaterMarks.map((hwm) => Math.max(0, hwm - relayWriterControlReserve(hwm))) + } + } + + function mulberry32(seed: number): () => number { + let a = seed + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + } + + // Multi-byte UTF-8, astral pairs, lone surrogates, controls, quotes, and backslashes. + const alphabet = 'aZ9 "\\\n\r\té߀中𝄞😀𐀀�' + + it('matches the pre-optimization sizing loop across randomized inputs', () => { + const random = mulberry32(0xc0ffee) + const hwmChoices = [1030, 1100, 1250, 1400, 2048, 1024 * 1024] + for (let trial = 0; trial < 300; trial++) { + const length = Math.floor(random() * 240) + let data = '' + for (let i = 0; i < length; i++) { + data += alphabet[Math.floor(random() * alphabet.length)] + } + const clientCount = 1 + Math.floor(random() * 2) + const hwms = Array.from( + { length: clientCount }, + () => hwmChoices[Math.floor(random() * hwmChoices.length)] + ) + const params = { id: `pty-${trial}`, seq: trial } + const { sized, capacities } = makeDispatcher(hwms) + try { + for (const limit of [0, 1, Math.floor(length / 2), length, length + 17]) { + expect(sized.maxLegacyPtyDataChars(params, data, limit)).toBe( + referenceMaxChars(capacities, params, data, limit) + ) + } + expect(sized.maxLegacyPtyDataChars(params, data)).toBe( + referenceMaxChars(capacities, params, data, data.length) + ) + } finally { + sized.dispose() + } + } + }) + + it('sizes a chunk that fits every client with a single frame encode', () => { + const { sized } = makeDispatcher([1024 * 1024]) + try { + const spy = vi.spyOn(sized as unknown as DispatcherInternals, 'estimateFrameBytes') + const data = 'x'.repeat(16 * 1024) + expect(sized.maxLegacyPtyDataChars({ id: 'pty-1' }, data)).toBe(data.length) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + sized.dispose() + } + }) + + it('publishes PTY data with a single frame estimate', () => { + const frames: Buffer[] = [] + const publisher = new RelayDispatcher((data) => { + frames.push(Buffer.from(data)) + return true + }) + try { + const spy = vi.spyOn(publisher as unknown as DispatcherInternals, 'estimateFrameBytes') + expect(publisher.tryNotifyPtyData({ id: 'pty-1', data: 'hello' })).toBe(true) + expect(frames).toHaveLength(1) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + publisher.dispose() + } + }) + + it('enqueueFrame with a caller-supplied estimate matches the computed default', () => { + const frames: Buffer[] = [] + const publisher = new RelayDispatcher((data) => { + frames.push(Buffer.from(data)) + return true + }) + try { + const internals = publisher as unknown as DispatcherInternals + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { id: 'pty-1', data: 'héllo "𝄞"\\\n\uD800' } + } + expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(true) + expect( + internals.enqueueFrame( + internals.primaryClient, + msg, + 'ordinary', + undefined, + internals.estimateFrameBytes(msg) + ) + ).toBe(true) + expect(frames).toHaveLength(2) + expect( + decodeFirstFrame(frames[1]).payload.equals(decodeFirstFrame(frames[0]).payload) + ).toBe(true) + } finally { + publisher.dispose() + } + }) + + it('enqueueFrame rejects identically with and without a caller-supplied estimate', () => { + const { sized } = makeDispatcher([1030]) + try { + const internals = sized as unknown as DispatcherInternals + const msg: JsonRpcNotification = { + jsonrpc: '2.0', + method: 'pty.data', + params: { id: 'pty-1', data: 'x'.repeat(512) } + } + expect(internals.enqueueFrame(internals.primaryClient, msg, 'ordinary')).toBe(false) + expect( + internals.enqueueFrame( + internals.primaryClient, + msg, + 'ordinary', + undefined, + internals.estimateFrameBytes(msg) + ) + ).toBe(false) + } finally { + sized.dispose() + } + }) + }) }) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 833081c65fa..3809b9f5f80 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -193,20 +193,34 @@ export class RelayDispatcher { limit = data.length ): number { const clients = this.activeClients() + const max = Math.min(data.length, limit) if (clients.length === 0) { - return Math.min(data.length, limit) + return max } - let low = 0 - let high = Math.min(data.length, limit) - while (low < high) { - const mid = Math.ceil((low + high) / 2) - const msg: JsonRpcNotification = { + if (!(max > 0)) { + return 0 + } + const fitsAll = (bytes: number): boolean => + clients.every((client) => bytes <= client.writer.producerFrameCapacity) + const sizeFrame = (chunk: string): number => + this.estimateFrameBytes({ jsonrpc: '2.0', method: 'pty.data', - params: { ...params, data: data.slice(0, mid) } - } - const bytes = this.estimateFrameBytes(msg) - if (clients.every((client) => bytes <= client.writer.producerFrameCapacity)) { + params: { ...params, data: chunk } + }) + // Fast path: the whole chunk usually fits — one encode instead of log2(n). + if (fitsAll(sizeFrame(data.slice(0, max)))) { + return max + } + // Exact per-step size: only the escaped data string varies; its quotes are in baseBytes. + const baseBytes = sizeFrame('') + const bytesFor = (chars: number): number => + baseBytes + Buffer.byteLength(JSON.stringify(data.slice(0, chars))) - 2 + let low = 0 + let high = max + while (low < high) { + const mid = Math.ceil((low + high) / 2) + if (fitsAll(bytesFor(mid))) { low = mid } else { high = mid - 1 @@ -822,19 +836,21 @@ export class RelayDispatcher { client: RelayClient, msg: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification, lane: DispatcherWriterLane, - onSettled: (result: SinkWriteSettlement) => void = () => {} + onSettled: (result: SinkWriteSettlement) => void = () => {}, + // Why: publish paths already sized the frame; avoid a redundant encode. + estimatedBytes?: number ): boolean { if (this.disposed || client.closed) { return false } - const estimatedBytes = this.estimateFrameBytes(msg) + const frameBytes = estimatedBytes ?? this.estimateFrameBytes(msg) return client.writer.enqueue( lane, () => { const seq = client.nextOutgoingSeq++ return encodeJsonRpcFrame(msg, seq, client.highestReceivedSeq) }, - estimatedBytes, + frameBytes, onSettled ) } @@ -898,7 +914,7 @@ export class RelayDispatcher { return false } for (let index = 0; index < clients.length; index++) { - if (!this.enqueueLeasedFrame(clients[index], msg, lane, leases[index])) { + if (!this.enqueueLeasedFrame(clients[index], msg, lane, leases[index], bytes)) { if (this.disposed || clients[index].closed) { continue } @@ -949,7 +965,7 @@ export class RelayDispatcher { if (!leases) { return false } - return this.enqueueLeasedFrame(client, msg, lane, leases[0], onSettled) + return this.enqueueLeasedFrame(client, msg, lane, leases[0], bytes, onSettled) } private publishBulkWhenAvailable(client: RelayClient, msg: JsonRpcNotification): Promise { @@ -998,13 +1014,20 @@ export class RelayDispatcher { msg: JsonRpcNotification, lane: 'interactive' | 'ordinary' | 'fixed-bulk' | 'bulk', lease: LegacyPublicationLease, + estimatedBytes: number, onSettled: (result: SinkWriteSettlement) => void = () => {} ): boolean { - const accepted = this.enqueueFrame(client, msg, lane, (result) => { - lease.release() - onSettled(result) - this.notifyLegacyCapacityIfLow() - }) + const accepted = this.enqueueFrame( + client, + msg, + lane, + (result) => { + lease.release() + onSettled(result) + this.notifyLegacyCapacityIfLow() + }, + estimatedBytes + ) if (!accepted) { lease.release() this.notifyLegacyCapacityIfLow() diff --git a/src/relay/pty-handler-source-publication.test.ts b/src/relay/pty-handler-source-publication.test.ts index a99610671ec..282d9f536db 100644 --- a/src/relay/pty-handler-source-publication.test.ts +++ b/src/relay/pty-handler-source-publication.test.ts @@ -56,6 +56,11 @@ describe('PtyHandler negotiated source publication', () => { let heldResponseSettlements: ((result: SinkWriteSettlement) => void)[] let adapter: SshPtyConsumerSessionAdapter let pausePty: ReturnType + let exitCallback: ((event: { exitCode: number }) => void) | undefined + let destroyPty: ReturnType + let holdDataSettlements: boolean + let heldDataSettlements: ((result: SinkWriteSettlement) => void)[] + let highWaterMark: number | undefined beforeEach(async () => { vi.useFakeTimers() @@ -64,20 +69,28 @@ describe('PtyHandler negotiated source publication', () => { heldResponseId = null heldResponseSettlements = [] dataCallback = undefined + exitCallback = undefined + holdDataSettlements = false + heldDataSettlements = [] + highWaterMark = undefined pausePty = vi.fn() + destroyPty = vi.fn() mockPtySpawn.mockReset() mockPtySpawn.mockReturnValue({ pid: process.pid, onData: vi.fn((callback: (data: string) => void) => { dataCallback = callback }), - onExit: vi.fn(), + onExit: vi.fn((callback: (event: { exitCode: number }) => void) => { + exitCallback = callback + }), write: vi.fn(), resize: vi.fn(), kill: vi.fn(), clear: vi.fn(), pause: pausePty, - resume: vi.fn() + resume: vi.fn(), + destroy: destroyPty }) dispatcher = new RelayDispatcher( (data, settle) => { @@ -86,10 +99,24 @@ describe('PtyHandler negotiated source publication', () => { heldResponseSettlements.push(settle) return true } + const frame = notification(data) + if (holdDataSettlements && frame?.method === 'pty.data') { + heldDataSettlements.push(settle) + return true + } + if (frame?.method === 'pty.exit') { + // Why: real sockets never settle inside write(); a synchronous settle would re-enter + // the legacy capacity path before the pending exit is retired. + queueMicrotask(() => settle({ ok: true })) + return true + } settle({ ok: true }) return true }, - { supportsWriteCallback: true }, + { + supportsWriteCallback: true, + writableHighWaterMark: () => highWaterMark ?? 0 + }, endpointIdentity ) handler = new PtyHandler(dispatcher) @@ -132,6 +159,133 @@ describe('PtyHandler negotiated source publication', () => { .filter((frame): frame is Notification => frame?.method === 'pty.data') } + function exitFrames(): Notification[] { + return writes + .map(notification) + .filter((frame): frame is Notification => frame?.method === 'pty.exit') + } + + async function cancelSourceDelivery(spawnResult: Record): Promise { + const activation = spawnResult.sourceActivation as Record + dispatcher.feed( + requestFrame(9, 'pty.cancelDelivery', { + id: spawnResult.id, + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + deliveryToken: activation.deliveryToken + }) + ) + await vi.advanceTimersByTimeAsync(0) + } + + function stubPublication(overrides: Partial>): RelayPtySourcePublication { + return { + accepts: () => true, + exitPublicationSettled: () => false, + sealAndPublishExit: () => false, + publish: () => false, + onCreditAvailable: () => {}, + receivingActivation: () => undefined, + waitForPendingSend: async () => true, + activate: () => false, + getDebugSnapshot: () => ({}), + dispose: () => {}, + ...overrides + } as unknown as RelayPtySourcePublication + } + + it('publishes a single legacy exit after the owner cancels its delivery', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + dataCallback!('prompt') + await vi.advanceTimersByTimeAsync(8) + await cancelSourceDelivery(spawnResult) + expect(publication.accepts(String(spawnResult.id))).toBe(false) + + expect(() => exitCallback!({ exitCode: 0 })).not.toThrow() + + expect(exitFrames()).toHaveLength(1) + expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 0 }) + expect(destroyPty).toHaveBeenCalledOnce() + + handler.handleSourcePublicationCapacity(String(spawnResult.id)) + await vi.advanceTimersByTimeAsync(8) + expect(exitFrames()).toHaveLength(1) + }) + + it('drains buffered legacy output before the exit when capacity returns', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + await cancelSourceDelivery(spawnResult) + holdDataSettlements = true + + dataCallback!('first') + await vi.advanceTimersByTimeAsync(8) + expect(heldDataSettlements).toHaveLength(1) + // Why: shrinking the frame capacity makes the next chunk unpublishable, so it stays queued. + highWaterMark = 64 + dataCallback!('second') + await vi.advanceTimersByTimeAsync(8) + + expect(() => exitCallback!({ exitCode: 3 })).not.toThrow() + expect(exitFrames()).toHaveLength(0) + + highWaterMark = undefined + holdDataSettlements = false + heldDataSettlements[0]({ ok: true }) + await vi.advanceTimersByTimeAsync(8) + + const frames = writes + .map(notification) + .filter( + (frame): frame is Notification => + frame?.method === 'pty.data' || frame?.method === 'pty.exit' + ) + expect(frames.map((frame) => frame.method)).toEqual(['pty.data', 'pty.data', 'pty.exit']) + expect(frames.map((frame) => frame.params.data)).toEqual(['first', 'second', undefined]) + expect(frames.at(-1)!.params).toMatchObject({ id: spawnResult.id, code: 3 }) + }) + + it('contains a source publication fault and still delivers the legacy exit', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + handler.setSourcePublication( + stubPublication({ + sealAndPublishExit: () => { + throw new Error('ledger delivery vanished') + } + }) + ) + + expect(() => exitCallback!({ exitCode: 7 })).not.toThrow() + + expect(exitFrames()).toHaveLength(1) + expect(exitFrames()[0].params).toMatchObject({ id: spawnResult.id, code: 7 }) + expect(String(stderr.mock.calls.at(-1)?.[0])).toContain( + '[pty-handler] pty source exit publication failed' + ) + } finally { + stderr.mockRestore() + } + }) + + it('completes the exit from the settled state without re-sealing the delivery', async () => { + await spawn({}) + const spawnResult = writes.map((buffer) => responseResult(buffer, 2)).find(Boolean)! + const sealAndPublishExit = vi.fn(() => true) + handler.setSourcePublication( + stubPublication({ exitPublicationSettled: () => true, sealAndPublishExit }) + ) + + exitCallback!({ exitCode: 0 }) + handler.handleSourcePublicationCapacity(String(spawnResult.id)) + + expect(sealAndPublishExit).not.toHaveBeenCalled() + expect(exitFrames()).toHaveLength(0) + }) + it('fences the first source frame behind immutable spawn and attach activation metadata', async () => { heldResponseId = 2 await spawn({}) @@ -301,6 +455,34 @@ describe('PtyHandler negotiated source publication', () => { expect(adapter.getDebugSnapshot()).toMatchObject({ deliveryTokens: 1 }) }) + it('republishes the identical memoized span after a failed projection', async () => { + await spawn({}) + const appendSpy = vi.spyOn(adapter, 'appendSource') + const projectSpy = vi + .spyOn(dispatcher, 'projectPtyDataToMatchingClients') + .mockReturnValueOnce(false) + // Why: a larger capacity result on the retry must not move the memoized span boundary. + const maxCharsSpy = vi.spyOn(dispatcher, 'maxLegacyPtyDataChars') + maxCharsSpy.mockReturnValueOnce(4).mockReturnValue(8) + + dataCallback!('abcdefgh') + await vi.advanceTimersByTimeAsync(8) + expect(projectSpy).toHaveBeenCalledTimes(1) + + handler.handleSourcePublicationCapacity('pty-1') + await vi.advanceTimersByTimeAsync(8) + + // Retried projection carries the byte-identical span params. + expect(projectSpy.mock.calls[1][1]).toEqual(projectSpy.mock.calls[0][1]) + const spans = appendSpy.mock.calls.map(([, input]) => input) + expect(spans.filter((span) => span.data === 'abcd')).toHaveLength(1) + expect(spans.map((span) => span.data).join('')).toBe('abcdefgh') + expect(spans.map((span) => [span.displayStart, span.displayEnd])).toEqual([ + [0, 4], + [4, 8] + ]) + }) + it('keeps the native PTY and V1 owner live when one subscriber saturates', async () => { await spawn({}) const detached: number[] = [] diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 1a204088a8b..c81d2b5a6a1 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -1340,6 +1340,123 @@ describe('PtyHandler', () => { ]) }) + describe('legacy flush retry with a memoized source chunk', () => { + let capacityListener: (() => void) | undefined + let hasCapacity: boolean + let maxChars: number + let admitted: Record[] + let dataCallback: ((data: string) => void) | undefined + + async function setupRetryHarness(spawnParams: Record = {}): Promise { + await handler.dispose({ waitForPhysicalExit: false }) + capacityListener = undefined + hasCapacity = false + admitted = [] + Object.assign(dispatcher, { + onLegacyPtyCapacity: vi.fn((listener: () => void) => { + capacityListener = listener + return vi.fn() + }), + tryNotifyPtyData: vi.fn((params: Record) => { + if (hasCapacity) { + admitted.push(params) + } + return hasCapacity + }), + tryNotifyPtyExit: vi.fn(() => true), + legacyRetentionBelowLowWater: true, + // Clamped like the real dispatcher: never more than min(data.length, limit). + maxLegacyPtyDataChars: vi.fn((_params: unknown, data: string, limit?: number) => + Math.min(maxChars, data.length, limit ?? data.length) + ) + }) + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + dataCallback = undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn((callback: (data: string) => void) => { + dataCallback = callback + }), + onExit: vi.fn() + }) + await dispatcher.callRequest('pty.spawn', spawnParams) + expect(dataCallback).toBeDefined() + } + + it('resends the memo verbatim and keeps the coalesced tail when capacity grows', async () => { + maxChars = 5_000 + await setupRetryHarness() + dataCallback!('a'.repeat(20_000)) + await vi.advanceTimersByTimeAsync(8) + // Why before restoring capacity: the failed batch made zero writes so no flush is + // rescheduled; this enqueue re-arms the timer. + dataCallback!('b'.repeat(10)) + hasCapacity = true + maxChars = 16_384 + await vi.runAllTimersAsync() + + expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(20_000) + 'b'.repeat(10)) + expect((admitted[0].data as string).length).toBe(5_000) + // Why: remainder frames must not leak transformed/rawLength/seq keys. + expect(admitted[1]).toStrictEqual({ id: 'pty-1', data: expect.any(String) }) + }) + + it('keeps a tail appended after a failed flush under constant capacity', async () => { + maxChars = 16_384 + await setupRetryHarness() + dataCallback!('a'.repeat(5_000)) + await vi.advanceTimersByTimeAsync(8) + dataCallback!('b'.repeat(10)) + hasCapacity = true + await vi.runAllTimersAsync() + + expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(5_000) + 'b'.repeat(10)) + expect(admitted[1].data).toBe('b'.repeat(10)) + }) + + it('does not duplicate memoized chars when capacity shrinks before the retry', async () => { + maxChars = 16_384 + await setupRetryHarness() + dataCallback!('a'.repeat(20_000)) + await vi.advanceTimersByTimeAsync(8) + maxChars = 5_000 + hasCapacity = true + // Why: nothing else re-arms the flush timer after a zero-write batch. + capacityListener!() + await vi.runAllTimersAsync() + + expect(admitted.map((frame) => frame.data).join('')).toBe('a'.repeat(20_000)) + expect((admitted[0].data as string).length).toBe(16_384) + expect((admitted[1].data as string).length).toBe(3_616) + }) + + it('retains a coalesced transformed raw advance behind a memoized source-only chunk', async () => { + maxChars = 16_384 + await setupRetryHarness({ + startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, + startupIngress: { + colors: { foreground: '#2e3434', background: '#ffffff' }, + deadlineMs: 5_000 + } + }) + // First answered query: direct publish fails, entry queued without a memo. + dataCallback!('\x1b]10;?\x07') + // Capacity event while still blocked: the flush fails and memoizes the source-only chunk. + capacityListener!() + await vi.advanceTimersByTimeAsync(0) + // Second answered query coalesces into the memoized transformed head. + dataCallback!('\x1b]11;?\x07') + hasCapacity = true + capacityListener!() + await vi.runAllTimersAsync() + + expect(admitted).toEqual([ + { id: 'pty-1', data: '', rawLength: 7, seq: 7, transformed: true }, + { id: 'pty-1', data: '', rawLength: 7, seq: 14, transformed: true } + ]) + }) + }) + it('leaves startup queries untouched for an unsupported relay capability version', async () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 552c1e8b974..c54e1d13e89 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -894,10 +894,15 @@ export class PtyHandler { ...(pending.rawLength === undefined ? {} : { rawLength: pending.rawLength }), ...(pending.transformed ? { transformed: true } : {}) } - let chunkChars = pending.transformed - ? desiredChars - : (this.dispatcher.maxLegacyPtyDataChars?.(paramsWithoutData, pending.data, desiredChars) ?? - desiredChars) + // Why: a failed publish may already have reserved this exact span (source-ledger append, + // partial legacy fan-out), so a retry must resend it verbatim and slice the remainder at + // the memo boundary — capacity and coalesced data can both have changed since. The capacity + // search is skipped on retry: its result is discarded, and publish re-checks capacity. + let chunkChars = + pending.transformed || pending.sourceChunk + ? desiredChars + : (this.dispatcher.maxLegacyPtyDataChars?.(paramsWithoutData, pending.data, desiredChars) ?? + desiredChars) if ( chunkChars > 0 && chunkChars < pending.data.length && @@ -914,8 +919,8 @@ export class PtyHandler { this.pausePtyOutput(id) return false } - const chunk = pending.data.slice(0, chunkChars) - const remaining = pending.data.slice(chunkChars) + const chunk = pending.sourceChunk?.data ?? pending.data.slice(0, chunkChars) + const remaining = pending.data.slice(chunk.length) const chunkRawLength = pending.transformed ? pending.rawLength : pending.rawLength === undefined @@ -938,10 +943,16 @@ export class PtyHandler { this.pausePtyOutput(id) return false } - if (remaining) { + // rawLength fallback is defensive only: transformed memos always carry rawLength (ingress meta). + const publishedRawLength = sourceChunk.rawLength ?? sourceChunk.data.length + const remainingRawLength = pending.transformed + ? (pending.rawLength ?? 0) - publishedRawLength + : remaining.length + if (remaining || (pending.transformed && remainingRawLength > 0)) { queue[0] = { data: remaining, - ...(pending.rawLength === undefined ? {} : { rawLength: remaining.length }), + ...(pending.transformed ? { transformed: true } : {}), + ...(pending.rawLength === undefined ? {} : { rawLength: remainingRawLength }), seq: pending.seq } } else { @@ -1019,14 +1030,33 @@ export class PtyHandler { return } if (this.sourcePublication?.accepts(id)) { - if ( - !this.sourcePublication.sealAndPublishExit(exit) || - !this.sourcePublication.exitPublicationSettled(id) - ) { + try { + // Why: after the exit settlement, re-entering sealAndPublishExit would pump a closed + // ledger delivery; the settled state alone decides completion. + if (this.sourcePublication.exitPublicationSettled(id)) { + this.pendingExitByPty.delete(id) + return + } + if (!this.sourcePublication.sealAndPublishExit(exit)) { + return + } + if ( + this.sourcePublication.accepts(id) && + !this.sourcePublication.exitPublicationSettled(id) + ) { + return + } + this.pendingExitByPty.delete(id) return + } catch (err) { + // Why: a source-publication fault must never escape onExit — it reaches + // uncaughtException and kills the whole relay daemon. Fall back to the legacy exit. + process.stderr.write( + `[pty-handler] pty source exit publication failed for ${id}: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }\n` + ) } - this.pendingExitByPty.delete(id) - return } const published = this.dispatcher.tryNotifyPtyExit ? this.dispatcher.tryNotifyPtyExit(exit) diff --git a/src/relay/pty-source-credit-availability-notice.ts b/src/relay/pty-source-credit-availability-notice.ts new file mode 100644 index 00000000000..392bab9c775 --- /dev/null +++ b/src/relay/pty-source-credit-availability-notice.ts @@ -0,0 +1,19 @@ +/** + * Guarded fan-out of "this delivery released credit" to the publication layer. + */ +export function notifyPtySourceCreditAvailable( + notify: ((id: string) => void) | undefined, + id: string +): void { + try { + notify?.(id) + } catch (err) { + // Why: this fires from request handlers, detach paths, and a bare grace setTimeout; a + // downstream throw in the timer would reach uncaughtException and kill the daemon. + process.stderr.write( + `[pty-source-credit] credit-available notification failed for ${id}: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }\n` + ) + } +} diff --git a/src/relay/pty-source-credit-ledger.test.ts b/src/relay/pty-source-credit-ledger.test.ts index e34ff5b10e0..37ad35a8f1e 100644 --- a/src/relay/pty-source-credit-ledger.test.ts +++ b/src/relay/pty-source-credit-ledger.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' import { RelayPtySourceCreditLedger } from './pty-source-credit-ledger' +import { CLOSED_DELIVERY_TOMBSTONE_LIMIT } from './pty-source-credit-record' function identity( deliveryToken = 'token-1', @@ -501,6 +502,40 @@ describe('RelayPtySourceCreditLedger', () => { expect(ledger.snapshot(owners.at(-1)!)).toMatchObject({ state: 'closed' }) }) + it('probes live, closed, cancelled and evicted deliveries without throwing', () => { + const ledger = new RelayPtySourceCreditLedger() + const live = identity('token-live', { id: 'pty-live' }) + ledger.open(live, 8) + expect(ledger.snapshotIfKnown(live)).toMatchObject({ state: 'active', exitPublished: false }) + + const completed = identity('token-completed', { id: 'pty-completed' }) + ledger.open(completed, 8) + ledger.seal(completed) + ledger.settleExitPublication(completed, { ok: true }) + expect(ledger.snapshotIfKnown(completed)).toMatchObject({ + state: 'closed', + exitPublished: true + }) + + const canceled = identity('token-canceled', { id: 'pty-canceled' }) + ledger.open(canceled, 8) + ledger.cancel(canceled, 'client-request') + expect(ledger.snapshotIfKnown(canceled)).toMatchObject({ + state: 'closed', + exitPublished: false + }) + + expect(ledger.snapshotIfKnown(identity('token-unknown'))).toBeNull() + expect(() => ledger.snapshot(identity('token-unknown'))).toThrow('stale') + + for (let index = 0; index < CLOSED_DELIVERY_TOMBSTONE_LIMIT; index++) { + const evicting = identity(`token-evicting-${index}`, { id: `pty-evicting-${index}` }) + ledger.open(evicting, 8) + ledger.cancel(evicting, 'test') + } + expect(ledger.snapshotIfKnown(canceled)).toBeNull() + }) + it('rejects reopening a recently closed one-use token', () => { const ledger = new RelayPtySourceCreditLedger() const owner = identity() diff --git a/src/relay/pty-source-credit-ledger.ts b/src/relay/pty-source-credit-ledger.ts index 0a697ea30b0..be628672c91 100644 --- a/src/relay/pty-source-credit-ledger.ts +++ b/src/relay/pty-source-credit-ledger.ts @@ -25,6 +25,7 @@ import { createDeliveryCancellation, createDeliveryRecord, createReplacementDeliveryRecord, + matchingDeliverySnapshot, MAX_SOURCE_SPAN_DATA_BYTES, ptyOwnerKey, retainedDataBytesTotal, @@ -248,16 +249,18 @@ export class RelayPtySourceCreditLedger { return Object.freeze({ cancellation, recovery: Object.freeze(replacement.spans.slice()) }) } + // Why: callers that can legitimately race a close probe instead of throwing; an evicted + // tombstone reads as null and must be treated as closed. + snapshotIfKnown(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot | null { + return matchingDeliverySnapshot(this.deliveries, this.closedSnapshots, identity) + } + snapshot(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot { - const active = this.deliveries.get(ptySourceDeliveryKey(identity)) - if (active && samePtySourceDelivery(active.identity, identity)) { - return snapshotDeliveryRecord(active) + const snapshot = this.snapshotIfKnown(identity) + if (!snapshot) { + throw new Error('Unknown or stale PTY source delivery') } - const closed = this.closedSnapshots.get(ptySourceDeliveryKey(identity)) - if (closed && samePtySourceDelivery(closed, identity)) { - return closed - } - throw new Error('Unknown or stale PTY source delivery') + return snapshot } retainedSourceSu = (): number => retainedSourceTotal(this.deliveries.values()) diff --git a/src/relay/pty-source-credit-record.ts b/src/relay/pty-source-credit-record.ts index cbdfd497e2d..d2730d25dc7 100644 --- a/src/relay/pty-source-credit-record.ts +++ b/src/relay/pty-source-credit-record.ts @@ -5,7 +5,11 @@ import type { PtySourceSpan, PtySourceTransform } from '../shared/pty-source-credit-contract' -import { ptySourceSpanIsSplittable } from '../shared/pty-source-credit-contract' +import { + ptySourceDeliveryKey, + ptySourceSpanIsSplittable, + samePtySourceDelivery +} from '../shared/pty-source-credit-contract' import { assertNonNegativeSafeInteger, assertPositiveSafeInteger, @@ -169,6 +173,21 @@ export function snapshotDeliveryRecord(record: DeliveryRecord): PtySourceDeliver }) } +// Why: identity equality is re-checked because a delivery key outlives the token that made it. +export function matchingDeliverySnapshot( + deliveries: ReadonlyMap, + closedSnapshots: ReadonlyMap, + identity: PtySourceDeliveryIdentity +): PtySourceDeliverySnapshot | null { + const key = ptySourceDeliveryKey(identity) + const active = deliveries.get(key) + if (active && samePtySourceDelivery(active.identity, identity)) { + return snapshotDeliveryRecord(active) + } + const closed = closedSnapshots.get(key) + return closed && samePtySourceDelivery(closed, identity) ? closed : null +} + export function retainedSourceTotal(records: Iterable): number { let total = 0 for (const record of records) { diff --git a/src/relay/relay-pty-source-cancellation-exit.test.ts b/src/relay/relay-pty-source-cancellation-exit.test.ts new file mode 100644 index 00000000000..e59b909802b --- /dev/null +++ b/src/relay/relay-pty-source-cancellation-exit.test.ts @@ -0,0 +1,308 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PTY_CONSUMER_OWNER_GRACE_MS } from '../shared/pty-consumer-session' +import { + RelayDispatcher, + type RelayClientSessionIdentity, + type SinkWriteSettlement +} from './dispatcher' +import { encodeJsonRpcFrame, MessageType } from './protocol' +import { RelayPtySourcePublication } from './relay-pty-source-publication' +import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const endpointIdentity: RelayClientSessionIdentity = { + principal: 'endpoint-principal', + authenticated: true, + allowSessionOwner: true, + authenticationKind: 'endpoint-credential' +} + +type Notification = { method: string; params: Record } + +function requestFrame(id: number, method: string, params: Record): Buffer { + return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0) +} + +function decode(buffer: Buffer): Record | null { + if (buffer[0] !== MessageType.Regular) { + return null + } + const length = buffer.readUInt32BE(9) + return JSON.parse(buffer.subarray(13, 13 + length).toString('utf8')) +} + +function notification(buffer: Buffer): Notification | null { + const message = decode(buffer) + return typeof message?.method === 'string' && message.id === undefined + ? (message as Notification) + : null +} + +function responseResult(buffer: Buffer): Record | null { + const message = decode(buffer) + return !message || message.id === undefined + ? null + : ((message.result as Record) ?? null) +} + +async function flushRequests(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('RelayPtySourcePublication cancellation and exit', () => { + let dispatcher: RelayDispatcher | null = null + + afterEach(() => { + dispatcher?.dispose() + dispatcher = null + }) + + async function createHarness(windowSu = 8, holdExitSettlement = false) { + const writes: Buffer[] = [] + const exitSettlements: ((result: SinkWriteSettlement) => void)[] = [] + const capacityIds: string[] = [] + dispatcher = new RelayDispatcher( + (data, onSettled) => { + writes.push(Buffer.from(data)) + const frame = notification(data) + if (frame?.method === 'pty.exit') { + exitSettlements.push(onSettled) + if (holdExitSettlement) { + return true + } + } + onSettled({ ok: true }) + return true + }, + { supportsWriteCallback: true }, + endpointIdentity + ) + let publication: RelayPtySourcePublication + const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => + publication.onCreditAvailable(id) + ) + publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => capacityIds.push(id)) + dispatcher.feed( + requestFrame(1, 'pty.openClient', { + protocolVersion: 1, + clientInstanceId: 'client-1', + requestedRole: 'session-owner', + capabilities: { outputFlowControl: { versions: [1], requestedWindowSu: windowSu } } + }) + ) + await flushRequests() + const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] + expect( + publication.activate('pty-1', 'incarnation-1', { + clientId: 1, + isStale: () => false, + sessionIdentity: endpointIdentity, + onResponseSettled: (callback) => activationSettlements.push(callback) + }) + ).toBe('opened') + activationSettlements[0]({ ok: true }) + return { adapter, publication, exitSettlements, capacityIds, writes } + } + + function cancelDeliveryFrame( + requestId: number, + activation: { clientGeneration: number; ownerGeneration: number; deliveryToken: string } + ): Buffer { + return requestFrame(requestId, 'pty.cancelDelivery', { + id: 'pty-1', + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + deliveryToken: activation.deliveryToken + }) + } + + function exitFrames(writes: Buffer[]): Notification[] { + return writes + .map(notification) + .filter((frame): frame is Notification => frame?.method === 'pty.exit') + } + + function ackFrame( + requestId: number, + activation: { clientGeneration: number; ownerGeneration: number; deliveryToken: string }, + creditedEndSu: number + ): Buffer { + return encodeJsonRpcFrame( + { + jsonrpc: '2.0', + method: 'pty.ackData', + params: { + acknowledgements: [ + { + id: 'pty-1', + clientGeneration: activation.clientGeneration, + ownerGeneration: activation.ownerGeneration, + deliveryToken: activation.deliveryToken, + creditedEndSu + } + ] + } + }, + requestId, + 0 + ) + } + + it('retires the record when a client cancels, so the exit never seals a dead ledger', async () => { + const harness = await createHarness() + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + + dispatcher!.feed(cancelDeliveryFrame(2, activation)) + await flushRequests() + + expect(harness.publication.accepts('pty-1')).toBe(false) + expect(harness.capacityIds).toContain('pty-1') + expect(() => + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(false) + ).not.toThrow() + }) + + it('retires the record when the reconnect grace expires', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) + try { + const harness = await createHarness() + harness.publication.publish('pty-1', { data: 'data' }, false) + + dispatcher!.invalidateClient() + vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS) + + expect(harness.publication.accepts('pty-1')).toBe(false) + expect(harness.capacityIds).toContain('pty-1') + expect(() => + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(false) + ).not.toThrow() + } finally { + vi.useRealTimers() + } + }) + + it('opens a fresh delivery when the same client re-attaches after a cancel', async () => { + const harness = await createHarness() + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + dispatcher!.feed(cancelDeliveryFrame(2, activation)) + await flushRequests() + + const activationSettlements: ((result: SinkWriteSettlement) => void)[] = [] + expect( + harness.publication.activate('pty-1', 'incarnation-1', { + clientId: 1, + isStale: () => false, + sessionIdentity: endpointIdentity, + onResponseSettled: (callback) => activationSettlements.push(callback) + }) + ).toBe('opened') + activationSettlements[0]({ ok: true }) + + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(exitFrames(harness.writes)).toHaveLength(1) + }) + + it('survives a cancel while the credit-mode exit frame is still in flight', async () => { + const harness = await createHarness(8, true) + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + dispatcher!.feed(ackFrame(2, activation, 4)) + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(harness.exitSettlements).toHaveLength(1) + + dispatcher!.feed(cancelDeliveryFrame(3, activation)) + await flushRequests() + expect( + harness.writes.map(responseResult).filter((result) => result?.canceled === true) + ).toHaveLength(1) + // Why: A' keeps the record while the frame is in flight so no duplicate legacy exit escapes. + expect(harness.publication.accepts('pty-1')).toBe(true) + + expect(() => harness.exitSettlements[0]({ ok: true })).not.toThrow() + + expect(exitFrames(harness.writes)).toHaveLength(1) + expect(harness.capacityIds).toContain('pty-1') + expect(harness.publication.exitPublicationSettled('pty-1')).toBe(true) + expect(harness.publication.accepts('pty-1')).toBe(false) + }) + + it('retires the record after the in-flight exit frame fails on a canceled delivery', async () => { + const harness = await createHarness(8, true) + harness.publication.publish('pty-1', { data: 'data' }, false) + const activation = harness.publication.receivingActivation('pty-1', 1)! + dispatcher!.feed(ackFrame(2, activation, 4)) + harness.publication.sealAndPublishExit({ id: 'pty-1', code: 0, incarnationId: 'incarnation-1' }) + dispatcher!.feed(cancelDeliveryFrame(3, activation)) + await flushRequests() + const capacityBefore = harness.capacityIds.length + + expect(() => + harness.exitSettlements[0]({ ok: false, error: new Error('socket write failed') }) + ).not.toThrow() + // Why: the delivery is gone, so no credit event will arrive — D's widened finally must retry. + expect(harness.capacityIds.length).toBeGreaterThan(capacityBefore) + + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(harness.publication.accepts('pty-1')).toBe(false) + expect(harness.publication.getDebugSnapshot()).toMatchObject({ + exitCommitted: 0, + exitRolledBack: 1 + }) + }) + + it('publishes exactly one exit for a healthy completion even when the retry re-runs', async () => { + const harness = await createHarness() + + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(true) + expect(harness.publication.exitPublicationSettled('pty-1')).toBe(true) + expect(harness.publication.accepts('pty-1')).toBe(false) + + expect(() => + expect( + harness.publication.sealAndPublishExit({ + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + ).toBe(false) + ).not.toThrow() + expect(exitFrames(harness.writes)).toHaveLength(1) + }) +}) diff --git a/src/relay/relay-pty-source-exit-publication.test.ts b/src/relay/relay-pty-source-exit-publication.test.ts new file mode 100644 index 00000000000..619c7da8829 --- /dev/null +++ b/src/relay/relay-pty-source-exit-publication.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + PtySourceDeliveryIdentity, + PtySourceDeliverySnapshot +} from '../shared/pty-source-credit-contract' +import type { RelayDispatcher } from './dispatcher' +import { sealAndPublishPtySourceExit } from './relay-pty-source-exit-publication' +import type { + RelayPtySourceDeliveryRecord, + RelayPtySourcePublicationCounters, + RelayPtySourceSendScheduler +} from './relay-pty-source-send-scheduler' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const identity: PtySourceDeliveryIdentity = Object.freeze({ + id: 'pty-1', + providerGeneration: 1, + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1' +}) + +const params = { id: 'pty-1', code: 0, incarnationId: 'incarnation-1' } + +function deliveryRecord( + overrides: Partial = {} +): RelayPtySourceDeliveryRecord { + return { + clientId: 1, + identity, + sourceActivation: { + status: 'pending', + clientGeneration: identity.clientGeneration, + ownerGeneration: identity.ownerGeneration, + ptyIncarnation: identity.ptyIncarnation, + deliveryToken: identity.deliveryToken, + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + }, + displayEnd: 0, + activating: false, + activationRecoveryRequest: null, + sealed: false, + legacyExitAccepted: false, + sourceExitState: 'idle', + sending: false, + turnFrames: 0, + turnSourceSu: 0, + turnScheduled: false, + sendWaiters: new Set(), + recoveryCheckpointSourceEndSu: null, + recoveryEndSu: null, + recoveryCompletionPending: false, + restoreRequired: false, + rotationPending: false, + ...overrides + } +} + +function closedSnapshot( + overrides: Partial = {} +): PtySourceDeliverySnapshot { + return Object.freeze({ + ...identity, + state: 'closed', + windowSu: 8, + receivedEndSu: 0, + sentEndSu: 0, + creditedEndSu: 0, + exitPublished: false, + generationClosed: false, + ...overrides + }) +} + +function createScenario( + initialProbe: PtySourceDeliverySnapshot | null, + record: RelayPtySourceDeliveryRecord, + notifyAccepted = true +) { + const deliveries = new Map([['pty-1', record]]) + let probe = initialProbe + const setProbe = (next: PtySourceDeliverySnapshot | null): void => { + probe = next + } + const session = { + sourceDeliverySnapshotIfKnown: vi.fn(() => probe), + sourceDeliverySnapshot: vi.fn(() => { + if (!probe) { + throw new Error('Unknown or stale PTY source delivery') + } + return probe + }), + sealDelivery: vi.fn(), + settleExitPublication: vi.fn(), + deliveryMode: vi.fn(() => 'source-owner' as const) + } + const dispatcher = { + tryNotifyPtyExit: vi.fn(() => notifyAccepted), + tryNotifyPtyExitToMatchingClients: vi.fn( + (_matches: (clientId: number) => boolean, _params: unknown) => notifyAccepted + ), + projectPtyExitToMatchingClients: vi.fn(() => true), + tryNotifyPtyExitToClient: vi.fn(() => true) + } + const sender = { pump: vi.fn() } + const counters: RelayPtySourcePublicationCounters = { + opened: 0, + rotated: 0, + appendDenied: 0, + sendCommitted: 0, + sendRolledBack: 0, + exitCommitted: 0, + exitRolledBack: 0 + } + const capacityIds: string[] = [] + const run = (): boolean => + sealAndPublishPtySourceExit({ + params, + record, + deliveries, + dispatcher: dispatcher as unknown as RelayDispatcher, + session: session as unknown as SshPtyConsumerSessionAdapter, + sender: sender as unknown as RelayPtySourceSendScheduler, + counters, + onCapacity: (id) => capacityIds.push(id) + }) + return { deliveries, session, dispatcher, sender, counters, capacityIds, run, setProbe } +} + +describe('sealAndPublishPtySourceExit closed-delivery guard', () => { + it.each([ + ['an unknown probe', null], + ['a closed probe', closedSnapshot()], + ['a closing probe', closedSnapshot({ state: 'closing' })] + ])('broadcasts a legacy exit and retires the record for %s', (_label, probe) => { + const record = deliveryRecord() + const scenario = createScenario(probe, record) + + expect(scenario.run()).toBe(true) + + expect(scenario.dispatcher.tryNotifyPtyExit).toHaveBeenCalledWith(params) + expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled() + expect(scenario.session.sealDelivery).not.toHaveBeenCalled() + expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() + expect(scenario.sender.pump).not.toHaveBeenCalled() + expect(scenario.deliveries.has('pty-1')).toBe(false) + }) + + it('re-targets only source-owner clients once a legacy broadcast already landed', () => { + const record = deliveryRecord({ legacyExitAccepted: true }) + const scenario = createScenario(closedSnapshot(), record) + + expect(scenario.run()).toBe(true) + + expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() + const matches = scenario.dispatcher.tryNotifyPtyExitToMatchingClients.mock.calls[0][0] + expect(matches(1)).toBe(true) + scenario.session.deliveryMode.mockReturnValue('legacy-owner' as never) + expect(matches(1)).toBe(false) + expect(scenario.deliveries.has('pty-1')).toBe(false) + }) + + it('keeps the record retryable when the legacy notify is refused', () => { + const record = deliveryRecord() + const scenario = createScenario(closedSnapshot(), record, false) + + expect(scenario.run()).toBe(false) + + expect(scenario.deliveries.get('pty-1')).toBe(record) + }) + + it.each([ + ['the tombstone retains exitPublished', closedSnapshot({ exitPublished: true }), 'idle'], + ['the tombstone was evicted', null, 'published'] + ])('retires a healthily completed delivery silently when %s', (_label, probe, exitState) => { + const record = deliveryRecord({ + sourceExitState: exitState as RelayPtySourceDeliveryRecord['sourceExitState'] + }) + const scenario = createScenario(probe, record) + + expect(scenario.run()).toBe(true) + + expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() + expect(scenario.dispatcher.tryNotifyPtyExitToMatchingClients).not.toHaveBeenCalled() + expect(scenario.sender.pump).not.toHaveBeenCalled() + expect(scenario.deliveries.has('pty-1')).toBe(false) + }) + + it('defers to the in-flight exit frame settlement', () => { + const record = deliveryRecord({ sourceExitState: 'pending' }) + const scenario = createScenario(closedSnapshot(), record) + + expect(scenario.run()).toBe(false) + + expect(scenario.session.sourceDeliverySnapshotIfKnown).not.toHaveBeenCalled() + expect(scenario.dispatcher.tryNotifyPtyExit).not.toHaveBeenCalled() + expect(scenario.deliveries.get('pty-1')).toBe(record) + }) +}) + +describe('sealAndPublishPtySourceExit settlement closure', () => { + function createInFlightScenario(settlementProbe: PtySourceDeliverySnapshot | null) { + const record = deliveryRecord({ sealed: true, legacyExitAccepted: true }) + const scenario = createScenario(closedSnapshot({ state: 'sealed-unsettled' }), record) + let settle: ((result: { ok: true } | { ok: false; error: Error }) => void) | undefined + scenario.dispatcher.tryNotifyPtyExitToClient.mockImplementation( + (...args: unknown[]): boolean => { + settle = args[2] as typeof settle + return true + } + ) + expect(scenario.run()).toBe(true) + scenario.setProbe(settlementProbe) + return { ...scenario, record, settle: settle! } + } + + it('skips the ledger settle and still resumes capacity when the delivery vanished', () => { + const scenario = createInFlightScenario(null) + + expect(() => scenario.settle({ ok: true })).not.toThrow() + + expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() + expect(scenario.record.sourceExitState).toBe('published') + expect(scenario.capacityIds).toEqual(['pty-1']) + }) + + it('resumes capacity on a failed settlement once the delivery is gone', () => { + const scenario = createInFlightScenario(closedSnapshot()) + + scenario.settle({ ok: false, error: new Error('socket write failed') }) + + expect(scenario.session.settleExitPublication).not.toHaveBeenCalled() + expect(scenario.record.sourceExitState).toBe('idle') + expect(scenario.capacityIds).toEqual(['pty-1']) + }) + + it.each([ + ['committed', { ok: true } as const], + ['rolled back', { ok: false, error: new Error('socket write failed') } as const] + ])('logs, contains and resumes after a %s ledger settlement fault', (_label, result) => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const scenario = createInFlightScenario(closedSnapshot({ state: 'sealed-unsettled' })) + scenario.session.settleExitPublication.mockImplementation(() => { + throw new Error('PTY source delivery is not sealed') + }) + + expect(() => scenario.settle(result)).not.toThrow() + + expect(String(stderr.mock.calls.at(-1)?.[0])).toContain( + '[pty-source-exit] exit settlement failed for pty-1' + ) + expect(scenario.capacityIds).toEqual(['pty-1']) + } finally { + stderr.mockRestore() + } + }) +}) diff --git a/src/relay/relay-pty-source-exit-publication.ts b/src/relay/relay-pty-source-exit-publication.ts index 76990942fc3..deafb51fc5b 100644 --- a/src/relay/relay-pty-source-exit-publication.ts +++ b/src/relay/relay-pty-source-exit-publication.ts @@ -27,6 +27,32 @@ export function sealAndPublishPtySourceExit(options: { } return published } + if (record.sourceExitState === 'pending') { + // Why: an exit frame is in flight; its settlement drives the next step. + return false + } + const probe = session.sourceDeliverySnapshotIfKnown(record.identity) + if (!probe || probe.state === 'closed' || probe.state === 'closing') { + if (probe?.exitPublished === true || record.sourceExitState === 'published') { + // Why: the delivery completed healthily — the owner already has the credit-mode exit. + if (deliveries.get(params.id) === record) { + deliveries.delete(params.id) + } + return true + } + // Why: the delivery was canceled out from under the record; never touch the sealed + // ledger — the exit flows as a legacy broadcast instead. + const published = record.legacyExitAccepted + ? dispatcher.tryNotifyPtyExitToMatchingClients( + (clientId) => session.deliveryMode(clientId) === 'source-owner', + params + ) + : dispatcher.tryNotifyPtyExit(params) + if (published && deliveries.get(params.id) === record) { + deliveries.delete(params.id) + } + return published + } if (!record.sealed) { session.sealDelivery(record.identity) record.sealed = true @@ -50,17 +76,33 @@ export function sealAndPublishPtySourceExit(options: { } record.sourceExitState = 'pending' const settle = onceSinkSettlement((result) => { + if (result.ok) { + record.sourceExitState = 'published' + counters.exitCommitted++ + } else { + record.sourceExitState = 'idle' + counters.exitRolledBack++ + } + let deliveryGone = false + let settlementFailed = false try { - session.settleExitPublication(record.identity, result) - if (result.ok) { - record.sourceExitState = 'published' - counters.exitCommitted++ - } else { - record.sourceExitState = 'idle' - counters.exitRolledBack++ + // Why: a client cancel or rotation can close the delivery while this frame is in + // flight; settling a closed ledger entry throws out of a bare socket write/drain + // callback (dispatcher-client-writer releaseEntry) straight into uncaughtException. + deliveryGone = + session.sourceDeliverySnapshotIfKnown(record.identity)?.state !== 'sealed-unsettled' + if (!deliveryGone) { + session.settleExitPublication(record.identity, result) } + } catch (err) { + settlementFailed = true + process.stderr.write( + `[pty-source-exit] exit settlement failed for ${params.id}: ${ + err instanceof Error ? (err.stack ?? err.message) : String(err) + }\n` + ) } finally { - if (result.ok) { + if (result.ok || deliveryGone || settlementFailed) { onCapacity(params.id) } } diff --git a/src/relay/relay-pty-source-output.ts b/src/relay/relay-pty-source-output.ts index 6beefefa6cd..bbc57d1c643 100644 --- a/src/relay/relay-pty-source-output.ts +++ b/src/relay/relay-pty-source-output.ts @@ -1,3 +1,9 @@ +import { randomUUID } from 'node:crypto' +import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' +import type { RelayDispatcher } from './dispatcher' +import type { RelayPtySourceDeliveryRecord } from './relay-pty-source-send-scheduler' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + export type RelayPtySourceOutput = { data: string rawLength?: number @@ -6,3 +12,60 @@ export type RelayPtySourceOutput = { sourceAccepted?: boolean sourceSpanId?: string } + +// Why: an evicted tombstone probes null, so an unknown delivery counts as closed. +export function ptySourceDeliveryClosed( + session: SshPtyConsumerSessionAdapter, + identity: PtySourceDeliveryIdentity +): boolean { + const state = session.sourceDeliverySnapshotIfKnown(identity)?.state + return !state || state === 'closed' || state === 'closing' +} + +export function appendPtySourceOutput( + session: SshPtyConsumerSessionAdapter, + record: RelayPtySourceDeliveryRecord, + output: RelayPtySourceOutput +): boolean { + const rawLength = output.rawLength ?? output.data.length + output.sourceSpanId ??= randomUUID() + try { + session.appendSource(record.identity, { + spanId: output.sourceSpanId, + data: output.data, + displayStart: record.displayEnd, + displayEnd: record.displayEnd + output.data.length, + splittable: output.transformed !== true, + transform: { + transformed: output.transformed === true, + rawLengthSu: rawLength, + scalarSafe: output.transformed !== true + } + }) + } catch { + return false + } + output.sourceAccepted = true + record.displayEnd += output.data.length + return true +} + +export function projectPtySourceOutputToLegacy( + dispatcher: RelayDispatcher, + session: SshPtyConsumerSessionAdapter, + id: string, + output: RelayPtySourceOutput, + interactive: boolean +): boolean { + return dispatcher.projectPtyDataToMatchingClients( + (clientId) => session.deliveryMode(clientId) !== 'source-owner', + { + id, + data: output.data, + ...(output.seq === undefined ? {} : { seq: output.seq }), + ...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }), + ...(output.transformed ? { transformed: true } : {}) + }, + { interactive } + ) +} diff --git a/src/relay/relay-pty-source-publication.test.ts b/src/relay/relay-pty-source-publication.test.ts index 3ce4526efa6..1d30e4e8540 100644 --- a/src/relay/relay-pty-source-publication.test.ts +++ b/src/relay/relay-pty-source-publication.test.ts @@ -19,7 +19,9 @@ function requestFrame(id: number, method: string, params: Record } | null { +type Notification = { method: string; params: Record } + +function notification(buffer: Buffer): Notification | null { if (buffer[0] !== MessageType.Regular) { return null } @@ -52,17 +54,23 @@ describe('RelayPtySourcePublication', () => { async function createHarness( windowSu = 4, settleSourceImmediately = true, - highWaterMark?: number + highWaterMark?: number, + holdExitSettlement = false ) { const writes: Buffer[] = [] const sourceSettlements: ((result: SinkWriteSettlement) => void)[] = [] + const exitSettlements: ((result: SinkWriteSettlement) => void)[] = [] + const capacityIds: string[] = [] dispatcher = new RelayDispatcher( (data, onSettled) => { writes.push(Buffer.from(data)) const frame = notification(data) if (frame?.method === 'pty.data' || frame?.method === 'pty.exit') { sourceSettlements.push(onSettled) - if (settleSourceImmediately) { + if (frame.method === 'pty.exit') { + exitSettlements.push(onSettled) + } + if (settleSourceImmediately && !(holdExitSettlement && frame.method === 'pty.exit')) { onSettled({ ok: true }) } } else { @@ -85,7 +93,7 @@ describe('RelayPtySourcePublication', () => { const adapter = new SshPtyConsumerSessionAdapter(dispatcher, 'build-a', undefined, (id) => publication.onCreditAvailable(id) ) - publication = new RelayPtySourcePublication(dispatcher, adapter, () => {}) + publication = new RelayPtySourcePublication(dispatcher, adapter, (id) => capacityIds.push(id)) dispatcher.feed( requestFrame(1, 'pty.openClient', { protocolVersion: 1, @@ -105,7 +113,7 @@ describe('RelayPtySourcePublication', () => { }) ).toBe('opened') activationSettlements[0]({ ok: true }) - return { adapter, publication, sourceSettlements, writes } + return { adapter, publication, sourceSettlements, exitSettlements, capacityIds, writes } } it('commits only from writer settlement and resumes exactly after cumulative ACK', async () => { diff --git a/src/relay/relay-pty-source-publication.ts b/src/relay/relay-pty-source-publication.ts index 10ef73b0ce6..b46a65c72e3 100644 --- a/src/relay/relay-pty-source-publication.ts +++ b/src/relay/relay-pty-source-publication.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto' import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' import type { PtySourceRecoveryRequest, @@ -19,7 +18,12 @@ import { } from './relay-pty-source-send-scheduler' import { sealAndPublishPtySourceExit } from './relay-pty-source-exit-publication' import type { RelayDispatcher, RequestContext } from './dispatcher' -import type { RelayPtySourceOutput } from './relay-pty-source-output' +import { + appendPtySourceOutput, + projectPtySourceOutputToLegacy, + ptySourceDeliveryClosed, + type RelayPtySourceOutput +} from './relay-pty-source-output' import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' export class RelayPtySourcePublication { @@ -61,7 +65,7 @@ export class RelayPtySourcePublication { return false } const mode = this.session.deliveryMode(context.clientId) - const current = this.deliveries.get(id) + let current = this.deliveries.get(id) if (mode === 'subscriber') { this.sender.releaseRotationFence(current) return false @@ -69,11 +73,24 @@ export class RelayPtySourcePublication { if (mode === 'legacy-owner') { if (current) { this.session.cancelDelivery(current.identity, 'source-credit-disabled') + this.sender.wakeSendWaiters(current) this.deliveries.delete(id) this.onCapacity(id) } return false } + if ( + current?.clientId === context.clientId && + !current.restoreRequired && + current.sourceExitState !== 'pending' && + this.deliveryClosedUnderRecord(current) + ) { + // Why: a canceled delivery can never resume as 'existing'; retire it so re-attach opens fresh. + this.sender.wakeSendWaiters(current) + this.deliveries.delete(id) + this.onCapacity(id) + current = undefined + } if (current?.clientId === context.clientId) { this.sender.releaseRotationFence(current) if (current.activating && current.activationRecoveryRequest) { @@ -195,42 +212,21 @@ export class RelayPtySourcePublication { ) { return false } - if (!output.sourceAccepted) { - const rawLength = output.rawLength ?? output.data.length - output.sourceSpanId ??= randomUUID() - try { - this.session.appendSource(record.identity, { - spanId: output.sourceSpanId, - data: output.data, - displayStart: record.displayEnd, - displayEnd: record.displayEnd + output.data.length, - splittable: output.transformed !== true, - transform: { - transformed: output.transformed === true, - rawLengthSu: rawLength, - scalarSafe: output.transformed !== true - } - }) - } catch { - this.counters.appendDenied++ + if (!output.sourceAccepted && !appendPtySourceOutput(this.session, record, output)) { + if (this.deliveryClosedUnderRecord(record)) { + this.sender.wakeSendWaiters(record) + this.deliveries.delete(id) + // Why: deferred — publish() can run inside flushPendingOutput's captured-queue drain, + // where pendingOutputByPty is transiently empty; a synchronous capacity callback would + // publish pty.exit ahead of still-buffered output. By microtask time the failed chunk + // has been re-queued (flushPtyOutput re-sets the queue synchronously on failure). + queueMicrotask(() => this.onCapacity(id)) return false } - output.sourceAccepted = true - record.displayEnd += output.data.length + this.counters.appendDenied++ + return false } - if ( - !this.dispatcher.projectPtyDataToMatchingClients( - (clientId) => this.session.deliveryMode(clientId) !== 'source-owner', - { - id, - data: output.data, - ...(output.seq === undefined ? {} : { seq: output.seq }), - ...(output.rawLength === undefined ? {} : { rawLength: output.rawLength }), - ...(output.transformed ? { transformed: true } : {}) - }, - { interactive } - ) - ) { + if (!projectPtySourceOutputToLegacy(this.dispatcher, this.session, id, output, interactive)) { return false } this.sender.pump(record) @@ -269,6 +265,10 @@ export class RelayPtySourcePublication { dispose = (): void => this.sender.dispose() + private deliveryClosedUnderRecord(record: RelayPtySourceDeliveryRecord): boolean { + return ptySourceDeliveryClosed(this.session, record.identity) + } + private registerActivationSettlement( id: string, record: RelayPtySourceDeliveryRecord, diff --git a/src/relay/relay-pty-source-send-scheduler.test.ts b/src/relay/relay-pty-source-send-scheduler.test.ts new file mode 100644 index 00000000000..7c130be7045 --- /dev/null +++ b/src/relay/relay-pty-source-send-scheduler.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + PtySourceDeliveryIdentity, + PtySourceDeliverySnapshot +} from '../shared/pty-source-credit-contract' +import type { RelayDispatcher } from './dispatcher' +import { + RelayPtySourceSendScheduler, + type RelayPtySourceDeliveryRecord, + type RelayPtySourcePublicationCounters +} from './relay-pty-source-send-scheduler' +import type { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' + +const identity: PtySourceDeliveryIdentity = Object.freeze({ + id: 'pty-1', + providerGeneration: 1, + clientGeneration: 2, + ownerGeneration: 3, + ptyIncarnation: 'incarnation-1', + deliveryToken: 'token-1' +}) + +function deliveryRecord( + overrides: Partial = {} +): RelayPtySourceDeliveryRecord { + return { + clientId: 1, + identity, + sourceActivation: { + status: 'pending', + clientGeneration: identity.clientGeneration, + ownerGeneration: identity.ownerGeneration, + ptyIncarnation: identity.ptyIncarnation, + deliveryToken: identity.deliveryToken, + checkpointSourceEndSu: 0, + recoveryEndSu: 0 + }, + displayEnd: 0, + activating: false, + activationRecoveryRequest: null, + sealed: false, + legacyExitAccepted: false, + sourceExitState: 'idle', + sending: false, + turnFrames: 0, + turnSourceSu: 0, + turnScheduled: false, + sendWaiters: new Set(), + recoveryCheckpointSourceEndSu: null, + recoveryEndSu: null, + recoveryCompletionPending: false, + restoreRequired: false, + rotationPending: false, + ...overrides + } +} + +function createScheduler( + probe: PtySourceDeliverySnapshot | null, + record: RelayPtySourceDeliveryRecord +) { + const deliveries = new Map([['pty-1', record]]) + const session = { + sourceDeliverySnapshotIfKnown: vi.fn(() => probe), + sourceDeliverySnapshot: vi.fn(() => { + throw new Error('Unknown or stale PTY source delivery') + }), + reserveSourceSend: vi.fn(() => null) + } + const dispatcher = { + onLegacyPtyCapacity: vi.fn(() => () => {}), + producerDataBudget: vi.fn(() => 4096), + tryNotifyPtyDataToClient: vi.fn(() => true) + } + const counters: RelayPtySourcePublicationCounters = { + opened: 0, + rotated: 0, + appendDenied: 0, + sendCommitted: 0, + sendRolledBack: 0, + exitCommitted: 0, + exitRolledBack: 0 + } + const capacityIds: string[] = [] + const scheduler = new RelayPtySourceSendScheduler( + dispatcher as unknown as RelayDispatcher, + session as unknown as SshPtyConsumerSessionAdapter, + deliveries, + counters, + (id) => capacityIds.push(id) + ) + return { scheduler, deliveries, session, capacityIds } +} + +describe('RelayPtySourceSendScheduler close handling', () => { + it('leaves an in-flight exit frame untouched on a credit event', () => { + const record = deliveryRecord({ sourceExitState: 'pending' }) + const harness = createScheduler(null, record) + + harness.scheduler.onCreditAvailable('pty-1') + + expect(harness.deliveries.get('pty-1')).toBe(record) + expect(harness.session.sourceDeliverySnapshotIfKnown).not.toHaveBeenCalled() + expect(harness.session.reserveSourceSend).not.toHaveBeenCalled() + expect(harness.capacityIds).toEqual([]) + }) + + it('prunes a record whose tombstone was already evicted', () => { + const waiter = vi.fn() + const record = deliveryRecord({ sendWaiters: new Set([waiter]) }) + const harness = createScheduler(null, record) + + expect(() => harness.scheduler.onCreditAvailable('pty-1')).not.toThrow() + + expect(waiter).toHaveBeenCalledOnce() + expect(record.sendWaiters.size).toBe(0) + expect(harness.deliveries.has('pty-1')).toBe(false) + expect(harness.capacityIds).toEqual(['pty-1']) + }) + + it('preserves a closed record after the legacy exit projection landed', () => { + const record = deliveryRecord({ legacyExitAccepted: true }) + const harness = createScheduler(null, record) + + harness.scheduler.onCreditAvailable('pty-1') + + expect(harness.deliveries.get('pty-1')).toBe(record) + expect(harness.session.reserveSourceSend).not.toHaveBeenCalled() + expect(harness.capacityIds).toEqual(['pty-1']) + }) + + it('prunes a healthily published exit after its final credit closes the delivery', () => { + const record = deliveryRecord({ + legacyExitAccepted: true, + sourceExitState: 'published' + }) + const harness = createScheduler(null, record) + + harness.scheduler.onCreditAvailable('pty-1') + + expect(harness.deliveries.has('pty-1')).toBe(false) + expect(harness.capacityIds).toEqual(['pty-1']) + }) + + it('skips unknown deliveries in the debug snapshot instead of throwing', () => { + const harness = createScheduler(null, deliveryRecord({ sealed: true })) + + expect(harness.scheduler.getDebugSnapshot()).toMatchObject({ + active: 0, + activating: 0, + sealedUnsettled: 0, + outstandingSourceUnits: 0 + }) + }) +}) diff --git a/src/relay/relay-pty-source-send-scheduler.ts b/src/relay/relay-pty-source-send-scheduler.ts index 59402f5e5ba..bbec1a824d3 100644 --- a/src/relay/relay-pty-source-send-scheduler.ts +++ b/src/relay/relay-pty-source-send-scheduler.ts @@ -1,4 +1,7 @@ -import type { PtySourceDeliveryIdentity } from '../shared/pty-source-credit-contract' +import type { + PtySourceDeliveryIdentity, + PtySourceDeliverySnapshot +} from '../shared/pty-source-credit-contract' import type { PtySourceRecoveryCheckpoint } from '../shared/pty-source-recovery-contract' import type { PtySourceReceivingActivation } from '../shared/pty-source-receiving-activation' import type { RelayDispatcher, SinkWriteSettlement } from './dispatcher' @@ -100,7 +103,23 @@ export class RelayPtySourceSendScheduler { if (!record || record.restoreRequired) { return } - if (this.pruneClosed(id, record)) { + if (record.sourceExitState === 'pending') { + // Why: the credit-mode exit frame is in flight; pruning now diverts publishPendingExit + // into a duplicate legacy pty.exit, and pumping a closed delivery throws. The exit + // settlement (which fires onCapacity) resumes progress. + return + } + const snapshot = this.session.sourceDeliverySnapshotIfKnown(record.identity) + if ( + record.sourceExitState === 'idle' && + record.legacyExitAccepted && + (!snapshot || snapshot.state === 'closed' || snapshot.state === 'closing') + ) { + // Why: preserve partial exit progress so the retry targets only the source owner. + this.onCapacity(id) + return + } + if (this.pruneClosed(id, record, snapshot)) { this.onCapacity(id) return } @@ -114,7 +133,10 @@ export class RelayPtySourceSendScheduler { let sealedUnsettled = 0 let outstandingSourceUnits = 0 for (const record of this.deliveries.values()) { - const snapshot = this.session.sourceDeliverySnapshot(record.identity) + const snapshot = this.session.sourceDeliverySnapshotIfKnown(record.identity) + if (!snapshot) { + continue + } outstandingSourceUnits += snapshot.sentEndSu - snapshot.creditedEndSu if (record.activating) { activating++ @@ -262,11 +284,20 @@ export class RelayPtySourceSendScheduler { }) } - pruneClosed(id: string, record: RelayPtySourceDeliveryRecord): boolean { - if (this.session.sourceDeliverySnapshot(record.identity).state !== 'closed') { + pruneClosed( + id: string, + record: RelayPtySourceDeliveryRecord, + snapshot: PtySourceDeliverySnapshot | null = this.session.sourceDeliverySnapshotIfKnown( + record.identity + ) + ): boolean { + // Why: an evicted tombstone probes null and must count as closed — this runs from paths + // (credit callbacks, write settlements) where a throw escapes every caller's try/catch. + if (snapshot && snapshot.state !== 'closed') { return false } if (this.deliveries.get(id) === record) { + this.wakeSendWaiters(record) this.deliveries.delete(id) } return true diff --git a/src/relay/ssh-pty-consumer-session-adapter.ts b/src/relay/ssh-pty-consumer-session-adapter.ts index c5bedb37053..1617fdb09dd 100644 --- a/src/relay/ssh-pty-consumer-session-adapter.ts +++ b/src/relay/ssh-pty-consumer-session-adapter.ts @@ -217,6 +217,12 @@ export class SshPtyConsumerSessionAdapter { return this.sourceCredit.snapshot(identity) } + sourceDeliverySnapshotIfKnown( + identity: PtySourceDeliveryIdentity + ): PtySourceDeliverySnapshot | null { + return this.sourceCredit.snapshotIfKnown(identity) + } + cancelDelivery(identity: PtySourceDeliveryIdentity, reason: string): void { this.clearPausedIdentity(identity) this.sourceCredit.cancelIdentity(identity, reason) diff --git a/src/relay/ssh-pty-source-credit-adapter.test.ts b/src/relay/ssh-pty-source-credit-adapter.test.ts index f738395a154..915a7683f09 100644 --- a/src/relay/ssh-pty-source-credit-adapter.test.ts +++ b/src/relay/ssh-pty-source-credit-adapter.test.ts @@ -114,6 +114,90 @@ describe('SshPtySourceCreditAdapter cleanup', () => { expect(() => adapter.open(grant, 'pty-late', 'incarnation-late')).toThrow('disposed') }) + it('notifies credit availability on every adapter-side cancellation but not on rotate', () => { + vi.useFakeTimers() + const onCreditAvailable = vi.fn() + const adapter = new SshPtySourceCreditAdapter(undefined, onCreditAvailable) + const grant = ownerGrant(1, 1) + const canceled = adapter.open(grant, 'pty-cancel', 'incarnation-1')! + + adapter.cancel( + { + id: canceled.id, + deliveryToken: canceled.deliveryToken, + clientGeneration: canceled.clientGeneration, + ownerGeneration: canceled.ownerGeneration + }, + grant + ) + expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel']]) + + const expiring = adapter.open(grant, 'pty-grace', 'incarnation-2')! + adapter.retainOrCloseOnDetach(grant) + vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS) + expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel'], ['pty-grace']]) + expect(adapter.snapshot(expiring).state).toBe('closed') + + const detaching = adapter.open(ownerGrant(3, 3), 'pty-detach', 'incarnation-3')! + adapter.retainOrCloseOnDetach( + Object.freeze({ + protocolVersion: PTY_CONSUMER_SESSION_PROTOCOL_VERSION, + serverBuildId: 'build-a', + clientGeneration: 3, + role: 'subscriber' + }) + ) + expect(onCreditAvailable.mock.calls).toEqual([['pty-cancel'], ['pty-grace'], ['pty-detach']]) + expect(adapter.snapshot(detaching).state).toBe('closed') + + const rotating = adapter.open(ownerGrant(4, 4), 'pty-rotate', 'incarnation-4')! + adapter.rotate(rotating, ownerGrant(5, 5), 0) + const publicationOwned = adapter.open(ownerGrant(6, 6), 'pty-publication', 'incarnation-5')! + adapter.cancelIdentity(publicationOwned, 'publication-owned') + expect(onCreditAvailable).toHaveBeenCalledTimes(3) + expect(adapter.snapshot(publicationOwned).state).toBe('closed') + }) + + it('swallows a throwing credit-available callback in cancel and in the grace timer', () => { + vi.useFakeTimers() + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const adapter = new SshPtySourceCreditAdapter(undefined, () => { + throw new Error('publication faulted') + }) + const grant = ownerGrant(1, 1) + const canceled = adapter.open(grant, 'pty-1', 'incarnation-1')! + + expect(() => + adapter.cancel( + { + id: canceled.id, + deliveryToken: canceled.deliveryToken, + clientGeneration: canceled.clientGeneration, + ownerGeneration: canceled.ownerGeneration + }, + grant + ) + ).not.toThrow() + + const expiring = adapter.open(grant, 'pty-2', 'incarnation-2')! + adapter.retainOrCloseOnDetach(grant) + expect(() => vi.advanceTimersByTime(PTY_CONSUMER_OWNER_GRACE_MS)).not.toThrow() + + expect(adapter.snapshot(expiring).state).toBe('closed') + expect(stderr.mock.calls.map(([line]) => String(line))).toEqual([ + expect.stringContaining( + '[pty-source-credit] credit-available notification failed for pty-1' + ), + expect.stringContaining( + '[pty-source-credit] credit-available notification failed for pty-2' + ) + ]) + } finally { + stderr.mockRestore() + } + }) + it('returns the same bounded proof for duplicate token cancellation', () => { const adapter = new SshPtySourceCreditAdapter() const grant = ownerGrant(1, 1) diff --git a/src/relay/ssh-pty-source-credit-adapter.ts b/src/relay/ssh-pty-source-credit-adapter.ts index d551dec8142..e03c647c912 100644 --- a/src/relay/ssh-pty-source-credit-adapter.ts +++ b/src/relay/ssh-pty-source-credit-adapter.ts @@ -15,6 +15,7 @@ import { ptySourceCancellationResult, RecentPtySourceCancellationIndex } from './pty-source-cancellation-index' +import { notifyPtySourceCreditAvailable } from './pty-source-credit-availability-notice' import { ownedPtySourceDelivery } from './pty-source-delivery-ownership' import { RelayPtySourceCreditLedger, @@ -141,6 +142,10 @@ export class SshPtySourceCreditAdapter { return this.sourceCredit.snapshot(identity) } + snapshotIfKnown(identity: PtySourceDeliveryIdentity): PtySourceDeliverySnapshot | null { + return this.sourceCredit.snapshotIfKnown(identity) + } + acknowledge( params: Record, grant: Readonly | null @@ -205,6 +210,9 @@ export class SshPtySourceCreditAdapter { this.identityByToken.delete(token) this.recentCancellations.remember(proof) this.clearGraceWhenSettled(identity.ownerGeneration) + // Why: the publication must retire its record the moment the delivery closes, or the next + // exit seals a dead ledger entry. + notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) return ptySourceCancellationResult(proof) } @@ -247,9 +255,7 @@ export class SshPtySourceCreditAdapter { return } this.disposed = true - for (const timer of this.graceTimers.values()) { - clearTimeout(timer) - } + this.graceTimers.forEach((timer) => clearTimeout(timer)) this.graceTimers.clear() this.sourceCredit.closeGeneration(this.relayProviderGeneration) this.identityByToken.clear() @@ -262,6 +268,7 @@ export class SshPtySourceCreditAdapter { continue } this.cancelExact(token, identity, 'client-detached') + notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) } } @@ -274,6 +281,7 @@ export class SshPtySourceCreditAdapter { continue } this.cancelExact(token, identity, 'reconnect-grace-expired') + notifyPtySourceCreditAvailable(this.onCreditAvailable, identity.id) } }, PTY_CONSUMER_OWNER_GRACE_MS) timer.unref?.() @@ -314,10 +322,7 @@ export class SshPtySourceCreditAdapter { } private clearGraceTimer(ownerGeneration: number): void { - const timer = this.graceTimers.get(ownerGeneration) - if (timer) { - clearTimeout(timer) - this.graceTimers.delete(ownerGeneration) - } + clearTimeout(this.graceTimers.get(ownerGeneration)) + this.graceTimers.delete(ownerGeneration) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1d90f1cf5e0..e5b5d7387d8 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -205,6 +205,7 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' import { translate } from '@/i18n/i18n' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' +import WorktreeBaseFallbackDialog from './components/WorktreeBaseFallbackDialog' import { useOsc52ClipboardDefaultOnNotice } from './components/terminal-pane/osc52-clipboard-default-on-notice' import { hasRequestedBackgroundTerminalWorktreeMount, @@ -2762,6 +2763,7 @@ function App(): React.JSX.Element { + {/* Why: Electron's drag-region hit-test is DOM-order-based (ignores z-index); render last so WindowControls stay clickable. */} {hasCustomTitleBar && } diff --git a/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx b/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx new file mode 100644 index 00000000000..2016fa4072a --- /dev/null +++ b/src/renderer/src/components/WorktreeBaseFallbackDialog.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import WorktreeBaseFallbackDialog from './WorktreeBaseFallbackDialog' +import { + requestWorktreeBaseFallbackNotice, + resetWorktreeBaseFallbackNoticesForTests +} from './worktree-base-fallback-notice' + +const initialState = useAppStore.getInitialState() +let root: Root | null = null + +async function renderDialog(activeModal: AppState['activeModal'] = 'none'): Promise { + useAppStore.setState({ + activeModal, + setContextualToursBlockingSurfaceVisible: vi.fn() + }) + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render() + }) +} + +function getButton(label: string): HTMLButtonElement { + const button = [...document.body.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!button) { + throw new Error(`Button not found: ${label}`) + } + return button +} + +describe('WorktreeBaseFallbackDialog', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + resetWorktreeBaseFallbackNoticesForTests() + }) + + afterEach(async () => { + await act(async () => { + root?.unmount() + }) + root = null + document.body.innerHTML = '' + resetWorktreeBaseFallbackNoticesForTests() + useAppStore.setState(initialState, true) + }) + + it('explains the fallback and advances queued notices', async () => { + requestWorktreeBaseFallbackNotice({ + requestedRef: 'origin/main', + localRef: 'main' + }) + requestWorktreeBaseFallbackNotice({ + requestedRef: 'upstream/develop', + localRef: 'develop' + }) + await renderDialog() + + expect(document.body.textContent).toContain('Workspace created from a local base') + expect(document.body.textContent).toContain( + 'The remote-tracking ref "origin/main" was unavailable' + ) + expect(document.body.textContent).toContain('may not include the latest remote changes') + + await act(async () => { + getButton('Got it').click() + }) + + expect(document.body.textContent).toContain('"upstream/develop" was unavailable') + }) + + it('waits until another app modal closes', async () => { + requestWorktreeBaseFallbackNotice({ + requestedRef: 'origin/main', + localRef: 'main' + }) + await renderDialog('new-workspace-composer') + + expect(document.body.textContent).not.toContain('Workspace created from a local base') + + await act(async () => { + useAppStore.setState({ activeModal: 'none' }) + }) + + expect(document.body.textContent).toContain('Workspace created from a local base') + }) +}) diff --git a/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx b/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx new file mode 100644 index 00000000000..e64e5904511 --- /dev/null +++ b/src/renderer/src/components/WorktreeBaseFallbackDialog.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useSyncExternalStore } from 'react' +import { TriangleAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + dismissWorktreeBaseFallbackNotice, + getWorktreeBaseFallbackNotice, + subscribeWorktreeBaseFallbackNotice +} from './worktree-base-fallback-notice' + +export default function WorktreeBaseFallbackDialog(): React.JSX.Element { + const notice = useSyncExternalStore( + subscribeWorktreeBaseFallbackNotice, + getWorktreeBaseFallbackNotice, + getWorktreeBaseFallbackNotice + ) + const activeModal = useAppStore((state) => state.activeModal) + const setContextualToursBlockingSurfaceVisible = useAppStore( + (state) => state.setContextualToursBlockingSurfaceVisible + ) + const lastNoticeRef = useRef(notice) + const displayedNotice = notice ?? lastNoticeRef.current + const open = notice !== null && activeModal === 'none' + + useEffect(() => { + if (notice) { + lastNoticeRef.current = notice + } + }, [notice]) + + useEffect(() => { + setContextualToursBlockingSurfaceVisible(open) + return () => setContextualToursBlockingSurfaceVisible(false) + }, [open, setContextualToursBlockingSurfaceVisible]) + + return ( + { + if (!isOpen) { + dismissWorktreeBaseFallbackNotice() + } + }} + > + + + + + {displayedNotice ? ( + + {translate( + 'auto.components.WorktreeBaseFallbackDialog.description', + 'The remote-tracking ref "{{value0}}" was unavailable, so Orca used local "{{value1}}" instead. This workspace may not include the latest remote changes.', + { value0: displayedNotice.requestedRef, value1: displayedNotice.localRef } + )} + + ) : null} + + + + + + + ) +} diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index d9b5cd182c0..59dd184bab1 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -103,8 +103,8 @@ describe('AccountsPane', () => { it('scopes account copy to the active remote server and disables local sign-in actions', () => { // Note: static SSR markup reads the store's initial state (zustand v5), so - // this exercises the fallback server label; the named-server path is - // covered by live validation against a paired server. + // this exercises the pre-hydration path where no server name is known yet. + // The named-server label is covered in provider-account-scope.test.ts. const markup = renderPane( { ...getDefaultSettings('/tmp'), diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 998be2fd684..3ffbc766d7b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -5515,6 +5515,7 @@ describe('connectPanePty', () => { // Once replay completes (guard cleared), real keystrokes flow through. replayingPanesRef.current.delete(1) setFitOverride('pty-live', 'remote-desktop-fit', 80, 24) + transport.claimViewport.mockClear() ;(onDataHandler as (data: string) => void)('a') expect(transport.sendInput).toHaveBeenCalledWith('a') expect(transport.claimViewport).toHaveBeenCalledTimes(1) @@ -23569,6 +23570,72 @@ describe('connectPanePty', () => { expect(transport.resize).not.toHaveBeenCalled() }) + it('claims a focused visible remote mirror once when its passive fit hold arrives', async () => { + let documentFocused = true + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + hasFocus: vi.fn(() => documentFocused) + } as unknown as Document + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { connectPanePty } = await import('./pty-connection') + const ptyId = 'remote:env-1@@terminal-visible' + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: ptyId }, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const pane = createPane(2) + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => ({ cols: 132, rows: 42 })) + } as never + const binding = connectPanePty(pane as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + transport.claimViewport.mockClear() + + setFitOverride(ptyId, 'remote-desktop-fit', 10, 4) + + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + setFitOverride(ptyId, 'desktop-fit', 132, 42) + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + documentFocused = false + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + documentFocused = true + binding.reassertPtySizeAfterWindowWake() + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + binding.dispose() + }) + it('does NOT re-assert while a mobile-fit override parks the PTY at phone dims', async () => { const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index e816f6ff851..83398227ee4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -69,7 +69,11 @@ import { type SafeFitContinuationHandle } from '@/lib/pane-manager/pane-tree-ops' import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' -import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' +import { + bindPanePtyId, + getFitOverrideForPty, + onOverrideChange +} from '@/lib/pane-manager/mobile-fit-overrides' import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' @@ -2250,14 +2254,26 @@ export function connectPanePty( terminalKeyTarget.addEventListener('keydown', onTerminalKeyDown, { capture: true }) } + let visibleRemoteViewportClaimPtyId: string | null = null + let pendingVisibleRemoteViewportClaim = false const setPanePtyFitBinding = (ptyId: string): void => { bindPanePtyId(pane.id, ptyId, deps.tabId) pane.container.dataset.ptyId = ptyId + if ( + deps.isVisibleRef.current && + isRemoteRuntimePtyId(ptyId) && + visibleRemoteViewportClaimPtyId !== ptyId + ) { + // Why: the initial fit event consumes this activation arm before later peer ownership changes. + visibleRemoteViewportClaimPtyId = ptyId + pendingVisibleRemoteViewportClaim = true + } // Why: override hydration can arrive before this pane knows its PTY. Once - // data-pty-id is bound, safeFit can park xterm at the held phone grid. + // data-pty-id is bound, safeFit can park xterm at the authoritative grid. if (getFitOverrideForPty(ptyId)) { safeFit(pane) } + claimPendingVisibleRemoteViewport() } let activePanePtyBinding: string | null = null // Why: bind time lets async liveness reconcile ignore a request started @@ -2311,6 +2327,8 @@ export function connectPanePty( // Why: fit bindings live in a module-level map, so pane teardown must // clear them explicitly instead of relying on DOM removal. bindPanePtyId(pane.id, null, deps.tabId) + visibleRemoteViewportClaimPtyId = null + pendingVisibleRemoteViewportClaim = false activePanePtyBinding = null activePanePtyBindingBoundAt = null delete pane.container.dataset.ptyId @@ -3852,6 +3870,52 @@ export function connectPanePty( transport.claimViewport?.(cols, rows) } } + const claimPendingVisibleRemoteViewport = (): void => { + if ( + !pendingVisibleRemoteViewportClaim || + !deps.isVisibleRef.current || + typeof document === 'undefined' || + document.visibilityState === 'hidden' || + typeof document.hasFocus !== 'function' || + !document.hasFocus() + ) { + return + } + claimViewportForUserActivity() + } + const armVisibleRemoteViewportClaim = (): void => { + const ptyId = transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + visibleRemoteViewportClaimPtyId = null + pendingVisibleRemoteViewportClaim = false + return + } + if ( + visibleRemoteViewportClaimPtyId !== ptyId || + pendingVisibleRemoteViewportClaim || + getFitOverrideForPty(ptyId)?.mode === 'remote-desktop-fit' + ) { + visibleRemoteViewportClaimPtyId = ptyId + pendingVisibleRemoteViewportClaim = true + } + } + const unsubscribeRemoteDesktopActivationClaim = onOverrideChange((event) => { + if (event.ptyId !== transport.getPtyId() || !isRemoteRuntimePtyId(event.ptyId)) { + return + } + if (event.mode === 'desktop-fit') { + visibleRemoteViewportClaimPtyId = event.ptyId + pendingVisibleRemoteViewportClaim = false + return + } + if (event.mode === 'remote-desktop-fit') { + if (deps.isVisibleRef.current && visibleRemoteViewportClaimPtyId !== event.ptyId) { + visibleRemoteViewportClaimPtyId = event.ptyId + pendingVisibleRemoteViewportClaim = true + } + claimPendingVisibleRemoteViewport() + } + }) // Why: an unbound transport (detached during a remount/move and never // rebound) silently rejects every keystroke while the PTY stays alive and @@ -8731,15 +8795,22 @@ export function connectPanePty( agentCompletionCoordinator.startProcessTracking() // Why: the hidden-delivery gate must follow every pane visibility flip. syncHiddenRendererPtyDelivery() + if (!deps.isVisibleRef.current) { + pendingVisibleRemoteViewportClaim = false + } }, // Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback. noteVisibilityResume() { + armVisibleRemoteViewportClaim() + claimPendingVisibleRemoteViewport() ptySizeReassertion.request({ fit: false }) consumeHibernatedAgentWake() requestKnownDroidReconfirmation() sampleVisiblePaneForegroundAgent() }, reassertPtySizeAfterWindowWake() { + armVisibleRemoteViewportClaim() + claimPendingVisibleRemoteViewport() ptySizeReassertion.request({ fit: false }) }, // Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads. @@ -8818,6 +8889,7 @@ export function connectPanePty( // Why: park/reconnect/remount doesn't advance the recovery epoch, so invalidate this xterm or its delayed retry could hit the next instance. terminalRecoveryInstance.unregister() unregisterUndeliverableWriteHandler() + unsubscribeRemoteDesktopActivationClaim() cancelHiddenOutputSnapshotScrollRestore() structuralReplayCoordinator.dispose() cancelFreshSpawnFollowReset() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 1a7ca3d7170..8e6f8690267 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -701,6 +701,358 @@ describe('createRemoteRuntimePtyTransport', () => { ) }) + it('retries initial web mirror inventory after a transient runtime close', async () => { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + return healthyRuntimeCall?.(request) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const recoveryPhases: string[] = [] + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + cols: 100, + rows: 30, + callbacks: { + onError, + onRecoveryStateChange: (state) => recoveryPhases.push(state.phase) + } + }) + + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + expect(activateAttempts).toBe(2) + expect(onError).not.toHaveBeenCalled() + expect(recoveryPhases).toContain('backoff') + expect(latestSubscribePayload().terminal).toBe('terminal-1') + expect(runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.create')).toBe( + false + ) + }) + + it('keeps web mirror inventory and subscription failures inside one recovery budget', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + if (request.method === 'session.tabs.list') { + return healthyRuntimeCall?.({ + method: 'session.tabs.activate', + params: { tabId: 'host-tab-1', leafId: 'pane:1' } + }) + } + return healthyRuntimeCall?.(request) + }) + runtimeSubscribe.mockRejectedValue( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(60_000) + + const attemptsAtCutoff = runtimeSubscribe.mock.calls.length + expect(attemptsAtCutoff).toBe(8) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(runtimeSubscribe).toHaveBeenCalledTimes(attemptsAtCutoff) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('ends web mirror recovery when a retry returns a fatal inventory error', async () => { + vi.useFakeTimers() + try { + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string }) => { + if (request.method !== 'session.tabs.activate') { + throw new Error(`Unexpected method ${request.method}`) + } + activateAttempts += 1 + if (activateAttempts === 1) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + throw Object.assign(new Error('Remote runtime pairing credentials expired.'), { + code: 'unauthorized' + }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: { onError } + }) + await vi.advanceTimersByTimeAsync(250) + + expect(onError).toHaveBeenCalledWith('Remote runtime pairing credentials expired.') + expect(transport.getRecoveryState?.().phase).toBe('offline') + const attemptsAfterFatalError = activateAttempts + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(activateAttempts).toBe(attemptsAfterFatalError) + expect(transport.getRecoveryState?.().phase).toBe('offline') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('does not restart web mirror recovery when an in-flight request rejects after cutoff', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let rejectInFlight: (error: Error) => void = () => {} + let activateAttempts = 0 + runtimeCall.mockImplementation((request: { method: string }) => { + if (request.method !== 'session.tabs.activate') { + throw new Error(`Unexpected method ${request.method}`) + } + activateAttempts += 1 + if (activateAttempts === 1) { + return Promise.reject( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + } + return new Promise((_, reject) => { + rejectInFlight = reject + }) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(250) + expect(activateAttempts).toBe(2) + + await vi.advanceTimersByTimeAsync(60_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + rejectInFlight( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + await vi.advanceTimersByTimeAsync(5 * 60_000) + + expect(activateAttempts).toBe(2) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + runtimeCall.mockImplementation(healthyRuntimeCall!) + expect(transport.retryRecovery?.()).toBe(true) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + expect(latestSubscribePayload().terminal).toBe('terminal-1') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('does not restart web mirror recovery when subscription rejects after cutoff', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + return healthyRuntimeCall?.(request) + }) + let rejectSubscription: (error: Error) => void = () => {} + runtimeSubscribe.mockImplementation( + () => + new Promise((_, reject) => { + rejectSubscription = reject + }) + ) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(250) + expect(runtimeSubscribe).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(60_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + rejectSubscription( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + await vi.advanceTimersByTimeAsync(0) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + await vi.advanceTimersByTimeAsync(5 * 60_000) + + expect(runtimeSubscribe).toHaveBeenCalledTimes(1) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('does not subscribe after mirror metadata resolution crosses the recovery cutoff', async () => { + vi.useFakeTimers() + try { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + let resolveMetadata: (value: unknown) => void = () => {} + runtimeCall.mockImplementation((request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + return Promise.reject( + Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + ) + } + if (request.method === 'terminal.resolvePane') { + return new Promise((resolve) => { + resolveMetadata = resolve + }) + } + return healthyRuntimeCall?.(request) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: {} + }) + await vi.advanceTimersByTimeAsync(250) + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'terminal.resolvePane' }) + ) + + await vi.advanceTimersByTimeAsync(60_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + resolveMetadata({ + ok: true, + result: { + terminal: { + handle: 'terminal-1', + tabId: 'host-tab-1', + leafId: 'pane:1', + worktreeId: 'wt-1' + } + } + }) + await vi.advanceTimersByTimeAsync(0) + + expect(runtimeSubscribe).not.toHaveBeenCalled() + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('ignores stale web mirror inventory failure after a newer connect lifecycle', async () => { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let rejectStaleInventory: (error: Error) => void = () => {} + let activateAttempts = 0 + runtimeCall.mockImplementation((request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + return new Promise((_, reject) => { + rejectStaleInventory = reject + }) + } + return healthyRuntimeCall?.(request) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const staleOnError = vi.fn() + const currentOnError = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + callbacks: { onError: staleOnError } + }) + await vi.waitFor(() => expect(activateAttempts).toBe(1)) + await transport.connect({ url: '', callbacks: { onError: currentOnError } }) + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + + rejectStaleInventory( + Object.assign(new Error('Remote runtime pairing credentials expired.'), { + code: 'unauthorized' + }) + ) + for (let index = 0; index < 20; index += 1) { + await Promise.resolve() + } + + expect(staleOnError).not.toHaveBeenCalled() + expect(currentOnError).not.toHaveBeenCalled() + expect(runtimeSubscribe).toHaveBeenCalledTimes(1) + transport.destroy?.() + }) + it('resolves a HUB-native SSH PTY wake hint to its runtime terminal handle', async () => { const leafId = '11111111-1111-4111-8111-111111111111' runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 7048e324e63..e7df8f18257 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -164,6 +164,7 @@ export function createRemoteRuntimePtyTransport( let resubscribeRequestedRequiresReplacement = false let recoveryRequiresReplacement = false let stopWaitingForPublishedHandle: (() => void) | null = null + let settleHostSessionAttachRetry: ((retry: boolean) => void) | null = null let attachGeneration = 0 let subscriptionGeneration = 0 @@ -213,6 +214,13 @@ export function createRemoteRuntimePtyTransport( subscriptionGeneration += 1 closeMultiplexedStream() } + if ( + recovery.currentPhase === 'disconnected' || + recovery.currentPhase === 'disposed' || + recovery.currentPhase === 'idle' + ) { + settleHostSessionAttachRetry?.(false) + } emitRecoveryState() }) let lastRecoveryStateKey = '' @@ -228,6 +236,7 @@ export function createRemoteRuntimePtyTransport( let agentSessionRequiresHostAuthorityReplay = false let terminalCreateUnknownOutcomeError: unknown = null let lastConnectOptions: Parameters[0] | null = null + let lastAttachOptions: Parameters[0] | null = null let resolvePaneUnavailable = false let recoveringPaneHandle: string | null = null const adoptExecutionMetadata = (terminal: { @@ -397,7 +406,8 @@ export function createRemoteRuntimePtyTransport( } async function waitForHostSessionHandle( - hostTabId: string + hostTabId: string, + isCurrent: () => boolean ): Promise { if (!worktreeId) { return undefined @@ -425,7 +435,7 @@ export function createRemoteRuntimePtyTransport( } const startedAt = Date.now() - while (!destroyed) { + while (isCurrent()) { const remainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt) if (remainingMs <= 0) { return undefined @@ -457,6 +467,63 @@ export function createRemoteRuntimePtyTransport( return undefined } + function waitForHostSessionAttachRetry(recoveryEpoch: number): Promise { + return new Promise((resolve) => { + let settled = false + const settle = (retry: boolean): void => { + if (settled) { + return + } + settled = true + if (settleHostSessionAttachRetry === settle) { + settleHostSessionAttachRetry = null + } + resolve(retry) + } + settleHostSessionAttachRetry?.(false) + settleHostSessionAttachRetry = settle + if (!recovery.schedule(recoveryEpoch, () => settle(true))) { + settle(false) + } + }) + } + + async function waitForHostSessionHandleWithRecovery( + hostTabId: string, + isCurrent: () => boolean + ): Promise { + let recoveryEpoch = recovery.isActive ? recovery.currentEpoch : undefined + while (isCurrent()) { + try { + const hostHandle = await waitForHostSessionHandle(hostTabId, isCurrent) + if (!isCurrent()) { + return undefined + } + if (recoveryEpoch !== undefined) { + if (!recovery.isCurrent(recoveryEpoch)) { + return undefined + } + } + return hostHandle + } catch (error) { + if ( + !isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) || + !isCurrent() + ) { + throw error + } + if (recoveryEpoch !== undefined && !recovery.isCurrent(recoveryEpoch)) { + return undefined + } + recoveryEpoch ??= recovery.begin() + if (!(await waitForHostSessionAttachRetry(recoveryEpoch)) || !isCurrent()) { + return undefined + } + } + } + return undefined + } + async function waitForResubscribeHostSessionHandle( hostTabId: string, previousHandle: string, @@ -523,29 +590,27 @@ export function createRemoteRuntimePtyTransport( async function attachHostSessionMirror( options: { cols?: number; rows?: number }, notifySpawn = true, - expectedAttachGeneration?: number + expectedAttachGeneration?: number, + expectedLifecycleEpoch?: number ): Promise { if (!tabId || !isWebTerminalSurfaceTabId(tabId)) { return undefined } + const isCurrent = (): boolean => + !destroyed && + (expectedAttachGeneration === undefined || expectedAttachGeneration === attachGeneration) && + (expectedLifecycleEpoch === undefined || expectedLifecycleEpoch === lifecycleEpoch) const hostTabId = toHostSessionTabId(tabId) - const hostHandle = await waitForHostSessionHandle(hostTabId) - if (hostHandle === undefined || destroyed) { + const hostHandle = await waitForHostSessionHandleWithRecovery(hostTabId, isCurrent) + if (hostHandle === undefined || !isCurrent()) { return undefined } if (hostHandle === null) { storedCallbacks.onError?.('Remote terminal was closed.') return undefined } - if ( - !hostHandle || - destroyed || - (expectedAttachGeneration !== undefined && expectedAttachGeneration !== attachGeneration) - ) { - if ( - !destroyed && - (expectedAttachGeneration === undefined || expectedAttachGeneration === attachGeneration) - ) { + if (!hostHandle || !isCurrent()) { + if (isCurrent()) { storedCallbacks.onError?.('Remote terminal was closed.') } return undefined @@ -573,6 +638,9 @@ export function createRemoteRuntimePtyTransport( } } + if (!isCurrent() || recovery.currentPhase === 'disconnected') { + return undefined + } handle = hostHandle remotePtyId = toRemoteRuntimePtyId(hostHandle, currentRuntimeEnvironmentId) registerShutdownHandlers(remotePtyId) @@ -592,12 +660,7 @@ export function createRemoteRuntimePtyTransport( throw error } } - if ( - destroyed || - !connected || - !remotePtyId || - (expectedAttachGeneration !== undefined && expectedAttachGeneration !== attachGeneration) - ) { + if (!connected || !remotePtyId || !isCurrent()) { return undefined } @@ -1216,6 +1279,9 @@ export function createRemoteRuntimePtyTransport( if (!isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error))) { return false } + if (recovery.currentPhase === 'disconnected') { + return true + } scheduleResubscribeAfterTransportClose() return true } @@ -1548,6 +1614,7 @@ export function createRemoteRuntimePtyTransport( const connectLifecycleEpoch = ++lifecycleEpoch const createEnvironmentId = currentRuntimeEnvironmentId lastConnectOptions = options + lastAttachOptions = null storedCallbacks = options.callbacks recoveryRequiresReplacement = false terminalEnded = false @@ -1559,7 +1626,7 @@ export function createRemoteRuntimePtyTransport( try { if (isWebTerminalSurfaceTabId(tabId ?? '')) { - return await attachHostSessionMirror(options) + return await attachHostSessionMirror(options, true, undefined, connectLifecycleEpoch) } if (options.sessionId && !getRemoteRuntimeTerminalHandle(options.sessionId)) { @@ -1762,12 +1829,13 @@ export function createRemoteRuntimePtyTransport( }, attach(options) { - lifecycleEpoch += 1 + const attachLifecycleEpoch = ++lifecycleEpoch const generation = ++attachGeneration cancelTerminalCreateRetryWait() recovery.cancel() recoveryRequiresReplacement = false clearPublishedHandleWait() + lastAttachOptions = options storedCallbacks = options.callbacks terminalEnded = false connecting = true @@ -1798,7 +1866,7 @@ export function createRemoteRuntimePtyTransport( const persistedHandle = nextHandle void (async () => { if (isWebTerminalSurfaceTabId(tabId ?? '')) { - await attachHostSessionMirror(options, false, generation) + await attachHostSessionMirror(options, false, generation, attachLifecycleEpoch) return } if (!tabId || !leafId || !worktreeId) { @@ -1845,10 +1913,15 @@ export function createRemoteRuntimePtyTransport( } await adoptResolvedHostPane(resolved, options, false, generation) })().catch((error) => { - if (generation !== attachGeneration || destroyed) { + if ( + generation !== attachGeneration || + attachLifecycleEpoch !== lifecycleEpoch || + destroyed + ) { return } clearPendingViewportClaim() + recovery.cancel() handleRemoteTerminalError(error) }) }, @@ -1998,6 +2071,23 @@ export function createRemoteRuntimePtyTransport( getRecoveryState, retryRecovery() { + if ( + !destroyed && + !terminalEnded && + !connected && + isWebTerminalSurfaceTabId(tabId ?? '') && + recovery.currentPhase === 'disconnected' + ) { + recovery.cancel() + if (lastAttachOptions) { + transport.attach(lastAttachOptions) + return true + } + if (lastConnectOptions) { + void transport.connect(lastConnectOptions) + return true + } + } if ( !destroyed && !terminalEnded && diff --git a/src/renderer/src/components/worktree-base-fallback-notice.ts b/src/renderer/src/components/worktree-base-fallback-notice.ts new file mode 100644 index 00000000000..fc314732b4a --- /dev/null +++ b/src/renderer/src/components/worktree-base-fallback-notice.ts @@ -0,0 +1,35 @@ +import type { WorktreeCreateBaseFallback } from '../../../shared/types' + +const notices: WorktreeCreateBaseFallback[] = [] +const listeners = new Set<() => void>() + +function notifyListeners(): void { + for (const listener of listeners) { + listener() + } +} + +export function requestWorktreeBaseFallbackNotice(notice: WorktreeCreateBaseFallback): void { + notices.push(notice) + notifyListeners() +} + +export function dismissWorktreeBaseFallbackNotice(): void { + if (notices.shift()) { + notifyListeners() + } +} + +export function getWorktreeBaseFallbackNotice(): WorktreeCreateBaseFallback | null { + return notices[0] ?? null +} + +export function subscribeWorktreeBaseFallbackNotice(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function resetWorktreeBaseFallbackNoticesForTests(): void { + notices.length = 0 + notifyListeners() +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9706e8f1fb7..edc3de12f21 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14343,6 +14343,11 @@ "useManualTerminalWorktreeParking": { "cannotPark": "These terminals cannot be parked safely." } + }, + "WorktreeBaseFallbackDialog": { + "title": "Workspace created from a local base", + "description": "The remote-tracking ref \"{{value0}}\" was unavailable, so Orca used local \"{{value1}}\" instead. This workspace may not include the latest remote changes.", + "dismiss": "Got it" } }, "i18n": { diff --git a/src/renderer/src/lib/react-renderer-root.test.ts b/src/renderer/src/lib/react-renderer-root.test.ts new file mode 100644 index 00000000000..3592196f969 --- /dev/null +++ b/src/renderer/src/lib/react-renderer-root.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import { getOrCreateRendererRoot } from './react-renderer-root' + +vi.mock('react-dom/client', () => ({ + createRoot: vi.fn() +})) + +beforeEach(() => { + vi.mocked(createRoot).mockReset() +}) + +describe('getOrCreateRendererRoot', () => { + it('reuses the root retained by an HMR entry module', () => { + const hotData: { orcaRendererRoot?: Root } = {} + const root = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + vi.mocked(createRoot).mockReturnValue(root) + + expect(getOrCreateRendererRoot({} as HTMLElement, hotData)).toBe(root) + expect(getOrCreateRendererRoot({} as HTMLElement, hotData)).toBe(root) + expect(createRoot).toHaveBeenCalledTimes(1) + }) + + it('creates an independent root without HMR state', () => { + const first = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + const second = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + vi.mocked(createRoot).mockReturnValueOnce(first).mockReturnValueOnce(second) + + expect(getOrCreateRendererRoot({} as HTMLElement)).toBe(first) + expect(getOrCreateRendererRoot({} as HTMLElement)).toBe(second) + }) +}) diff --git a/src/renderer/src/lib/react-renderer-root.ts b/src/renderer/src/lib/react-renderer-root.ts new file mode 100644 index 00000000000..0a73210ecfa --- /dev/null +++ b/src/renderer/src/lib/react-renderer-root.ts @@ -0,0 +1,20 @@ +import { createRoot, type Root } from 'react-dom/client' + +type RendererRootHotData = { + orcaRendererRoot?: Root +} + +export function getOrCreateRendererRoot( + container: HTMLElement, + hotData?: RendererRootHotData +): Root { + const existingRoot = hotData?.orcaRendererRoot + if (existingRoot) { + return existingRoot + } + const root = createRoot(container) + if (hotData) { + hotData.orcaRendererRoot = root + } + return root +} diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 713ddfb7902..7cbd47bfdcb 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -198,6 +198,24 @@ describe('windows terminal capabilities', () => { expect(wslIsAvailable).toHaveBeenCalledTimes(2) }) + it('rechecks availability when distro discovery invalidates a stale failure', async () => { + const wslIsAvailable = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + vi.stubGlobal('window', { + api: { + wsl: { isAvailable: wslIsAvailable, listDistros: vi.fn().mockResolvedValue(['Ubuntu']) }, + pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: vi.fn().mockResolvedValue({ hostPlatform: 'win32' }) } + } + }) + + await expect(loadWindowsTerminalCapabilities()).resolves.toMatchObject({ + wslAvailable: true, + wslDistros: ['Ubuntu'] + }) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + }) + it('re-probes when the capability cache expires', async () => { const wslIsAvailable = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false) const pwshIsAvailable = vi.fn().mockResolvedValue(false) @@ -558,6 +576,49 @@ describe('windows terminal capabilities', () => { expect(detectRemoteWindowsTerminalCapabilities).toHaveBeenCalledTimes(1) }) + it('refreshes local capabilities while a long-lived consumer remains mounted', async () => { + vi.useFakeTimers() + const { wslIsAvailable, wslListDistros } = stubTerminalCapabilityApi({ + wslAvailable: false, + pwshAvailable: true, + wslDistros: [] + }) + wslIsAvailable.mockResolvedValueOnce(false).mockResolvedValue(true) + wslListDistros.mockResolvedValueOnce([]).mockResolvedValue(['Ubuntu']) + let latest: ReturnType | null = null + + function HookProbe(): null { + latest = useWindowsTerminalCapabilities(true) + return null + } + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + hookRoots.push(root) + + try { + await act(async () => { + root.render(createElement(HookProbe)) + await Promise.resolve() + await Promise.resolve() + }) + expect(latest).toMatchObject({ wslAvailable: false, wslDistros: [] }) + + await act(async () => { + vi.advanceTimersByTime(30_000) + await Promise.resolve() + await Promise.resolve() + }) + expect(latest).toMatchObject({ wslAvailable: true, wslDistros: ['Ubuntu'] }) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(30_000) + expect(wslIsAvailable).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + it.each([ { name: 'Windows to Linux', diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index 37c888f41dc..5d085cc8136 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -174,7 +174,7 @@ export function loadWindowsTerminalCapabilities( } export function refreshWindowsTerminalCapabilities( - ownerKey: string | undefined = undefined, + ownerKey?: string, target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }, sshConnectionId?: string | null ): Promise { @@ -197,7 +197,7 @@ export function selectWindowsTerminalCapabilitiesForOwner( export function useWindowsTerminalCapabilities( enabled: boolean, forceRefreshOnMount = false, - ownerKey: string | undefined = undefined, + ownerKey?: string, target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' }, sshConnectionId?: string | null ): WindowsTerminalCapabilities { @@ -249,9 +249,24 @@ export function useWindowsTerminalCapabilities( setState({ ownerKey: resolvedOwnerKey, capabilities: nextCapabilities }) } }) + const refreshInterval = globalThis.setInterval(() => { + if (resolvedTarget.kind !== 'local' || sshConnectionIdKey) { + return + } + const cachedCapabilities = getCachedWindowsTerminalCapabilities(resolvedOwnerKey) + if (cachedCapabilities.wslAvailable && cachedCapabilities.wslDistros.length > 0) { + return + } + void loadWindowsTerminalCapabilities({ + ownerKey: resolvedOwnerKey, + target: resolvedTarget, + sshConnectionId: sshConnectionIdKey + }) + }, CAPABILITY_CACHE_TTL_MS) return () => { cancelled = true + globalThis.clearInterval(refreshInterval) const currentSubscribers = subscribersByOwnerKey.get(resolvedOwnerKey) currentSubscribers?.delete(setCapabilities) if (currentSubscribers?.size === 0) { diff --git a/src/renderer/src/lib/windows-terminal-capability-read.ts b/src/renderer/src/lib/windows-terminal-capability-read.ts index e73336b424f..3c9a7edc6bc 100644 --- a/src/renderer/src/lib/windows-terminal-capability-read.ts +++ b/src/renderer/src/lib/windows-terminal-capability-read.ts @@ -4,6 +4,18 @@ import type { WindowsTerminalCapabilities } from './windows-terminal-capabilitie export type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget +async function reconcileWslAvailability( + available: boolean, + distros: string[], + probe: () => Promise +): Promise { + // A non-empty list can invalidate the negative cache after the parallel probe read it. + if (available || distros.length === 0) { + return available + } + return probe().catch(() => false) +} + export async function readWindowsTerminalCapabilities( target: WindowsTerminalCapabilityLoadTarget, sshConnectionId?: string | null @@ -48,8 +60,11 @@ export async function readWindowsTerminalCapabilities( .then((status) => status.hostPlatform ?? null) .catch(() => null) ]) + const reconciledWslAvailable = await reconcileWslAvailability(wslAvailable, wslDistros, () => + window.api.wsl.isAvailable() + ) return { - wslAvailable, + wslAvailable: reconciledWslAvailable, wslDistros, pwshAvailable, gitBashAvailable, @@ -76,8 +91,13 @@ export async function readWindowsTerminalCapabilities( .then((status) => status.hostPlatform ?? null) .catch(() => null) ]) + const reconciledWslAvailable = await reconcileWslAvailability(wslAvailable, wslDistros, () => + callRuntimeRpc(target, 'host.wsl.isAvailable', undefined, { + timeoutMs: 15_000 + }) + ) return { - wslAvailable, + wslAvailable: reconciledWslAvailable, wslDistros, pwshAvailable, gitBashAvailable, diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a980c8ed730..7acc230bb5a 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,7 +1,6 @@ import './assets/main.css' import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' import { useTranslation } from 'react-i18next' import App from './App' import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' @@ -14,6 +13,7 @@ import { installTypingLatencyDiagnostic } from './lib/typing-latency-diagnostic' import { shouldEnableReactGrab } from './lib/react-grab-dev-gate' import { I18nProvider } from './i18n/I18nProvider' import { translate } from './i18n/i18n' +import { getOrCreateRendererRoot } from './lib/react-renderer-root' recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() @@ -55,7 +55,7 @@ function RendererRoot(): React.JSX.Element { ) } -createRoot(rootElement).render( +getOrCreateRendererRoot(rootElement, import.meta.hot?.data).render( diff --git a/src/renderer/src/popout.tsx b/src/renderer/src/popout.tsx index e68565f509c..15f1be88fb6 100644 --- a/src/renderer/src/popout.tsx +++ b/src/renderer/src/popout.tsx @@ -1,7 +1,6 @@ import './assets/main.css' import { StrictMode, useEffect } from 'react' -import { createRoot } from 'react-dom/client' import { useTranslation } from 'react-i18next' import { DashboardPopoutRoot } from './components/dashboard-popout/DashboardPopoutRoot' import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' @@ -15,6 +14,7 @@ import { I18nProvider } from './i18n/I18nProvider' import { translate } from './i18n/i18n' import { useAppStore } from './store' import type { GlobalSettings } from '../../shared/types' +import { getOrCreateRendererRoot } from './lib/react-renderer-root' // Why: the pop-out window is a separate BrowserWindow with its own React root, // so it must run the same renderer bootstrap as main.tsx (crash diagnostics, @@ -114,7 +114,7 @@ function PopoutRoot(): React.JSX.Element { ) } -createRoot(rootElement).render( +getOrCreateRendererRoot(rootElement, import.meta.hot?.data).render( diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 5d828b36c89..3b2761504bf 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -110,6 +110,8 @@ type RemoteRuntimeMultiplexedTerminalState = { // Track it so a gap triggers a self-healing snapshot resync instead of // silently rendering corrupt/missing output (frame-drop resync). expectedSeq: number | undefined + // Why: compare command probes when initial/live frames supplied no high-water. + commandProbeBaselineSeq: number | undefined recoverySnapshotSeq: number | undefined resyncInFlight: boolean resyncPendingSend: boolean @@ -162,6 +164,8 @@ export const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' type E2eRemoteTerminalMultiplexAckGateSnapshot = { + droppedOutputBytes: number + droppedOutputFrames: number heldTerminalCount: number heldStreamCount: number heldAckChars: number @@ -169,6 +173,7 @@ type E2eRemoteTerminalMultiplexAckGateSnapshot = { } type E2eRemoteTerminalMultiplexAckGateApi = { + dropOutputUntilResubscribe: (terminals: string[]) => number hold: (terminals: string[]) => void release: () => void sendInput: (terminal: string, text: string) => number @@ -180,6 +185,9 @@ type E2eRemoteTerminalMultiplexAckGateWindow = Window & { } const e2eHeldRemoteAckTerminals = new Set() +const e2eDroppedOutputStreams = new Set() +let e2eDroppedOutputBytes = 0 +let e2eDroppedOutputFrames = 0 let e2eReleasedRemoteAckChars = 0 function shouldHoldE2eRemoteTerminalAck(terminal: string): boolean { @@ -198,6 +206,8 @@ function getE2eRemoteAckSnapshot(): E2eRemoteTerminalMultiplexAckGateSnapshot { } } return { + droppedOutputBytes: e2eDroppedOutputBytes, + droppedOutputFrames: e2eDroppedOutputFrames, heldTerminalCount: e2eHeldRemoteAckTerminals.size, heldStreamCount, heldAckChars, @@ -212,19 +222,52 @@ function releaseE2eRemoteTerminalAcks(): void { e2eHeldRemoteAckTerminals.clear() } +function resetE2eDroppedRemoteOutput(): void { + e2eDroppedOutputStreams.clear() + e2eDroppedOutputBytes = 0 + e2eDroppedOutputFrames = 0 +} + +function shouldDropE2eRemoteTerminalOutput( + stream: RemoteRuntimeMultiplexedTerminalState, + bytes: number +): boolean { + if (!e2eConfig.exposeStore || !e2eDroppedOutputStreams.has(stream)) { + return false + } + e2eDroppedOutputBytes += bytes + e2eDroppedOutputFrames += 1 + return true +} + function exposeE2eRemoteTerminalMultiplexAckGate(): void { if (!e2eConfig.exposeStore || typeof window === 'undefined') { return } const target = window as E2eRemoteTerminalMultiplexAckGateWindow target.__remoteTerminalMultiplexAckGate ??= { + dropOutputUntilResubscribe: (terminals) => { + resetE2eDroppedRemoteOutput() + const targets = new Set(terminals) + for (const multiplexer of multiplexers.values()) { + for (const stream of multiplexer.getStreamsForE2e()) { + if (targets.has(stream.terminal)) { + e2eDroppedOutputStreams.add(stream) + } + } + } + return e2eDroppedOutputStreams.size + }, hold: (terminals) => { releaseE2eRemoteTerminalAcks() for (const terminal of terminals) { e2eHeldRemoteAckTerminals.add(terminal) } }, - release: releaseE2eRemoteTerminalAcks, + release: () => { + releaseE2eRemoteTerminalAcks() + resetE2eDroppedRemoteOutput() + }, sendInput: (terminal, value) => { let sent = 0 for (const multiplexer of multiplexers.values()) { @@ -290,6 +333,7 @@ class RemoteRuntimeTerminalMultiplexer { initialSnapshotReceived: false, pendingSnapshotRequest: null, expectedSeq: undefined, + commandProbeBaselineSeq: undefined, recoverySnapshotSeq: undefined, resyncInFlight: false, resyncPendingSend: false, @@ -586,6 +630,14 @@ class RemoteRuntimeTerminalMultiplexer { } return } + if ( + (frame.opcode === TerminalStreamOpcode.Output || + frame.opcode === TerminalStreamOpcode.OutputSpan) && + shouldDropE2eRemoteTerminalOutput(stream, frame.payload.byteLength) + ) { + this.queueOutputAcknowledgement(stream, frame.payload.byteLength) + return + } stream.watchdog.recordInbound() if ( frame.opcode === TerminalStreamOpcode.Output || @@ -644,6 +696,7 @@ class RemoteRuntimeTerminalMultiplexer { } if (typeof seq === 'number') { stream.expectedSeq = seq + stream.commandProbeBaselineSeq = undefined } stream.callbacks.onData(data, { seq, @@ -746,6 +799,7 @@ class RemoteRuntimeTerminalMultiplexer { if (target === 'initial') { clearResyncTimer(stream) stream.expectedSeq = typeof info?.seq === 'number' ? info.seq : undefined + stream.commandProbeBaselineSeq = undefined stream.resyncInFlight = false stream.resyncPendingSend = false stream.initialSnapshotReceived = true @@ -756,6 +810,7 @@ class RemoteRuntimeTerminalMultiplexer { if (snapshotApplied) { clearResyncTimer(stream) stream.expectedSeq = typeof info?.seq === 'number' ? info.seq : undefined + stream.commandProbeBaselineSeq = undefined stream.recoverySnapshotSeq = typeof info?.seq === 'number' ? info.seq : undefined stream.resyncAttempts = 0 stream.resyncInFlight = false @@ -996,13 +1051,47 @@ class RemoteRuntimeTerminalMultiplexer { private probeCommandResponse(stream: RemoteRuntimeMultiplexedTerminalState): void { void this.requestSnapshot(stream).then( - () => { + (snapshot) => { if (this.streams.get(stream.streamId) !== stream) { return } + if (typeof snapshot?.seq === 'number' && typeof stream.expectedSeq !== 'number') { + if ( + typeof stream.commandProbeBaselineSeq === 'number' && + snapshot.seq > stream.commandProbeBaselineSeq + ) { + recordRendererCrashBreadcrumb('remote_terminal_stream_stall_probe_baseline_advanced', { + baselineSeq: stream.commandProbeBaselineSeq, + environmentId: this.environmentId, + snapshotSeq: snapshot.seq, + streamId: stream.streamId, + terminal: stream.terminal + }) + this.recoverStalledStream(stream) + return + } + stream.commandProbeBaselineSeq ??= snapshot.seq + } else if ( + typeof snapshot?.seq === 'number' && + typeof stream.expectedSeq === 'number' && + snapshot.seq > stream.expectedSeq + ) { + recordRendererCrashBreadcrumb('remote_terminal_stream_stall_probe_detected_gap', { + deliveredSeq: stream.expectedSeq, + environmentId: this.environmentId, + snapshotSeq: snapshot.seq, + streamId: stream.streamId, + terminal: stream.terminal + }) + this.recoverStalledStream(stream) + return + } stream.watchdog.completeCommandResponseProbe() recordRendererCrashBreadcrumb('remote_terminal_stream_stall_probe_succeeded', { + deliveredSeq: stream.expectedSeq ?? null, environmentId: this.environmentId, + probeBaselineSeq: stream.commandProbeBaselineSeq ?? null, + snapshotSeq: snapshot?.seq ?? null, streamId: stream.streamId, terminal: stream.terminal }) @@ -1209,6 +1298,7 @@ export function _getRemoteRuntimeTerminalMultiplexerCountForTest(): number { export function resetRemoteRuntimeTerminalMultiplexersForTests(): void { multiplexers.clear() e2eHeldRemoteAckTerminals.clear() + resetE2eDroppedRemoteOutput() e2eReleasedRemoteAckChars = 0 } diff --git a/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts index 921eb0d3a4f..1e8b239d1ce 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts @@ -142,6 +142,7 @@ describe('remote terminal stalled stream recovery', () => { client: { id: 'mac-viewer', type: 'desktop' }, callbacks: { onData: vi.fn(), onSnapshot: vi.fn(), onTransportClose } }) + emitSnapshot(stream.streamId, undefined, 'baseline', 8) sendBinary.mockClear() expect(stream.sendInput('secret\r')).toBe(true) @@ -152,7 +153,7 @@ describe('remote terminal stalled stream recovery', () => { : null expect(firstRequestPayload?.requestId).toBeTypeOf('number') - emitRequestedSnapshot(stream.streamId, firstRequestPayload?.requestId ?? 0) + emitSnapshot(stream.streamId, firstRequestPayload?.requestId ?? 0, 'baseline', 8) await vi.advanceTimersByTimeAsync(0) expect(onTransportClose).not.toHaveBeenCalled() @@ -163,6 +164,138 @@ describe('remote terminal stalled stream recovery', () => { stream.close() }) + it('restarts a stream when the authoritative snapshot advanced without live output', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const onTransportClose = vi.fn() + const onData = vi.fn() + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-stale-live-tail', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData, onSnapshot: vi.fn(), onTransportClose } + }) + emitOutput(stream.streamId, 'baseline') + sendBinary.mockClear() + + expect(stream.sendInput('echo missing\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const request = sentFrames(TerminalStreamOpcode.SnapshotRequest)[0] + const payload = request + ? decodeTerminalStreamJson<{ requestId: number }>(request.payload) + : null + emitSnapshot(stream.streamId, payload?.requestId ?? 0, 'baselinemissing', 15) + await vi.advanceTimersByTimeAsync(0) + + expect(onData).toHaveBeenCalledOnce() + expect(onTransportClose).toHaveBeenCalledWith({ recoverable: true }) + expect(sentUnsubscribeStreamIds()).toEqual([stream.streamId]) + }) + + it('establishes a probe baseline before recovering an unsequenced stream', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const onTransportClose = vi.fn() + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-first-output-lost', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData: vi.fn(), onSnapshot: vi.fn(), onTransportClose } + }) + sendBinary.mockClear() + + expect(stream.sendInput('echo first-output\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const request = sentFrames(TerminalStreamOpcode.SnapshotRequest)[0] + const payload = request + ? decodeTerminalStreamJson<{ requestId: number }>(request.payload) + : null + emitSnapshot(stream.streamId, payload?.requestId ?? 0, 'first-output', 12) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('silent-command\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const sameRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[1] + const samePayload = sameRequest + ? decodeTerminalStreamJson<{ requestId: number }>(sameRequest.payload) + : null + emitSnapshot(stream.streamId, samePayload?.requestId ?? 0, 'first-output', 12) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('regressed-snapshot\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const regressedRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[2] + expect(regressedRequest).toBeDefined() + if (!regressedRequest) { + throw new Error('Missing regressed snapshot request') + } + const regressedPayload = decodeTerminalStreamJson<{ requestId: number }>( + regressedRequest.payload + ) + expect(regressedPayload?.requestId).toBeTypeOf('number') + if (!regressedPayload || typeof regressedPayload.requestId !== 'number') { + throw new Error('Malformed regressed snapshot request') + } + emitSnapshot(stream.streamId, regressedPayload.requestId, 'partial-output', 8) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('still-below-baseline\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const belowBaselineRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[3] + expect(belowBaselineRequest).toBeDefined() + if (!belowBaselineRequest) { + throw new Error('Missing below-baseline snapshot request') + } + const belowBaselinePayload = decodeTerminalStreamJson<{ requestId: number }>( + belowBaselineRequest.payload + ) + expect(belowBaselinePayload?.requestId).toBeTypeOf('number') + if (!belowBaselinePayload || typeof belowBaselinePayload.requestId !== 'number') { + throw new Error('Malformed below-baseline snapshot request') + } + emitSnapshot(stream.streamId, belowBaselinePayload.requestId, 'partial-output', 10) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(stream.sendInput('echo second-output\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const advancedRequest = sentFrames(TerminalStreamOpcode.SnapshotRequest)[4] + const advancedPayload = advancedRequest + ? decodeTerminalStreamJson<{ requestId: number }>(advancedRequest.payload) + : null + emitSnapshot(stream.streamId, advancedPayload?.requestId ?? 0, 'second-output', 25) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).toHaveBeenCalledWith({ recoverable: true }) + expect(sentUnsubscribeStreamIds()).toEqual([stream.streamId]) + }) + + it('keeps a responsive stream when its probe confirms zero output high-water', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const onTransportClose = vi.fn() + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-unsequenced-silent', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { onData: vi.fn(), onSnapshot: vi.fn(), onTransportClose } + }) + sendBinary.mockClear() + + expect(stream.sendInput('silent-command\r')).toBe(true) + await vi.advanceTimersByTimeAsync(REMOTE_TERMINAL_COMMAND_RESPONSE_TIMEOUT_MS) + const request = sentFrames(TerminalStreamOpcode.SnapshotRequest)[0] + const payload = request + ? decodeTerminalStreamJson<{ requestId: number }>(request.payload) + : null + emitSnapshot(stream.streamId, payload?.requestId ?? 0, '', 0) + await vi.advanceTimersByTimeAsync(0) + + expect(onTransportClose).not.toHaveBeenCalled() + expect(sentUnsubscribeStreamIds()).toEqual([]) + stream.close() + }) + it('keeps a command stream when any host frame proves it is responsive', async () => { const { getRemoteRuntimeTerminalMultiplexer } = await import('./remote-runtime-terminal-multiplexer') @@ -240,7 +373,12 @@ describe('remote terminal stalled stream recovery', () => { ) } - function emitRequestedSnapshot(streamId: number, requestId: number): void { + function emitSnapshot( + streamId: number, + requestId: number | undefined, + data = '', + seq?: number + ): void { callbacks?.onBinary( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.SnapshotStart, @@ -250,15 +388,26 @@ describe('remote terminal stalled stream recovery', () => { kind: 'scrollback', requestId, cols: 80, - rows: 24 + rows: 24, + seq }) }) ) + if (data) { + callbacks?.onBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(data) + }) + ) + } callbacks?.onBinary( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.SnapshotEnd, streamId, - seq: 2, + seq: 3, payload: new Uint8Array() }) ) diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index be485d0ef2f..13e6278e91b 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -462,6 +462,63 @@ describe('remote runtime terminal multiplex ACK gate', () => { liveTerminal.close() }) + it('drops output only from the armed stream when its replacement reuses the stream ID', async () => { + const { getRemoteRuntimeTerminalMultiplexer, resetRemoteRuntimeTerminalMultiplexersForTests } = + await import('./remote-runtime-terminal-multiplexer') + resetRemoteRuntimeTerminalMultiplexersForTests() + + const oldData = vi.fn() + const oldStream = await getRemoteRuntimeTerminalMultiplexer( + 'env-output-drop-gate' + ).subscribeTerminal({ + terminal: 'terminal-reused-stream-id', + client: { id: 'desktop-old', type: 'desktop' }, + callbacks: { onData: oldData, onSnapshot: vi.fn() } + }) + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + dropOutputUntilResubscribe: (terminals: string[]) => number + release: () => void + } + } + ).__remoteTerminalMultiplexAckGate + expect(gate?.dropOutputUntilResubscribe(['terminal-reused-stream-id'])).toBe(1) + + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: oldStream.streamId, + seq: 7, + payload: encodeTerminalStreamText('dropped') + }) + ) + expect(oldData).not.toHaveBeenCalled() + oldStream.close() + + const replacementData = vi.fn() + const replacement = await getRemoteRuntimeTerminalMultiplexer( + 'env-output-drop-gate' + ).subscribeTerminal({ + terminal: 'terminal-reused-stream-id', + client: { id: 'desktop-new', type: 'desktop' }, + callbacks: { onData: replacementData, onSnapshot: vi.fn() } + }) + expect(replacement.streamId).toBe(oldStream.streamId) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: replacement.streamId, + seq: 9, + payload: encodeTerminalStreamText('delivered') + }) + ) + + expect(replacementData).toHaveBeenCalledWith('delivered', { seq: 9, rawLength: 9 }) + gate?.release() + replacement.close() + }) + it('applies mid-session recovery snapshots without re-subscribing', async () => { const { getRemoteRuntimeTerminalMultiplexer } = await import('./remote-runtime-terminal-multiplexer') diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index dbcbff9f74e..44f45376fcf 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -31,6 +31,8 @@ import { markHugeRepoWarningDismissed } from '@/lib/source-control-huge-repo-warning-dismissals' +const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) + vi.mock('sonner', () => ({ toast: { warning: vi.fn(), @@ -41,6 +43,10 @@ vi.mock('sonner', () => ({ } })) +vi.mock('@/components/worktree-base-fallback-notice', () => ({ + requestWorktreeBaseFallbackNotice +})) + const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() const worktreeListMock = vi.fn().mockResolvedValue([]) @@ -3969,6 +3975,24 @@ describe('createWorktree base status merge', () => { }) }) + it('requests a warning dialog when creation falls back to a local base', async () => { + const store = createTestStore() + const wt = makeWorktree({ + id: 'repo1::/path/wt1', + repoId: 'repo1', + path: '/path/wt1' + }) + const baseFallback = { + requestedRef: 'origin/main', + localRef: 'main' + } + mockApi.worktrees.create.mockResolvedValue({ worktree: wt, baseFallback }) + + await store.getState().createWorktree('repo1', 'feature', 'origin/main') + + expect(requestWorktreeBaseFallbackNotice).toHaveBeenCalledWith(baseFallback) + }) + it.each([ { status: 'skipped_dirty_worktree', diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 27f04a3c8eb..f51e76fef6e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -71,6 +71,7 @@ import { } from '@/lib/source-control-huge-repo-warning-dismissals' import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' +import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, @@ -3785,6 +3786,9 @@ export const createWorktreeSlice: StateCreator } }) showLocalBaseRefRefreshToast(result.localBaseRefRefresh) + if (result.baseFallback) { + requestWorktreeBaseFallbackNotice(result.baseFallback) + } showLocalBaseRefUpdateSuggestionToast(result.localBaseRefUpdateSuggestion, { updateSettings: get().updateSettings, getSettings: () => get().settings, diff --git a/src/shared/local-agent-install-dir-detection.ts b/src/shared/local-agent-install-dir-detection.ts new file mode 100644 index 00000000000..9b29a142f9e --- /dev/null +++ b/src/shared/local-agent-install-dir-detection.ts @@ -0,0 +1,17 @@ +import path from 'node:path' +import { resolveCliCommands } from './node-cli-command-resolution' + +// Why: detection may precede shell-PATH hydration, but the fallback stays bounded. +export function detectCommandsInInstallDirs(commands: readonly string[]): Set { + if (commands.length === 0) { + return new Set() + } + try { + const resolvedCommands = resolveCliCommands(commands) + return new Set( + commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command)) + ) + } catch { + return new Set() + } +} diff --git a/src/shared/node-cli-command-resolution.ts b/src/shared/node-cli-command-resolution.ts new file mode 100644 index 00000000000..44e85c4dd08 --- /dev/null +++ b/src/shared/node-cli-command-resolution.ts @@ -0,0 +1,218 @@ +import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { delimiter, dirname, join } from 'node:path' + +type ResolveCommandOptions = { + pathEnv?: string | null + platform?: NodeJS.Platform + homePath?: string +} + +function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] { + if (platform === 'win32') { + return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName] + } + + return [commandName] +} + +function splitPath(pathEnv: string | null | undefined): string[] { + if (!pathEnv) { + return [] + } + + return pathEnv + .split(delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function parseVersionSegment(raw: string): number[] { + return raw + .replace(/^v/i, '') + .split('.') + .map((segment) => Number.parseInt(segment, 10)) + .map((segment) => (Number.isFinite(segment) ? segment : 0)) +} + +function compareVersionDesc(left: string, right: string): number { + const leftParts = parseVersionSegment(left) + const rightParts = parseVersionSegment(right) + const length = Math.max(leftParts.length, rightParts.length) + + for (let index = 0; index < length; index += 1) { + const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0) + if (delta !== 0) { + return delta + } + } + + return right.localeCompare(left) +} + +function findFirstExecutable( + platform: NodeJS.Platform, + directories: string[], + executableNames: string[] +): string | null { + for (const directory of directories) { + for (const executableName of executableNames) { + const candidate = join(directory, executableName) + if (isRunnableCommand(platform, candidate)) { + return candidate + } + } + } + + return null +} + +function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean { + try { + const stats = statSync(candidate) + if (!stats.isFile()) { + return false + } + if (platform === 'win32') { + return true + } + // Why: GUI fallback probing should skip placeholders/directories so spawn + // can continue to a runnable CLI instead of failing later with EACCES/EISDIR. + accessSync(candidate, constants.X_OK) + return true + } catch { + return false + } +} + +function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] { + const directories = [ + join(homePath, '.volta', 'bin'), + join(homePath, '.asdf', 'shims'), + join(homePath, '.fnm', 'aliases', 'default', 'bin'), + // Why: mise (formerly rtx) exposes managed tool binaries via a shims + // directory, similar to asdf. + join(homePath, '.local', 'share', 'mise', 'shims') + ] + + if (platform === 'win32') { + // Why: Anthropic's native Windows installer places claude.exe here, and + // GUI-launched Orca may not inherit the user's PATH entry for it. + directories.push(join(homePath, '.local', 'bin')) + directories.push(join(homePath, 'AppData', 'Roaming', 'npm')) + directories.push(join(homePath, 'AppData', 'Local', 'pnpm')) + directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin')) + } else { + directories.push(join(homePath, '.local', 'bin')) + // Why: pnpm uses platform-specific global bin directories that differ from + // npm's ~/.local/bin. + if (platform === 'darwin') { + directories.push(join(homePath, 'Library', 'pnpm')) + } else { + directories.push(join(homePath, '.local', 'share', 'pnpm')) + } + directories.push(join(homePath, '.yarn', 'bin')) + } + + directories.push(join(homePath, '.bun', 'bin')) + return directories +} + +function getNvmVersionDirectories(homePath: string): string[] { + const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node') + if (!existsSync(nvmVersionsDir)) { + return [] + } + + return readdirSync(nvmVersionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareVersionDesc) + .map((entry) => join(nvmVersionsDir, entry, 'bin')) +} + +function getVersionManagerDirectories( + platform: NodeJS.Platform, + homePath: string, + executableNames: string[] +): string[] { + const directories = getBaseVersionManagerDirectories(platform, homePath) + const firstNvmMatch = findFirstExecutable( + platform, + getNvmVersionDirectories(homePath), + executableNames + ) + if (firstNvmMatch) { + directories.unshift(dirname(firstNvmMatch)) + } + return directories +} + +export function resolveCliCommand( + commandName: string, + options: ResolveCommandOptions = {} +): string { + const platform = options.platform ?? process.platform + const executableNames = getExecutableNames(platform, commandName) + const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null + const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames) + if (pathCandidate) { + return pathCandidate + } + + const homePath = options.homePath ?? homedir() + const nvmCandidate = findFirstExecutable( + platform, + getNvmVersionDirectories(homePath), + executableNames + ) + const versionManagerCandidate = + nvmCandidate ?? + findFirstExecutable( + platform, + getBaseVersionManagerDirectories(platform, homePath), + executableNames + ) + return versionManagerCandidate ?? commandName +} + +export function resolveCliCommands( + commandNames: readonly string[], + options: ResolveCommandOptions = {} +): Map { + const platform = options.platform ?? process.platform + const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null + const pathDirectories = splitPath(pathEnv) + const homePath = options.homePath ?? homedir() + const installDirectories = [ + ...getNvmVersionDirectories(homePath), + ...getBaseVersionManagerDirectories(platform, homePath) + ] + const resolved = new Map() + + for (const commandName of new Set(commandNames)) { + const executableNames = getExecutableNames(platform, commandName) + const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames) + const installCandidate = + pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames) + resolved.set(commandName, installCandidate ?? commandName) + } + + return resolved +} + +export function resolveCodexCommand(options: ResolveCommandOptions = {}): string { + return resolveCliCommand('codex', options) +} + +export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string { + return resolveCliCommand('claude', options) +} + +// Why: Node-script CLIs need their version-manager sibling `node` on PATH. +export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] { + const platform = options.platform ?? process.platform + const homePath = options.homePath ?? homedir() + const nodeNames = getExecutableNames(platform, 'node') + return getVersionManagerDirectories(platform, homePath, nodeNames) +} diff --git a/src/shared/pr-check-status.test.ts b/src/shared/pr-check-status.test.ts index fc8c8415428..b924b68bc19 100644 --- a/src/shared/pr-check-status.test.ts +++ b/src/shared/pr-check-status.test.ts @@ -33,4 +33,8 @@ describe('provider-neutral check status', () => { expect(derivePRCheckStatusFromRollup([{ state: 'PENDING' }])).toBe('pending') expect(derivePRCheckStatusFromRollup([{ state: 'ERROR' }])).toBe('failure') }) + + it.each(['ERROR', 'STARTUP_FAILURE'])('treats raw %s conclusions as failures', (conclusion) => { + expect(derivePRCheckStatusFromRollup([{ status: 'COMPLETED', conclusion }])).toBe('failure') + }) }) diff --git a/src/shared/pr-check-status.ts b/src/shared/pr-check-status.ts index 184b8da5a62..bf7dd5f085c 100644 --- a/src/shared/pr-check-status.ts +++ b/src/shared/pr-check-status.ts @@ -42,8 +42,10 @@ function normalizeRollupCheck(raw: RawCheckRollup, index: number): PRCheckDetail const state = String(raw.state ?? '').toLowerCase() const conclusion = String(raw.conclusion ?? '').toLowerCase() const normalizedConclusion = - conclusion || - (state === 'failure' || state === 'error' ? 'failure' : state === 'success' ? 'success' : '') + conclusion === 'error' || conclusion === 'startup_failure' + ? 'failure' + : conclusion || + (state === 'failure' || state === 'error' ? 'failure' : state === 'success' ? 'success' : '') const isPending = status === 'queued' || status === 'in_progress' || diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index cde876882c6..a022e2469f2 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -98,6 +98,8 @@ export type TerminalTitleTracker = { * Titles are unaffected; un-suppressing resets the scanners' cross-chunk carry. */ setTransientFactScanningSuppressed: (suppressed: boolean) => void + /** Enable consumer-only bell, PR-link, and mode-2031 scans without resetting title state. */ + setTransientSideEffectScanningEnabled: (enabled: boolean) => void /** Cancel the stale-title timer and clear accumulated tracker state. */ dispose: () => void } @@ -117,12 +119,13 @@ export function createTerminalTitleTracker( onMode2031Subscribe, onMode2031Unsubscribe } = callbacks - const bellDetector = onBell ? createBellDetector() : null + let bellDetector = onBell ? createBellDetector() : null // Why: created only when a consumer exists so headless serve never pays the per-chunk 133/URL scans. const commandFinishedScanner = onCommandFinished ? createOsc133CommandFinishedScanner(onCommandFinished) : null let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null + let transientSideEffectScanningEnabled = true let transientFactScanningSuppressed = false let mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE // Why: seed both so a mid-session tracker behaves as if it had observed the pane's last live title (renderer parity). @@ -210,7 +213,7 @@ export function createTerminalTitleTracker( onPrLink?.(link) } } - if (onMode2031Subscribe || onMode2031Unsubscribe) { + if (transientSideEffectScanningEnabled && (onMode2031Subscribe || onMode2031Unsubscribe)) { const previousMode2031ReplyScanState = options.mode2031PendingSubscribe ? { ...mode2031ReplyScanState, pendingSubscribe: true } : mode2031ReplyScanState @@ -238,7 +241,11 @@ export function createTerminalTitleTracker( } } // The permission BEL rides outside the OSC title; a FRESH detector avoids touching the chunk detector's cross-chunk escape state. - if (onBell && createBellDetector().chunkContainsBell(frame)) { + if ( + transientSideEffectScanningEnabled && + onBell && + createBellDetector().chunkContainsBell(frame) + ) { onBell() } // Why: deliberately skip the 133/PR-link/2031 scanners — fabricated bytes contain none and must not perturb their cross-chunk carry. @@ -271,6 +278,16 @@ export function createTerminalTitleTracker( } } }, + setTransientSideEffectScanningEnabled(enabled: boolean): void { + if (enabled === transientSideEffectScanningEnabled) { + return + } + transientSideEffectScanningEnabled = enabled + bellDetector?.reset() + bellDetector = enabled && onBell ? createBellDetector() : null + prLinkDetector = enabled && onPrLink ? createTerminalGitHubPRLinkDetector() : null + mode2031ReplyScanState = INITIAL_MODE_2031_REPLY_SCAN_STATE + }, dispose(): void { clearStaleTitleTimer() agentTracker?.reset() diff --git a/src/shared/tui-agent-detection-commands.ts b/src/shared/tui-agent-detection-commands.ts new file mode 100644 index 00000000000..b0ca1b443b8 --- /dev/null +++ b/src/shared/tui-agent-detection-commands.ts @@ -0,0 +1,77 @@ +import type { TuiAgent } from './types' +import { + getTuiAgentDetectCommands, + TUI_AGENT_CONFIG, + type TuiAgentConfig, + type TuiAgentDetectionRuntime +} from './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/shared/types.ts b/src/shared/types.ts index a4018f8ab86..b8acdc76d4b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2286,6 +2286,7 @@ export type CreateWorktreeResult = { } defaultTabs?: WorktreeDefaultTabsLaunch warning?: string + baseFallback?: WorktreeCreateBaseFallback initialBaseStatus?: WorktreeBaseStatusEvent localBaseRefRefresh?: LocalBaseRefRefreshResult localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion @@ -2300,6 +2301,11 @@ export type CreateWorktreeResult = { timing?: WorktreeCreateTiming } +export type WorktreeCreateBaseFallback = { + requestedRef: string + localRef: string +} + export type PreservedWorktreeBranch = { branchName: string head?: string @@ -3423,6 +3429,8 @@ export type PersistedUIState = { _inlineAgentsDefaultedForAllUsers?: boolean /** One-shot migration flag for split-out card properties, set once so later deliberate unchecks of Linear issue/Ports stick across restarts. */ _expandedWorktreeCardPropertiesDefaulted?: boolean + /** One-shot backfill flag for 'jira-issue', which joined the defaults after the expansion migration had already stamped upgraded profiles. */ + _jiraIssueWorktreeCardPropertyDefaulted?: boolean /** totalAgentsSpawned snapshot at first sighting of the current app version, so the nag counts agents since last update (not from zero). */ starNagBaselineAgents?: number | null /** App version that set the current baseline; a version change re-captures the baseline on next spawn, restarting the nag countdown. */ diff --git a/src/shared/windows-batch-spawn.ts b/src/shared/windows-batch-spawn.ts new file mode 100644 index 00000000000..cd20dff5c79 --- /dev/null +++ b/src/shared/windows-batch-spawn.ts @@ -0,0 +1,43 @@ +import { win32 } from 'node:path' + +/** Full path to cmd.exe for GUI and service-launched processes. */ +export function getCmdExePath(): string { + return ( + process.env.ComSpec || + win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe') + ) +} + +export function isWindowsBatchScript(commandPath: string): boolean { + return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath) +} + +export const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS' + +export class UnsafeWindowsBatchArgumentsError extends Error { + constructor() { + super(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR) + this.name = 'UnsafeWindowsBatchArgumentsError' + } +} + +function hasUnsafeWindowsBatchSyntax(value: string): boolean { + return /[&|<>^"%!()\r\n]/.test(value) +} + +export function getSpawnArgsForWindows( + command: string, + args: string[] +): { spawnCmd: string; spawnArgs: string[] } { + if (isWindowsBatchScript(command)) { + for (const value of [command, ...args]) { + if (hasUnsafeWindowsBatchSyntax(value)) { + throw new UnsafeWindowsBatchArgumentsError() + } + } + + // Why: separate argv entries let Node quote spaces without breaking cmd. + return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] } + } + return { spawnCmd: command, spawnArgs: args } +} diff --git a/tests/e2e/artificial-opencode-pane-interactions.ts b/tests/e2e/artificial-opencode-pane-interactions.ts index f93c3a0788d..a8998c7b994 100644 --- a/tests/e2e/artificial-opencode-pane-interactions.ts +++ b/tests/e2e/artificial-opencode-pane-interactions.ts @@ -1,11 +1,13 @@ import type { Page } from '@stablyai/playwright-test' import { expect } from './helpers/orca-app' -import { ensureTerminalVisible } from './helpers/store' +import { ensureTerminalVisible, getActiveWorktreeId, switchToWorktree } from './helpers/store' import { getTerminalContent, + readPaneIdentitySnapshot, splitActiveTerminalPane, + UUID_RE, waitForActiveTerminalManager, - waitForPaneIdentitySnapshot + type PaneIdentitySnapshot } from './helpers/terminal' export type TerminalLoadPane = { @@ -52,16 +54,47 @@ export async function focusPane(page: Page, paneKey: string): Promise { ) } +export async function waitForTerminalPtyVisible( + page: Page, + ptyId: string, + timeoutMs = 10_000 +): Promise { + await expect + .poll( + () => + page.evaluate((targetPtyId) => { + for (const manager of window.__paneManagers?.values() ?? []) { + const pane = manager + .getPanes?.() + .find((candidate) => candidate.container.dataset.ptyId === targetPtyId) + if (pane) { + return pane.container.isConnected && pane.container.getClientRects().length > 0 + } + } + return false + }, ptyId), + { + timeout: timeoutMs, + message: `Terminal PTY ${ptyId} did not become visible` + } + ) + .toBe(true) +} + export async function ensureActiveWorktreePaneLoad( page: Page, paneCount: number ): Promise { await ensureTerminalVisible(page) await waitForActiveTerminalManager(page, 30_000) - let snapshot = await waitForPaneIdentitySnapshot(page, 1) + const worktreeId = await getActiveWorktreeId(page) + if (!worktreeId) { + throw new Error('Active worktree is unavailable for terminal pane load') + } + let snapshot = await waitForActiveWorktreePaneLoad(page, worktreeId, 1) while (snapshot.panes.length < paneCount) { await splitActiveTerminalPane(page, snapshot.panes.length % 2 === 0 ? 'horizontal' : 'vertical') - snapshot = await waitForPaneIdentitySnapshot(page, snapshot.panes.length + 1) + snapshot = await waitForActiveWorktreePaneLoad(page, worktreeId, snapshot.panes.length + 1) } return snapshot.panes.slice(0, paneCount).map((pane) => ({ paneKey: `${snapshot.tabId}:${pane.leafId}`, @@ -69,6 +102,47 @@ export async function ensureActiveWorktreePaneLoad( })) } +async function waitForActiveWorktreePaneLoad( + page: Page, + worktreeId: string, + paneCount: number +): Promise { + let snapshot: PaneIdentitySnapshot | null = null + await expect + .poll( + async () => { + if ((await getActiveWorktreeId(page)) !== worktreeId) { + // Why: late session reconciliation can clear selection while split PTYs bind. + await switchToWorktree(page, worktreeId) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + } + snapshot = await readPaneIdentitySnapshot(page) + return Boolean( + snapshot && + snapshot.panes.length === paneCount && + snapshot.panes.every( + (pane) => + UUID_RE.test(pane.leafId) && + pane.stablePaneId === pane.leafId && + pane.datasetLeafId === pane.leafId && + pane.ptyId !== null && + snapshot?.ptyIdsByLeafId[pane.leafId] === pane.ptyId + ) + ) + }, + { + timeout: 15_000, + message: 'Artificial load panes did not settle with stable PTY bindings' + } + ) + .toBe(true) + if (!snapshot) { + throw new Error('Artificial load pane snapshot is unavailable') + } + return snapshot +} + export async function waitForMarkerLatency( page: Page, marker: string, diff --git a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts index c533a395e22..48643b8d8de 100644 --- a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts @@ -17,6 +17,7 @@ import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal' +import { waitForTerminalPtyVisible } from './artificial-opencode-pane-interactions' type RevisitPressurePane = { paneKey: string; ptyId: string } @@ -161,6 +162,7 @@ export async function runRendererBackpressureRevisitScenario< await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForTerminalPtyVisible(orcaPage, typingPtyId) const measurement = await deps.measureTypingDuringLoad( orcaPage, typingScriptPath, @@ -198,6 +200,8 @@ export async function runRendererBackpressureRevisitScenario< await switchToWorktree(orcaPage, firstWorktreeId) await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) + // Why: hidden PaneManagers persist, so manager readiness alone can race the reveal commit. + await waitForTerminalPtyVisible(orcaPage, revisitPane.ptyId) await deps.focusPane(orcaPage, revisitPane.paneKey) await sendToTerminal(orcaPage, revisitPane.ptyId, `printf '\\n${revisitMarker}\\n'\r`) const revisitLatencyMs = await waitForMarkerLatency(orcaPage, revisitMarker, 10_000) diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index 9b937775a84..3e857d3258f 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -146,7 +146,8 @@ const MAX_TIMER_DRIFT_MS = 250 // the real baseline. const MAX_TIMER_DRIFT_UNDER_LOAD_MS = 2_500 const MAX_SCROLL_LATENCY_MS = 150 -const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 3 * 1024 * 1024 +// Why: byte-level peaks vary by drain quantum; the coarse guard matches the main-pressure scenario. +const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 5 * 1024 * 1024 function readPositiveInt(name: string, fallback: number): number { const raw = process.env[name] diff --git a/tests/e2e/browser-split-find-shortcut.spec.ts b/tests/e2e/browser-split-find-shortcut.spec.ts index a97448ec019..39847bdeac5 100644 --- a/tests/e2e/browser-split-find-shortcut.spec.ts +++ b/tests/e2e/browser-split-find-shortcut.spec.ts @@ -88,6 +88,17 @@ function browserAddressBar(page: Page, browserTabId: string) { ) } +async function focusBrowserAddressBar(page: Page, browserTabId: string): Promise { + const browserOverlay = page.locator(`[data-browser-overlay-tab-id="${browserTabId}"]`) + const addressBar = browserAddressBar(page, browserTabId) + const addressBarForm = browserOverlay.locator( + 'form:has(> [data-orca-browser-address-bar="true"])' + ) + await expect(addressBarForm).toBeVisible() + await addressBarForm.click() + await expect(addressBar).toBeFocused() +} + function browserFindInput(page: Page) { return page.getByPlaceholder('Find in page...') } @@ -200,9 +211,7 @@ test.describe('browser split Find shortcut', () => { await orcaPage.keyboard.press('Escape') await focusBrowserGroup(orcaPage, fixture.browserGroupId) - const browserAddress = browserAddressBar(orcaPage, fixture.browserTabId) - await expect(browserAddress).toBeVisible() - await browserAddress.click() + await focusBrowserAddressBar(orcaPage, fixture.browserTabId) await orcaPage.keyboard.press(`${modifier}+f`) await expect(browserFindInput(orcaPage)).toBeFocused() await expect(terminalFindInput(orcaPage)).toBeHidden() @@ -243,9 +252,7 @@ test.describe('browser split Find shortcut', () => { const fixture = await createTerminalBrowserSplit(orcaPage) await focusBrowserGroup(orcaPage, fixture.browserGroupId) const addressBar = browserAddressBar(orcaPage, fixture.browserTabId) - await expect(addressBar).toBeVisible() - await addressBar.click() - await expect(addressBar).toBeFocused() + await focusBrowserAddressBar(orcaPage, fixture.browserTabId) await orcaPage.evaluate(() => { const store = window.__store @@ -270,9 +277,7 @@ test.describe('browser split Find shortcut', () => { const fixture = await createTerminalBrowserSplit(orcaPage) await focusBrowserGroup(orcaPage, fixture.browserGroupId) const addressBar = browserAddressBar(orcaPage, fixture.browserTabId) - await expect(addressBar).toBeVisible() - await addressBar.click() - await expect(addressBar).toBeFocused() + await focusBrowserAddressBar(orcaPage, fixture.browserTabId) await orcaPage.evaluate(() => { const store = window.__store diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts index 77b85929d70..1a15cfd2897 100644 --- a/tests/e2e/headless-serve-desktop-activation.spec.ts +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -23,6 +23,7 @@ import { } from './helpers/terminal' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { RuntimeClient } from '../../src/cli/runtime/client' +import { RuntimeClientError } from '../../src/cli/runtime/types' import type { RuntimeStatus, RuntimeTerminalCreate, @@ -119,10 +120,22 @@ test('promotes the headless owner without replacing its daemon terminal', async const client = new RuntimeClient(userDataDir, 5_000) await expect - .poll(async () => (await client.getCliStatus()).result.app.desktopWindowStatus, { - timeout: 60_000, - message: 'headless serve never became safely openable' - }) + .poll( + async () => { + try { + return (await client.getCliStatus()).result.app.desktopWindowStatus + } catch (error) { + if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') { + return 'starting' + } + throw error + } + }, + { + timeout: 60_000, + message: 'headless serve never became safely openable' + } + ) .toBe('openable') const beforeStatus = await client.call('status.get') diff --git a/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts b/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts new file mode 100644 index 00000000000..9cd76f0d3e5 --- /dev/null +++ b/tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts @@ -0,0 +1,234 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import type { + RuntimeTerminalListResult, + RuntimeTerminalRead, + RuntimeTerminalShow +} from '../../src/shared/runtime-types' +import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedWebClient +} from './helpers/paired-electron-client' +import { getTerminalContent, waitForActivePanePtyId } from './helpers/terminal' + +const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-probe-gap-')) +const fixturePath = path.join(scratch, 'probe-gap-terminal.mjs') +const processedInputPath = path.join(scratch, 'processed-input.txt') +writeFileSync(processedInputPath, '') +writeFileSync( + fixturePath, + [ + "import { appendFileSync } from 'node:fs'", + 'const processedInputPath = process.argv[2]', + "process.stdout.write('PROBE_GAP_READY\\r\\n')", + "process.stdin.setEncoding('utf8')", + "let pending = ''", + "process.stdin.on('data', (data) => {", + ' pending += data', + ' const commands = pending.split(/\\r\\n|\\r|\\n/)', + ' pending = commands.pop() ?? ""', + ' for (const input of commands) {', + ' appendFileSync(processedInputPath, `${input}\\n`)', + ' process.stdout.write(`LIVE:${input}\\r\\n`)', + ' }', + '})', + 'process.stdin.resume()' + ].join('\n') +) + +test.afterAll(() => { + rmSync(scratch, { recursive: true, force: true }) +}) + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function fixtureCommand(): string { + const command = [process.execPath, fixturePath, processedInputPath] + return process.platform === 'win32' + ? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ') + : command.map(shellQuote).join(' ') +} + +async function callRuntime(page: Page, method: string, params: unknown): Promise { + return page.evaluate( + async ({ method, params }) => { + const response = await window.api.runtime.call({ method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params } + ) as Promise +} + +test('replaces a stale paired stream when the PTY snapshot advanced @headful', async ({ + electronApp, + orcaPage +}) => { + test.setTimeout(90_000) + const worktreeId = await orcaPage.evaluate(() => { + const state = window.__store?.getState() + const id = state?.activeWorktreeId + if (!id || !state?.allWorktrees().some((candidate) => candidate.id === id)) { + throw new Error('Headed host did not select its seeded worktree') + } + return id + }) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedWebClient(electronApp, offer) + let terminal: string | null = null + try { + await expect + .poll( + () => + client.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((candidate) => candidate.id === id), + worktreeId + ), + { timeout: 30_000 } + ) + .toBe(true) + const created = await callRuntime<{ + tab: { parentTabId: string; terminal: string | null } + }>(client.page, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + command: fixtureCommand(), + activate: false, + select: false, + navigation: 'caller' + }) + terminal = created.tab.terminal + if (!terminal) { + throw new Error('Paired runtime did not publish the probe-gap fixture') + } + const webTabId = toWebTerminalSurfaceTabId(created.tab.parentTabId) + await client.page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId) + const tab = client.page.locator(`[data-testid="sortable-tab"][data-tab-id="${webTabId}"]`) + await expect(tab).toBeVisible({ timeout: 30_000 }) + await tab.click() + await expect(tab).toHaveAttribute('data-active', 'true') + const originalPtyId = await waitForActivePanePtyId(client.page, 30_000) + const originalHostTerminal = await callRuntime<{ terminal: RuntimeTerminalShow }>( + orcaPage, + 'terminal.show', + { terminal } + ) + expect(originalHostTerminal.terminal.ptyId).not.toBeNull() + await expect + .poll(() => getTerminalContent(client.page), { timeout: 30_000 }) + .toContain('PROBE_GAP_READY') + const textarea = client.page.locator('.xterm-helper-textarea:visible').first() + await textarea.focus() + + expect( + await client.page.evaluate((target) => { + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + dropOutputUntilResubscribe: (terminals: string[]) => number + } + } + ).__remoteTerminalMultiplexAckGate + if (!gate) { + throw new Error('Remote terminal multiplex output gate is unavailable') + } + return gate.dropOutputUntilResubscribe([target]) + }, terminal) + ).toBe(1) + const missingMarker = `PROBE_GAP_MISSING_${Date.now()}` + await client.page.keyboard.type(missingMarker) + await client.page.keyboard.press('Enter') + + await expect + .poll( + () => + client.page.evaluate(() => { + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + snapshot: () => { droppedOutputFrames: number } + } + } + ).__remoteTerminalMultiplexAckGate + return gate?.snapshot().droppedOutputFrames ?? 0 + }), + { timeout: 10_000 } + ) + .toBeGreaterThan(0) + await expect + .poll(() => readFileSync(processedInputPath, 'utf8'), { timeout: 10_000 }) + .toContain(`${missingMarker}\n`) + await expect + .poll( + async () => { + const result = await callRuntime<{ terminal: RuntimeTerminalRead }>( + orcaPage, + 'terminal.read', + { terminal } + ) + return result.terminal.tail.join('\n') + }, + { timeout: 10_000 } + ) + .toContain(missingMarker) + expect(await getTerminalContent(client.page)).not.toContain(`LIVE:${missingMarker}`) + + await expect + .poll(() => getTerminalContent(client.page), { timeout: 20_000 }) + .toContain(`LIVE:${missingMarker}`) + await expect(tab).toHaveAttribute('data-active', 'true') + expect(await waitForActivePanePtyId(client.page, 30_000)).toBe(originalPtyId) + const recoveredHostTerminal = await callRuntime<{ terminal: RuntimeTerminalShow }>( + orcaPage, + 'terminal.show', + { terminal } + ) + expect(recoveredHostTerminal.terminal.ptyId).toBe(originalHostTerminal.terminal.ptyId) + const hostTerminals = await callRuntime(orcaPage, 'terminal.list', { + worktree: `id:${worktreeId}`, + requireFreshPtyLiveness: true + }) + expect( + hostTerminals.terminals + .filter((candidate) => candidate.tabId === created.tab.parentTabId) + .map((candidate) => ({ handle: candidate.handle, ptyId: candidate.ptyId })) + ).toEqual([{ handle: terminal, ptyId: originalHostTerminal.terminal.ptyId }]) + + const liveMarker = `PROBE_GAP_LIVE_${Date.now()}` + await textarea.focus() + await client.page.keyboard.type(liveMarker) + await client.page.keyboard.press('Enter') + await expect + .poll(() => getTerminalContent(client.page), { timeout: 10_000 }) + .toContain(`LIVE:${liveMarker}`) + await expect + .poll(() => readFileSync(processedInputPath, 'utf8'), { timeout: 10_000 }) + .toContain(`${liveMarker}\n`) + expect(await waitForActivePanePtyId(client.page, 30_000)).toBe(originalPtyId) + } finally { + await client.page + .evaluate(() => { + ;( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { release: () => void } + } + ).__remoteTerminalMultiplexAckGate?.release() + }) + .catch(() => undefined) + if (terminal) { + await callRuntime(orcaPage, 'terminal.closeTab', { terminal }).catch(() => undefined) + } + await client.dispose() + } +})