Merge remote-tracking branch 'origin/main' into OrcaWin/fix-windows-large-paste

This commit is contained in:
OrcaWin
2026-07-30 18:53:45 -07:00
120 changed files with 7441 additions and 1106 deletions
@@ -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
}
+53 -1
View File
@@ -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
+61 -79
View File
@@ -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)) {
"<https://github.com/$($env:CUT_BY)|@$($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)) {
"<https://github.com/$($env:CUT_BY)|@$($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 = @(
@@ -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
+12 -6
View File
@@ -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
+30 -5
View File
@@ -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": [
@@ -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 <socket>"); 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({
@@ -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'),
@@ -177,7 +177,8 @@ describe('PR workflow parallelism', () => {
'git_compatibility',
'shell_contracts',
'test',
'package'
'package',
'package_windows'
])
})
})
@@ -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')
}
})
})
@@ -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"')
})
})
+36 -4
View File
@@ -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 })
}
@@ -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 }
@@ -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 })
}
})
})
@@ -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)
}
})
})
-2
View File
@@ -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",
+2
View File
@@ -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": {
+6 -3
View File
@@ -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
+2 -10
View File
@@ -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.
+123 -1
View File
@@ -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<string | undefined, string>()
// 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<string | undefined, string>()
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')
})
})
+10 -10
View File
@@ -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<string | null> {
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<void> {
@@ -54,7 +53,7 @@ async function writeDeviceToken(hostId: string, token: string): Promise<void> {
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<void> {
@@ -62,7 +61,7 @@ async function deleteDeviceToken(hostId: string): Promise<void> {
await AsyncStorage.removeItem(webTokenKey(hostId))
return
}
await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
await deletePairingKeychainItem(tokenKey(hostId))
}
async function deleteHostCredentials(hostId: string): Promise<void> {
@@ -342,4 +341,5 @@ export function resetHostStoreForTests(): void {
hostListMutation = Promise.resolve()
tokenCache.clear()
inflightLoad = null
resetPairingKeychainForTests()
}
@@ -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
@@ -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<typeof MobileRelayCredentialBundleSchema>
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<MobileRelayCredentialBundle | null> {
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<void> {
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<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
await deletePairingKeychainItem(credentialKey(hostId))
}
function requireNativeSecretStore(): void {
@@ -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<typeof MobileRelayDirectUpgradeJournalSchema>
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<MobileRelayDirectUpgradeJournal | null> {
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<void> {
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<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS)
await deletePairingKeychainItem(journalKey(hostId))
}
function encodeBase64Url(value: Uint8Array): string {
@@ -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<PairingOffer['relay']> },
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<PairingOffer['relay']> },
@@ -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()
@@ -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<void> = 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<MobileRelayPairin
const load = journalMutation.then(async () => {
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<MobileRelayPairin
await removeIncompleteJournal()
return null
}
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
const rawSecrets = await readPairingKeychainItem(JOURNAL_SECRET_KEY)
if (rawSecrets === null) {
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
return null
@@ -83,7 +85,7 @@ async function removeIncompleteJournal(): Promise<void> {
// 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()
}
@@ -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<void>((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')
})
})
+257
View File
@@ -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<void> = 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<number | null> {
if (Platform.OS !== 'android') {
return null
}
return parsePresenceGeneration(await AsyncStorage.getItem(presenceStorageKey(key)))
}
async function loadGeneration(): Promise<LoadedGeneration> {
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<void> {
const raw = pending ? `${generation}:pending` : String(generation)
await AsyncStorage.setItem(GENERATION_STORAGE_KEY, raw)
cachedGeneration = { generation, pending }
}
function enqueueKeychainMutation(operation: () => Promise<void>): Promise<void> {
const mutation = keychainMutation.then(operation)
keychainMutation = mutation.catch(() => {})
return mutation
}
async function preparePresenceWrite(
key: string,
generation: number
): Promise<PresenceChange | null> {
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<void> {
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<void> {
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<string | null> {
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<void> {
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<void> {
return enqueueKeychainMutation(() => writePairingKeychainItemImpl(key, value))
}
async function deletePairingKeychainItemImpl(key: string): Promise<void> {
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<void> {
return enqueueKeychainMutation(() => deletePairingKeychainItemImpl(key))
}
/** Test-only: drop cached generation and mutation state between cases. */
export function resetPairingKeychainForTests(): void {
cachedGeneration = null
keychainMutation = Promise.resolve()
}
+4 -2
View File
@@ -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",
+2 -2
View File
@@ -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 {
+5 -2
View File
@@ -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'
+7 -4
View File
@@ -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,
+4 -4
View File
@@ -41,8 +41,8 @@ function findElectronViteMainEntries(): Set<string> {
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)
})
})
+3 -3
View File
@@ -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<typeof CodexCliCommandModule>()),
resolveCliCommand: resolveCliCommandMock
}))
+1 -226
View File
@@ -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<string, string> {
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<string, string>()
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'
@@ -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.'
})
})
@@ -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(
+23 -19
View File
@@ -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.
@@ -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<string> {
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'
+1 -1
View File
@@ -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
}))
@@ -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> = {}): 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(
@@ -41,7 +41,12 @@ export class SshPtyOutputSourceObligations {
private readonly coordinator: SshPtySourceObligationCoordinator
private readonly remoteConsumers: SshPtyRemoteSourceRangeConsumers
private readonly openedTokens = new Set<string>()
private readonly identityByPty = new Map<string, PtySourceDeliveryIdentity>()
// 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<void> {
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<SshPtySourceCancellationProof>
): Promise<SshPtySourceCancellationProofCommit | null> {
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)
}
}
+1 -77
View File
@@ -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<string>,
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'
+51 -17
View File
@@ -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
+67 -8
View File
@@ -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',
+165 -4
View File
@@ -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,
+33 -4
View File
@@ -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<string, WorktreeMeta> 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.
+75
View File
@@ -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<string, { commandCodeDetector: unknown }>
}
).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)
+70 -41
View File
@@ -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<RuntimePtyTitleTrackerEntry['commandCodeDetector']> {
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
@@ -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<typeof vi.fn>
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
})
})
@@ -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<void>((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}`
@@ -11,6 +11,7 @@ type MainOwnedUIState =
| 'trayMinimizeNoticeShown'
| 'dashboardPopoutBounds'
| '_expandedWorktreeCardPropertiesDefaulted'
| '_jiraIssueWorktreeCardPropertyDefaulted'
| 'starNagBaselineAgents'
| 'starNagAppVersion'
| 'starNagNextThreshold'
@@ -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<string, unknown>): void {
const complete = onNotificationByMethodMock.mock.calls.findLast(
([method]) => method === 'pty.recoveryComplete'
)?.[1] as ((params: Record<string, unknown>) => void) | undefined
complete?.(params)
}
async function prepareRecovery(targetId: string): Promise<{
session: SshRelaySession
deps: ReturnType<typeof createMockDeps>
@@ -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<string, unknown>) => 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<string, unknown>) => 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<string, unknown>) => void) | undefined
complete?.({
completeRecovery({
id: 'pty-1',
clientGeneration: 3,
ownerGeneration: 3,
+6 -2
View File
@@ -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,
@@ -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)
})
})
+35
View File
@@ -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
}
+18 -6
View File
@@ -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'])
})
})
})
+8 -61
View File
@@ -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<void> {
}
)
}
/**
* 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 }
}
+14
View File
@@ -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'
])
})
})
+11
View File
@@ -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)
}
+4
View File
@@ -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)
}
+558
View File
@@ -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 <distro>` 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', () => {
+186 -42
View File
@@ -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<string[]> {
if (wslDistroCache !== null) {
return wslDistroCache
if (shouldReuseCachedWslDistros()) {
return wslDistroCache ?? []
}
if (process.platform !== 'win32') {
@@ -154,17 +196,17 @@ export async function listWslDistrosAsync(): Promise<string[]> {
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 <distro>` 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<string | null> {
}
}
// 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<string> {
@@ -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()
})
})
+193
View File
@@ -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<string, unknown>,
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()
}
})
})
})
+43 -20
View File
@@ -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<void> {
@@ -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()
@@ -56,6 +56,11 @@ describe('PtyHandler negotiated source publication', () => {
let heldResponseSettlements: ((result: SinkWriteSettlement) => void)[]
let adapter: SshPtyConsumerSessionAdapter
let pausePty: ReturnType<typeof vi.fn>
let exitCallback: ((event: { exitCode: number }) => void) | undefined
let destroyPty: ReturnType<typeof vi.fn>
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<string, unknown>): Promise<void> {
const activation = spawnResult.sourceActivation as Record<string, unknown>
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<Record<string, unknown>>): 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[] = []
+117
View File
@@ -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<string, unknown>[]
let dataCallback: ((data: string) => void) | undefined
async function setupRetryHarness(spawnParams: Record<string, unknown> = {}): Promise<void> {
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<string, unknown>) => {
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' })
+44 -14
View File
@@ -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)
@@ -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`
)
}
}
@@ -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()
+11 -8
View File
@@ -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())
+20 -1
View File
@@ -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<string, DeliveryRecord>,
closedSnapshots: ReadonlyMap<string, PtySourceDeliverySnapshot>,
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<DeliveryRecord>): number {
let total = 0
for (const record of records) {
@@ -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<string, unknown> }
function requestFrame(id: number, method: string, params: Record<string, unknown>): Buffer {
return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0)
}
function decode(buffer: Buffer): Record<string, unknown> | 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<string, unknown> | null {
const message = decode(buffer)
return !message || message.id === undefined
? null
: ((message.result as Record<string, unknown>) ?? null)
}
async function flushRequests(): Promise<void> {
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)
})
})
@@ -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> = {}
): 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> = {}
): 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<string, RelayPtySourceDeliveryRecord>([['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()
}
})
})
+50 -8
View File
@@ -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)
}
}
+63
View File
@@ -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 }
)
}
+13 -5
View File
@@ -19,7 +19,9 @@ function requestFrame(id: number, method: string, params: Record<string, unknown
return encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0)
}
function notification(buffer: Buffer): { method: string; params: Record<string, unknown> } | null {
type Notification = { method: string; params: Record<string, unknown> }
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 () => {
+36 -36
View File
@@ -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,
@@ -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> = {}
): 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<string, RelayPtySourceDeliveryRecord>([['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
})
})
})
+36 -5
View File
@@ -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
@@ -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)
@@ -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)
+13 -8
View File
@@ -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<string, unknown>,
grant: Readonly<PtyConsumerSessionGrant> | 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)
}
}
+2
View File
@@ -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 {
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
<SkillFreshnessNudge />
<WorktreeBaseFallbackDialog />
<PinnedTabCloseDialog />
{/* Why: Electron's drag-region hit-test is DOM-order-based (ignores z-index); render last so WindowControls stay clickable. */}
{hasCustomTitleBar && <WindowControls />}
@@ -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<void> {
useAppStore.setState({
activeModal,
setContextualToursBlockingSurfaceVisible: vi.fn()
})
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<WorktreeBaseFallbackDialog />)
})
}
function getButton(label: string): HTMLButtonElement {
const button = [...document.body.querySelectorAll<HTMLButtonElement>('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')
})
})
@@ -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 (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
dismissWorktreeBaseFallbackNotice()
}
}}
>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<TriangleAlert className="size-4 text-muted-foreground" aria-hidden="true" />
{translate(
'auto.components.WorktreeBaseFallbackDialog.title',
'Workspace created from a local base'
)}
</DialogTitle>
{displayedNotice ? (
<DialogDescription className="text-xs leading-relaxed">
{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 }
)}
</DialogDescription>
) : null}
</DialogHeader>
<DialogFooter>
<Button type="button" size="sm" autoFocus onClick={dismissWorktreeBaseFallbackNotice}>
{translate('auto.components.WorktreeBaseFallbackDialog.dismiss', 'Got it')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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'),
@@ -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 })
@@ -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()
@@ -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 }) => {
@@ -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<PtyTransport['connect']>[0] | null = null
let lastAttachOptions: Parameters<PtyTransport['attach']>[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<string | null | undefined | false> {
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<boolean> {
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<string | null | undefined | false> {
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<PtyConnectResult | undefined> {
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 &&
@@ -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()
}
+5
View File
@@ -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": {
@@ -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)
})
})
@@ -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
}
@@ -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<typeof useWindowsTerminalCapabilities> | 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',
@@ -174,7 +174,7 @@ export function loadWindowsTerminalCapabilities(
}
export function refreshWindowsTerminalCapabilities(
ownerKey: string | undefined = undefined,
ownerKey?: string,
target: WindowsTerminalCapabilityLoadTarget = { kind: 'local' },
sshConnectionId?: string | null
): Promise<WindowsTerminalCapabilities> {
@@ -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) {
@@ -4,6 +4,18 @@ import type { WindowsTerminalCapabilities } from './windows-terminal-capabilitie
export type WindowsTerminalCapabilityLoadTarget = RuntimeClientTarget
async function reconcileWslAvailability(
available: boolean,
distros: string[],
probe: () => Promise<boolean>
): Promise<boolean> {
// 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<boolean>(target, 'host.wsl.isAvailable', undefined, {
timeoutMs: 15_000
})
)
return {
wslAvailable,
wslAvailable: reconciledWslAvailable,
wslDistros,
pwshAvailable,
gitBashAvailable,
+2 -2
View File
@@ -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(
<StrictMode>
<I18nProvider>
<RendererRoot />

Some files were not shown because too many files have changed in this diff Show More