Merge remote-tracking branch 'origin/main' into brennanb2025/fix-10385-false-connected

# Conflicts:
#	mobile/src/transport/rpc-client.ts
This commit is contained in:
Brennan Benson
2026-07-31 00:49:46 -07:00
490 changed files with 34376 additions and 4138 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
}
+36
View File
@@ -9,6 +9,12 @@ on:
- 'config/scripts/build-windows-cli-launcher.mjs'
- 'config/scripts/build-windows-cli-launcher.test.mjs'
- 'config/scripts/computer-e2e-workflow.test.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-processes.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'
- 'config/scripts/computer-use-modifier-safety.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
@@ -59,6 +65,8 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
@@ -85,6 +93,8 @@ jobs:
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
config/scripts/computer-use-modifier-safety.test.mjs
config/scripts/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
@@ -140,6 +150,32 @@ jobs:
ORCA_COMPUTER_E2E: '1'
run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts
mac-native-owner-smoke:
if: github.event_name == 'pull_request'
runs-on: macos-15
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
- name: Owner-loss benchmark process cleanup
run: >-
pnpm vitest run
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
- name: Authenticated helper owner-loss smoke
run: pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1
- name: Swift tests and signed universal helper verification
run: pnpm verify:computer-native
mac:
# macOS Accessibility and Screen Recording require user-granted TCC entries.
# Keep this on manual/scheduled permission-bearing runners instead of PR CI.
+330
View File
@@ -0,0 +1,330 @@
name: Hourly macOS Dev Build
# Why: gives developers a signed macOS build of main every hour that the in-app
# updater can install directly, without waiting for an RC cut.
#
# Deliberately narrow scope:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
# and release-cut remain the gates that matter.
# - Signed but NOT notarized. Squirrel.Mac validates the replacement bundle's
# signature, not its notarization, so in-place updates still work while the
# ~10min notary round trip is skipped 24x a day.
#
# Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main
# repo's releases atom feed exposes only its 10 newest entries, so 24 hourly
# tags a day would evict every stable/RC entry and break updates for real users.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use a
# GitHub App installed on orca-hourly with Contents: Read and write. Its private
# key does not expire, unlike a PAT — nothing here needs yearly rotation, and the
# credential belongs to the org rather than to whoever created it.
#
# Provision the two secrets with `bash config/scripts/setup-hourly-release-token.sh`:
# HOURLY_RELEASE_APP_ID the App's numeric id
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
#
# Installation tokens live one hour, which is ample here: this job skips tests,
# notarization, and Windows signing entirely, so a run is pack + upload and lands
# well inside that. If one ever did overrun, the release is still an unpublished
# draft at that point, so nothing user-visible breaks.
on:
schedule:
# Top of every hour. Skipped automatically when main has not moved.
- cron: '0 * * * *'
workflow_dispatch:
inputs:
force:
description: Build even if main has not moved since the last hourly
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: hourly-mac-build
cancel-in-progress: false
env:
HOURLY_REPO: stablyai/orca-hourly
# Keep ~3 days of history so a regression can be bisected across a weekend.
HOURLY_RETAIN_COUNT: 72
jobs:
build-hourly-mac:
if: github.repository == 'stablyai/orca'
runs-on: blacksmith-6vcpu-macos-15
# Why 120: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x25 = 80, plus ~30 for checkout/build/verify), or
# the job is killed mid-retry and no cleanup step runs at all. A typical run
# is far shorter — there is no notarization, Windows signing, or test phase.
timeout-minutes: 120
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the hourly repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
# build step is compromised (zizmor: artipacked).
persist-credentials: false
- name: Mint hourly repo token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-hourly
# Why: main is often idle overnight. Rebuilding an unchanged commit burns a
# runner hour and adds a redundant tag to the retention window.
- name: Check whether main moved since the last hourly
id: freshness
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
run: |
set -euo pipefail
head_sha="$(git rev-parse HEAD)"
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
if [[ "$FORCED" == "true" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "Forced dispatch; building $head_sha."
exit 0
fi
# The previous hourly records its source commit in the release body.
# Drafts are excluded: an unpublished leftover never shipped, so treating
# it as "the last build" would skip a build that never actually happened.
last_body="$(gh release list --repo "$HOURLY_REPO" --limit 20 --json tagName,isDraft \
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
if [[ -z "$last_body" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "No prior hourly release found; building $head_sha."
exit 0
fi
last_sha="$(gh release view "$last_body" --repo "$HOURLY_REPO" --json body \
--jq '.body | capture("commit `(?<sha>[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)"
if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then
echo "should_build=false" >>"$GITHUB_OUTPUT"
echo "main is unchanged since $last_body ($last_sha); skipping."
else
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "main moved to $head_sha (last hourly built $last_sha); building."
fi
- name: Setup pnpm
if: steps.freshness.outputs.should_build == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
if: steps.freshness.outputs.should_build == 'true'
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
if: steps.freshness.outputs.should_build == 'true'
uses: actions/cache@v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-mac-
- name: Install dependencies
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: signing is what makes an hourly installable over an existing Orca, so
# a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/verify-macos-release-env.mjs
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Compute hourly version
id: hourly
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/hourly-build-version.mjs >>"$GITHUB_OUTPUT"
- name: Build app
if: steps.freshness.outputs.should_build == 'true'
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: hourly builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted dev artifacts.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
- name: Create hourly release
id: release
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: v${{ steps.hourly.outputs.version }}
SHA: ${{ steps.freshness.outputs.head_sha }}
run: |
set -euo pipefail
short_sha="${SHA:0:12}"
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on. It is flipped live only
# after the manifest is verified.
gh release create "$TAG" \
--repo "$HOURLY_REPO" \
--title "$TAG" \
--draft \
--notes "Automated hourly macOS dev build from commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
**Unvetted.** No tests ran. Signed but not notarized — installable
through Orca's in-app updater, but a manual download will be
Gatekeeper-quarantined."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish hourly macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
# Why lower than the release pipeline's 3x45: that budget is sized for
# notarization and Windows signing, neither of which runs here. An attempt
# is pack + upload only.
timeout_minutes: 25
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
env:
# Why: electron-builder's github publisher targets the repo named in the
# config; the token must therefore carry write access to orca-hourly.
GH_TOKEN: ${{ steps.app_token.outputs.token }}
ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
# release missing that manifest is a tag the picker offers and the download
# 404s on, so fail loudly instead of leaving a broken entry.
- name: Verify update manifest published
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$HOURLY_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Why exit 1 without deleting here: the release is still a draft, so it is
# already invisible to users, and the failure handler below owns cleanup.
# Deleting inline under `set -e` would also let the delete's exit code
# preempt this explicit failure.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Hourly draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Hourly draft $TAG has no ZIP artifact for the updater to download."
exit 1
fi
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
- name: Publish the verified release
id: publish_live
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease
echo "Published $TAG"
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.
#
# Why it is gated on publish_live not having succeeded: a later failure (the
# prune step) must not delete a release that already went live and that users
# may already be installing. A job killed by the outer timeout runs no steps
# at all — which is exactly why the release stays a draft until verified.
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
# so without it a manual cancel mid-publish would strand the draft.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -uo pipefail
# No --cleanup-tag: an unpublished draft never created a git tag.
echo "Run failed before publish; discarding draft $TAG"
gh release delete "$TAG" --repo "$HOURLY_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune old hourly releases
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
# keep showing up in tag lists with no release or assets attached.
# Drafts are excluded so retention counts shipped builds only; a stale
# draft is handled by the failure path, not by the retention window.
stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,createdAt,isDraft \
--jq "map(select(.isDraft | not)) | sort_by(.createdAt) | reverse | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune; at or under $HOURLY_RETAIN_COUNT retained builds."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
echo "Pruning $tag"
gh release delete "$tag" --repo "$HOURLY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
+56 -1
View File
@@ -273,6 +273,58 @@ jobs:
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
- name: Smoke packaged hang watchdog worker
run: xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.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 +383,7 @@ jobs:
- shell_contracts
- test
- package
- package_windows
runs-on: ubuntu-latest
steps:
@@ -350,6 +403,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 +411,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
+6
View File
@@ -44,9 +44,15 @@ package-lock.json
*~
# OS
# Keep these in step with isOsMetadataSkillEntryName (skill-package-identity.ts): both disk
# walkers skip a plain file with one of these names, the git-tree producer does not, and
# ignoring them here is what keeps `git add -A` from committing a stray one.
*.stackdump
.DS_Store
._*
Thumbs.db
ehthumbs.db
desktop.ini
# Lint/cache
.oxlintcache
-1
View File
@@ -21,7 +21,6 @@ type OutputChunk = Rollup.OutputChunk
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'main-thread-hang-watchdog-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
+34 -9
View File
@@ -14,10 +14,16 @@ 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'
// Why: hourly dev builds must carry the *release* identity — same bundle id and
// Developer ID signature — or Squirrel.Mac refuses to swap them over an installed
// Orca. Only notarization is skipped, which in-place updates never check.
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const hourlyBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION : undefined
const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
@@ -64,7 +70,11 @@ const winSpeechNativeResource = {
module.exports = {
appId,
productName: 'Orca',
...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}),
...(hourlyBuildVersion
? { extraMetadata: { version: hourlyBuildVersion } }
: localBuildVersion
? { extraMetadata: { version: localBuildVersion } }
: {}),
directories: {
buildResources: 'resources/build'
},
@@ -141,6 +151,7 @@ module.exports = {
'out/main/agent-hooks/**',
'out/main/antigravity/**',
'out/main/claude/**',
'out/main/claude-accounts/keychain.js',
'out/main/codex/**',
'out/main/copilot/**',
'out/main/cursor/**',
@@ -148,12 +159,10 @@ module.exports = {
'out/main/gemini/**',
'out/main/grok/**',
'out/main/hermes/**',
'out/main/win32-utils.js',
'out/main/daemon-entry.js',
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/main-thread-hang-watchdog-entry.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
@@ -179,7 +188,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' }
@@ -209,7 +218,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
@@ -312,7 +330,11 @@ module.exports = {
// explicit release path so production artifacts remain strict while dev
// artifacts do not fail with broken ad-hoc launch behavior.
hardenedRuntime: isMacRelease,
notarize: isMacRelease,
// Why: Squirrel.Mac validates the replacement bundle's signature, not its
// notarization, so hourly builds stay installable while skipping the ~10min
// notary round trip 24x a day. A manually-downloaded hourly zip will be
// Gatekeeper-quarantined — that is the accepted tradeoff for a dev channel.
notarize: isMacRelease && !isMacHourly,
extraResources: [
...commonExtraResources,
...createPackagedRuntimeNodeModuleResources('darwin'),
@@ -452,8 +474,11 @@ module.exports = {
publish: {
provider: 'github',
owner: 'stablyai',
repo: 'orca',
releaseType: 'release'
// Why: hourly tags must never enter the main repo's releases atom feed — it
// exposes only the 10 newest entries, so 24 hourly tags a day would evict
// every stable/RC entry and strand users on a feed with nothing to install.
repo: isMacHourly ? 'orca-hourly' : 'orca',
releaseType: isMacHourly ? 'prerelease' : 'release'
}
}
-1
View File
@@ -251,7 +251,6 @@ inline src/renderer/src/components/tab-bar/TabBar.tsx
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
inline src/renderer/src/components/task-page-cache-selectors.ts
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
+91 -16
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, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once 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": [
@@ -3155,12 +3162,16 @@
"file": "tests/e2e/paired-remote-terminal-retention-memory.spec.ts",
"assertions": [
"ordinary-parks paired terminals and restores authoritative host scrollback",
"warm-mounted hidden paired streams schedule zero renderer output work under sustained host output",
"client recovery completes while every target terminal remains unmounted in the host renderer"
]
},
{
"file": "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts",
"assertions": ["ordinary-parks paired terminals against an isolated headless Orca host"]
"assertions": [
"ordinary-parks paired terminals against an isolated headless Orca host",
"the headless host shares the zero-hidden-renderer-work and exact-restoration contract"
]
},
{
"file": "tests/e2e/terminal-parked-memory.spec.ts",
@@ -3231,6 +3242,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 +3259,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",
@@ -3316,6 +3339,24 @@
"result": "passed",
"durationSeconds": 3.16,
"summary": "All 27 encrypted shared-control tests passed. The new oracle was red on rc.1-equivalent behavior with the exact Refreshing remote runtime control transport error, then passed with one replacement connection, one delivered host RPC, and zero retained pending bytes."
},
{
"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-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"result": "passed",
"durationSeconds": 17.5,
"summary": "The headed six-PTY oracle kept all warm-mounted hidden streams at zero renderer scheduler enqueues/drains under sustained output and under 500 ms timer lag, then restored the authoritative flood tail and parked/live markers exactly once."
},
{
"date": "2026-07-30",
"runner": "local",
"platform": "macos",
"command": "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",
"result": "passed",
"durationSeconds": 14.0,
"summary": "The isolated headless host passed the same six-stream zero-hidden-renderer-work, bounded-lag, exact authoritative restoration, same-PTY, and continued-I/O oracle."
}
],
"runtimeBudget": {
@@ -3328,25 +3369,25 @@
},
"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,
"evidence": "Capable paired hosts use ordinary parking: the host PTY and bounded 5,000-row provider model remain, while five hidden client xterms and their raw per-PTY multiplex streams are destroyed. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream."
"evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Parked side effects share the singleton runtime client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy. Stream recovery adds no polling and is scoped to one stream."
},
"promotionCriteria": [
"Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.",
"Run the paired topology on Windows and Linux and add one live SSH retention run.",
"Run the stream-stall oracle with a v1.4.160-rc.3 host and v1.4.160-rc.4 client.",
"Add a production-scale paired run with dozens of worktrees and explicit event-loop and renderer-memory budgets.",
"Keep the budget-off control, exact manager count, row-4,000 reveal marker, parked-time output, and post-reveal PTY input in both topology runs."
"Keep the budget-off control, exact manager count, final bounded-tail flood marker, parked-time output, and post-reveal PTY input in both topology runs."
],
"knownGaps": [
"Paired reveal restores at most the requested 5,000 rows; older history is intentionally unavailable.",
"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."
},
@@ -6430,18 +6471,19 @@
"platforms": ["macos", "linux", "windows", "mobile"],
"providers": ["remote-runtime", "ssh", "local", "daemon"],
"coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence covers runtime-RPC stream budgets plus paired-renderer parse/discard credit. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, and round-robin fairness. The opt-in benchmark covers 1/20/100 ms RTT and 1/4/8 viewers, exact protocol-frame allocations, scheduler CPU, and measured @xterm/headless parser CPU/retained heap. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required.",
"coveredProviders": ["remote-runtime"],
"coverageNotes": "Local macOS evidence covers runtime-RPC stream budgets, paired-renderer parse/discard credit, and negotiated host-side suppression for hidden paired desktop panes. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, mixed-version pause negotiation, and round-robin fairness. The opt-in benchmark covers 1/20/100 ms RTT and 1/4/8 viewers, exact protocol-frame allocations, scheduler CPU, and measured @xterm/headless parser CPU/retained heap. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
"https://github.com/stablyai/orca/pull/7009"
],
"invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, batches, and aggregate in-flight credit. ACK means the renderer parsed the bytes or intentionally discarded them; receipt-time ACK is forbidden. Every replay, stale-generation, malformed-frame, hidden-pane, eviction, disposal, error, and reconnect path must settle credit exactly once so streams neither leak memory nor stall. A terminal covered by native chat must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, fairness, and fallback parity or explicit fallback deprecation.",
"oracle": "Assert mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, live output queued while snapshots load stays <=256KB per stream, large output splits into <=48KB frames, and output bursts coalesce. Feed paired output through the xterm parse callback and prove ACK is deferred until parse or intentional discard, then inject stale generation, malformed/transformed frames, replay failure, queue eviction, hidden panes, pane disposal, ACK send failure, recovery serialization failure, and reconnect races; assert ordered replay and exactly-once credit settlement. Fill the aggregate window across bulk and interactive streams, ACK once, and prove round-robin progress. Run the opt-in 64 MiB/viewer RTT matrix and enforce bounded 8 MiB aggregate in-flight memory, >7 MiB/s/viewer at 100 ms RTT, and <200 ms completion spread. JSON fallback parity and live Android scrollback restoration remain explicit gaps.",
"invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, batches, and aggregate in-flight credit. ACK means the renderer parsed the bytes or intentionally discarded them; receipt-time ACK is forbidden. A capability-negotiated hidden paired desktop stream must deliver zero raw output frames after host model ingestion, preserve side-effect facts, and restore from one authoritative snapshot before exact live output resumes. Every replay, stale-generation, malformed-frame, hidden-pane, eviction, disposal, error, and reconnect path must settle credit exactly once so streams neither leak memory nor stall. A terminal covered by native chat must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, fairness, and safe mixed-version fallback.",
"oracle": "Assert mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, live output queued while snapshots load stays <=256KB per stream, large output splits into <=48KB frames, and output bursts coalesce. Pause three negotiated paired desktop streams, sustain output, and assert zero renderer frames; reveal one and assert one authoritative snapshot followed by exact live bytes with no loss or duplication. Prove old clients continue receiving output and new clients never send pause to old hosts. Feed paired output through the xterm parse callback and prove ACK is deferred until parse or intentional discard, then inject stale generation, malformed/transformed frames, replay failure, queue eviction, hidden panes, pane disposal, ACK send failure, recovery serialization failure, and reconnect races; assert ordered replay and exactly-once credit settlement. Fill the aggregate window across bulk and interactive streams, ACK once, and prove round-robin progress. Run the opt-in 64 MiB/viewer RTT matrix and enforce bounded 8 MiB aggregate in-flight memory, >7 MiB/s/viewer at 100 ms RTT, and <200 ms completion spread. JSON fallback parity and live Android scrollback restoration remain explicit gaps.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.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-pty-ack-gate.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1",
"ORCA_TERMINAL_PERF_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts --disableConsoleIntercept src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
@@ -6452,6 +6494,7 @@
"src/main/runtime/rpc/terminal-multiplex.test.ts",
"src/main/runtime/rpc/terminal-multiplex-flow-control.bench.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-pty-ack-gate.test.ts",
"src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts",
"src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
@@ -6483,7 +6526,24 @@
"multibyte live output flushes when encoded bytes reach the batch budget",
"adaptive credit grows only after ACK, stays globally bounded, and drains pending streams round-robin",
"send and recovery serialization failures detach once instead of leaking credit or retrying forever",
"32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter"
"32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter",
"three negotiated hidden desktop streams emit zero output frames under sustained load while an unpaused stream remains live",
"an older client that omits pause negotiation continues receiving output"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"a runtime-owned hidden pane pauses output, consumes status/title/theme facts, and keeps input writable",
"reveal resumes before one authoritative snapshot and exact live output with no hidden raw write, loss, or duplication",
"dispose releases pause and unregisters the fact consumer"
]
},
{
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"assertions": [
"desired pause reapplies after either capability/snapshot ordering and across reconnect",
"reconnect delivers each authoritative snapshot and post-resume live marker exactly once"
]
},
{
@@ -6501,6 +6561,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": [
@@ -6552,6 +6618,15 @@
"result": "passed",
"durationSeconds": 0.91,
"summary": "The 1/20/100 ms RTT x 1/4/8 viewer matrix stayed at or below 8 MiB in flight with zero completion spread. At 100 ms it modeled 18.8 MiB/s per viewer for 1-4 viewers and 9.7 MiB/s for 8 viewers. Measured @xterm/headless parsing was 26.7/63.6/95.3 aggregate MiB/s for 1/4/8 viewers, with 84.4/236.5/336.0 ms CPU and 2893/13409/28991 KiB retained heap for 4 MiB per viewer."
},
{
"date": "2026-07-30",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-multiplex.test.ts --maxWorkers=1",
"result": "passed",
"durationSeconds": 9.05,
"summary": "All 58 terminal multiplex tests passed, including the three-stream hidden-output oracle. Its byte-identical title-selected command failed on current origin/main and failed again with the two causal host/protocol files reverted because hidden Output frames reached the renderer."
}
],
"runtimeBudget": {
@@ -6564,11 +6639,11 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Tests cover mobile initial snapshot byte downgrade, requested binary snapshot byte downgrade, pending live-output cap while snapshot loads, output chunk size, output coalescing, abort cleanup, and stale resize re-stream suppression. Needs intentional-break proof plus JSON fallback coverage before promotion."
"evidence": "The hidden paired-output oracle is red on origin/main, green on the candidate, and red with its causal host/protocol files reverted. It proves three paused streams emit no raw frames while an unpaused control remains live; compatibility tests preserve old-client/new-host output and prevent new-client/old-host pause opcodes. The broader gate remains partial pending legacy JSON fallback and live Android coverage."
},
"performanceBudget": {
"required": true,
"evidence": "Parsed/discarded credit uses 192 KiB/4 ms ACK batching, 512 KiB-to-2 MiB adaptive per-stream windows, a 2 MiB-to-8 MiB aggregate window, <=48 KiB output frames, <=256 KiB queued output per stream, and <=32 streams per connection (8 MiB aggregate pending output). The 64 MiB/viewer model gate requires >7 MiB/s/viewer at 100 ms RTT, <200 ms completion spread, and aggregate in-flight bytes <=8 MiB. The 2026-07-22 run modeled 9.7 MiB/s/viewer at 100 ms with eight viewers and measured @xterm/headless at 95.3 aggregate MiB/s, 336.0 ms parser CPU, and 28991 KiB retained heap for eight 4 MiB viewers."
"evidence": "Parsed/discarded credit uses 192 KiB/4 ms ACK batching, 512 KiB-to-2 MiB adaptive per-stream windows, a 2 MiB-to-8 MiB aggregate window, <=48 KiB output frames, <=256 KiB queued output per stream, and <=32 streams per connection (8 MiB aggregate pending output). Negotiated hidden paired streams add zero renderer output frames or scheduler drains after host ingestion; headed and headless six-stream runs enforce under 500 ms timer lag. The 64 MiB/viewer model gate requires >7 MiB/s/viewer at 100 ms RTT, <200 ms completion spread, and aggregate in-flight bytes <=8 MiB."
},
"promotionCriteria": [
"Gate binary multiplex first.",
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
expect(triggerPaths).toEqual(
expect.arrayContaining([
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
'config/scripts/computer-use-skill-guidance.test.mjs',
'config/scripts/computer-use-smoke.mjs',
@@ -70,9 +71,14 @@ describe('computer-use e2e workflow', () => {
const nativeSmokeRuns = workflow.jobs['native-smoke'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const checkout = workflow.jobs['native-smoke'].steps.find(
(step) => step.uses === 'actions/checkout@v6'
)
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
'config/scripts/computer-use-skill-guidance.test.mjs',
'config/scripts/computer-use-smoke.test.mjs',
@@ -106,12 +112,68 @@ describe('computer-use e2e workflow', () => {
'src/shared/remote-runtime-client.test.ts'
]
expect(checkout.with['persist-credentials']).toBe(false)
expect(regressionRun).toBeTruthy()
for (const file of expectedRegressionFiles) {
expect(regressionRun).toContain(file)
}
})
it('builds and tests the macOS helper on pull requests without TCC e2e', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const job = workflow.jobs['mac-native-owner-smoke']
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
expect(job.if).toBe("github.event_name == 'pull_request'")
expect(job['runs-on']).toBe('macos-15')
expect(checkout.with['persist-credentials']).toBe(false)
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
const cleanupRun = runs.find((run) =>
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
)
expect(cleanupRun).toContain(
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs'
)
expect(runs).toContain('pnpm verify:computer-native')
expect(runs.join('\n')).not.toContain('test:e2e:computer')
expect(workflow.on.pull_request.paths).toEqual(
expect.arrayContaining([
'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-metrics.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'
])
)
})
it('runs deterministic macOS owner-loss benchmark cleanup coverage', () => {
const benchmark = readFileSync(
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'),
'utf8'
)
const cleanup = readFileSync(
join(projectDir, 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'),
'utf8'
)
expect(benchmark).toContain('spawnBenchmarkProcess(executable, [launcherDir]')
expect(benchmark).toContain("stdio: ['ignore', stdoutDescriptor, stderrDescriptor]")
expect(benchmark).toContain('cleanupOwnerLossTrial({')
const parseIndex = benchmark.indexOf('parseBenchmarkTrialResult(serializedResult)')
const cleanupIndex = benchmark.indexOf('cleanupOwnerLossTrial({')
expect(parseIndex).toBeGreaterThanOrEqual(0)
expect(cleanupIndex).toBeGreaterThanOrEqual(0)
expect(parseIndex).toBeLessThan(cleanupIndex)
expect(benchmark).toContain('trialCleanupSha256: artifactSha256(trialCleanupPath)')
expect(cleanup).toContain('killRecordedAndMatchingProcesses(options.recordPath')
expect(cleanup).toContain("signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL'")
})
it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
+115 -6
View File
@@ -19,6 +19,39 @@ const {
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
expect(electronBuilderConfig.appId).toBe(
@@ -154,7 +187,12 @@ describe('electron-builder config', () => {
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
expect.arrayContaining([
'out/package.json',
'out/cli/**',
'out/shared/**',
'out/main/claude-accounts/keychain.js'
])
)
})
@@ -166,11 +204,9 @@ describe('electron-builder config', () => {
)
})
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
it('unpacks the forked main-thread hang-watchdog entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
it('keeps the worker-thread hang watchdog inside app.asar', () => {
expect(electronBuilderConfig.asarUnpack).not.toContain(
'out/main/main-thread-hang-watchdog-entry.js'
)
})
@@ -264,6 +300,52 @@ describe('electron-builder config', () => {
}
})
// Why: Squirrel.Mac swaps the .app in place only when the replacement carries the
// same bundle id and a valid Developer ID signature. A hourly built on the local
// (com.stablyai.orca.local, ad-hoc) identity would be un-installable over a real
// Orca — the whole point of the channel.
it('builds hourly artifacts with the release signing identity', () => {
withHourlyEnv((config) => {
expect(config.mac.appId).toBeUndefined()
expect(config.appId).toBe('com.stablyai.orca')
expect(config.mac.hardenedRuntime).toBe(true)
expect(config.forceCodeSigning).toBe(true)
})
})
// Why: notarization is the one release step hourly skips; in-place updates never
// check it, and 24 notary round trips a day is the cost being avoided.
it('skips notarization only for hourly builds', () => {
withHourlyEnv((config) => {
expect(config.mac.notarize).toBe(false)
})
withEnv({ ORCA_MAC_RELEASE: '1' }, (config) => {
expect(config.mac.notarize).toBe(true)
})
})
// Why: the main repo's releases atom feed exposes only its 10 newest entries.
// Publishing 24 hourly tags a day there would evict every stable/RC entry and
// break update checks for every real user.
it('publishes hourly builds to the separate hourly repo', () => {
withHourlyEnv((config) => {
expect(config.publish).toMatchObject({ repo: 'orca-hourly', releaseType: 'prerelease' })
})
expect(electronBuilderConfig.publish).toMatchObject({
repo: 'orca',
releaseType: 'release'
})
})
it('stamps hourly packages with the hourly version', () => {
withEnv(
{ ORCA_MAC_HOURLY: '1', ORCA_HOURLY_BUILD_VERSION: '1.4.160-hourly.202607281400' },
(config) => {
expect(config.extraMetadata).toEqual({ version: '1.4.160-hourly.202607281400' })
}
)
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)
@@ -477,6 +559,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 () => {
@@ -500,6 +596,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({
@@ -1,6 +1,9 @@
import { readFileSync } from 'node:fs'
import { EventEmitter } from 'node:events'
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { electronViteConfig } from '../../electron.vite.config'
import { createBootstrapFatalExitBanner, electronViteConfig } from '../../electron.vite.config'
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
@@ -29,6 +32,35 @@ describe('Electron Vite output contract', () => {
expect(external('node:fs', undefined, false)).toBe(true)
expect(external('@xterm/headless', undefined, false)).toBe(false)
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
expect(external('zod', undefined, false)).toBe(false)
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
})
it('exits when a static import fails before source error guards load', () => {
const processMock = new EventEmitter() as EventEmitter & {
exit: (code: number) => void
exitCode?: number
}
let scheduledExit: (() => void) | null = null
let exitedWith: number | null = null
processMock.exit = (code) => {
exitedWith = code
}
const context = {
process: processMock,
setImmediate: (callback: () => void) => {
scheduledExit = callback
}
}
runInNewContext(createBootstrapFatalExitBanner(), context)
processMock.emit('uncaughtException', new Error("Cannot find module 'zod'"))
expect(processMock.exitCode).toBe(1)
expect(scheduledExit).not.toBeNull()
scheduledExit?.()
expect(exitedWith).toBe(1)
expect(context).toHaveProperty(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)
})
it('isolates renderer entry side effects behind strict facades', () => {
@@ -124,6 +124,18 @@ function gitTreeSha(entries) {
return hashDirectory(root).toString('hex')
}
// Why: kept in step with isOsMetadataSkillEntryName in src/main/skills/skill-package-identity.ts.
// The scanner ignores these because the OS writes them into a live install; the generator
// ignores them so a stray one in a working tree cannot be committed into the manifest as
// content no user could ever match. Skipped rather than rejected: the file is not the
// developer's doing, so failing the build over it would be hostile.
const OS_METADATA_FILE_NAMES = new Set(['.ds_store', 'thumbs.db', 'ehthumbs.db', 'desktop.ini'])
function isOsMetadataSkillEntryName(name) {
const folded = name.toLocaleLowerCase('en-US')
return OS_METADATA_FILE_NAMES.has(folded) || folded.startsWith('._')
}
async function collectPackageFiles(packageRoot) {
const files = []
const caseFoldedPaths = new Map()
@@ -135,6 +147,14 @@ async function collectPackageFiles(packageRoot) {
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name)
const fileStat = await lstat(absolutePath)
// Only a plain file is OS-authored, so the type decides and not the name alone: a
// directory or link wearing the name would otherwise drop its subtree out of the
// manifest and skip the guards below. Decided before the case-fold map so two
// spellings of one sidecar cannot collide.
if (isOsMetadataSkillEntryName(entry.name) && fileStat.isFile()) {
continue
}
const relativePath = path.relative(packageRoot, absolutePath)
assertSafeRelativePath(relativePath)
const manifestPath = relativePath.split(path.sep).join('/')
@@ -144,7 +164,6 @@ async function collectPackageFiles(packageRoot) {
throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const fileStat = await lstat(absolutePath)
if (fileStat.isSymbolicLink()) {
throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`)
}
@@ -14,6 +14,7 @@ import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { observeSkillPackage } from '../../src/main/skills/skill-package-identity'
import {
appendReleaseRow,
assertReleasedHistoryPreserved,
@@ -436,6 +437,90 @@ describe('skill bundle manifest generator', () => {
)
})
it('ignores OS-authored sidecars a working tree may carry', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n')
await mkdir(path.join(packageRoot, 'references'))
await writeFile(path.join(packageRoot, 'references', 'guide.md'), 'nested\n')
const pristine = await collectPackageFiles(packageRoot)
expect(pristine.map((file) => file.path)).toEqual(['SKILL.md', 'references/guide.md'])
// Finder writes .DS_Store into any browsed folder, and it is gitignored — so without
// this the committed artifacts read as stale and lint fails for that developer, while
// the scanner would have no snapshot a real install could match.
await writeFile(path.join(packageRoot, '.DS_Store'), Buffer.from([0, 1, 2, 3]))
await writeFile(path.join(packageRoot, '._SKILL.md'), Buffer.from([0, 5]))
await writeFile(path.join(packageRoot, 'Thumbs.db'), Buffer.from([9]))
// Nested folders get browsed too, and a sidecar there shifts the same index-aligned list.
await writeFile(path.join(packageRoot, 'references', '.DS_Store'), Buffer.from([7]))
expect(await collectPackageFiles(packageRoot)).toEqual(pristine)
})
it('still records an unexpected file that is not OS metadata', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n')
await writeFile(path.join(packageRoot, 'payload.sh'), 'echo hi\n')
expect((await collectPackageFiles(packageRoot)).map((file) => file.path)).toEqual([
'SKILL.md',
'payload.sh'
])
})
it('keeps guarding a directory or link that only wears an OS metadata name', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n')
// Only plain files are OS-authored, so a subtree behind one of these names is real
// content that must stay in the manifest instead of shipping unrecorded.
await mkdir(path.join(packageRoot, '.DS_Store'))
await writeFile(path.join(packageRoot, '.DS_Store', 'payload.sh'), 'echo hi\n')
expect((await collectPackageFiles(packageRoot)).map((file) => file.path)).toEqual([
'.DS_Store/payload.sh',
'SKILL.md'
])
if (process.platform !== 'win32') {
await rm(path.join(packageRoot, '.DS_Store'), { recursive: true })
await symlink('SKILL.md', path.join(packageRoot, '._SKILL.md'))
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
'Symlink is not allowed in a shipped skill'
)
}
})
// Why: the predicate is hand-copied from the scanner, and an asymmetric skip is worse than
// no skip — one side would bake in content the other can never observe, leaving every
// install permanently unrecognized. Compared through both walkers so ordering and the
// case-fold map are covered too, not just the name test.
it('skips exactly the names the scanner skips', async () => {
const packageRoot = await createPackage()
for (const name of [
'SKILL.md',
'.DS_Store',
'.ds_store',
'.DS_STORE',
'Thumbs.db',
'THUMBS.DB',
'ehthumbs.db',
'desktop.ini',
'Desktop.INI',
'._SKILL.md',
'._',
// Near misses that both sides must keep.
'.dsstore',
'ds_store.md',
'_SKILL.md',
'.DS_Store.md'
]) {
await writeFile(path.join(packageRoot, name), `${name}\n`)
}
const generated = (await collectPackageFiles(packageRoot)).map((file) => file.path)
expect(generated).toEqual((await observeSkillPackage(packageRoot)).files.map((f) => f.path))
expect(generated).toEqual(['.DS_Store.md', '.dsstore', 'SKILL.md', '_SKILL.md', 'ds_store.md'])
})
it('computes the same Git tree identity as Git', async () => {
const packageRoot = path.resolve('skills', 'orca-cli')
const files = await collectPackageFiles(packageRoot)
@@ -0,0 +1,443 @@
#!/usr/bin/env node
import { execFileSync, fork, spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { Worker } from 'node:worker_threads'
import {
childRssBytes,
median,
physicalFootprintBytes,
sampleMemory,
sampleProductionPerformance
} from './hang-watchdog-process-metrics.mjs'
const INTERNAL_ENV = 'ORCA_HANG_WATCHDOG_BENCH_INTERNAL'
const BOUNDARY_ENV = 'ORCA_HANG_WATCHDOG_BENCH_BOUNDARY'
const RESULT_PREFIX = 'ORCA_HANG_WATCHDOG_BENCH_RESULT='
const DEFAULT_TRIALS = 7
const SETTLE_MS = 2_000
const SAMPLE_COUNT = 5
const SAMPLE_INTERVAL_MS = 200
const VERIFY_TIMEOUT_MS = 500
const VERIFY_CHECK_INTERVAL_MS = 50
const VERIFY_BLOCK_MS = 1_200
const PRODUCTION_HEARTBEAT_INTERVAL_MS = 2_000
const PRODUCTION_TIMEOUT_MS = 45_000
const PRODUCTION_CHECK_INTERVAL_MS = 5_000
const PRODUCTION_SAMPLE_MS = 30_000
const MAX_LAUNCH_ATTEMPTS = 3
const MIB = 1024 * 1024
const scriptPath = import.meta.filename
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const entryPath = path.join(repoRoot, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function forceGc() {
if (typeof global.gc !== 'function') {
throw new Error('Electron did not expose GC; keep --js-flags=--expose-gc in the harness')
}
global.gc()
global.gc()
}
function blockMainThread(ms) {
const startedAt = Date.now()
while (Date.now() - startedAt < ms) {
// Intentional synchronous stall.
}
return { startedAt, endedAt: Date.now() }
}
function readMarker(markerPath) {
try {
return JSON.parse(readFileSync(markerPath, 'utf8'))
} catch {
return null
}
}
async function verifyBlockedMainDetection(markerPath, sendHeartbeat) {
const block = blockMainThread(VERIFY_BLOCK_MS)
const detected = readMarker(markerPath)
sendHeartbeat()
const deadline = Date.now() + VERIFY_TIMEOUT_MS
let resolved
do {
resolved = readMarker(markerPath)
if (resolved?.selfRecovered === true) {
break
}
await sleep(VERIFY_CHECK_INTERVAL_MS)
} while (Date.now() < deadline)
const verified =
detected?.detectedAt >= block.startedAt &&
detected.detectedAt <= block.endedAt &&
detected.selfRecovered === false &&
resolved?.selfRecovered === true
if (!verified) {
throw new Error(
`Built watchdog failed blocked-main verification: ${JSON.stringify({ detected, resolved })}`
)
}
return true
}
function startChild(markerPath, timeoutMs, checkIntervalMs) {
const startedAt = process.hrtime.bigint()
const child = fork(entryPath, [], {
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid),
ORCA_HANG_WATCHDOG_MARKER_PATH: markerPath,
ORCA_HANG_WATCHDOG_TIMEOUT_MS: String(timeoutMs),
ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS: String(checkIntervalMs)
}
})
const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6
return {
pids: [process.pid, child.pid],
startupMs,
sendHeartbeat: () => child.send?.({ type: 'heartbeat' }),
shutdown: async () => {
if (child.exitCode !== null) {
return
}
const exitPromise = new Promise((resolve) => child.once('exit', resolve))
child.send?.({ type: 'shutdown' })
if (child.connected) {
child.disconnect()
}
await exitPromise
}
}
}
function startWorker(markerPath, timeoutMs, checkIntervalMs) {
const startedAt = process.hrtime.bigint()
const worker = new Worker(entryPath, {
workerData: {
parentPid: process.pid,
markerPath,
timeoutMs,
checkIntervalMs
}
})
const startupMs = Number(process.hrtime.bigint() - startedAt) / 1e6
return {
pids: [process.pid],
startupMs,
sendHeartbeat: () => worker.postMessage({ type: 'heartbeat' }),
shutdown: async () => {
if (worker.threadId === -1) {
return
}
const exitPromise = new Promise((resolve) => worker.once('exit', resolve))
worker.postMessage({ type: 'shutdown' })
await exitPromise
}
}
}
async function verifyBoundary(markerPath, startBoundary) {
const boundary = startBoundary(markerPath, VERIFY_TIMEOUT_MS, VERIFY_CHECK_INTERVAL_MS)
const heartbeat = setInterval(boundary.sendHeartbeat, 100)
try {
await sleep(SETTLE_MS)
return await verifyBlockedMainDetection(markerPath, boundary.sendHeartbeat)
} finally {
clearInterval(heartbeat)
await boundary.shutdown()
}
}
async function measureChild(markerPath) {
forceGc()
await sleep(SETTLE_MS)
forceGc()
const before = await sampleMemory(
() => process.memoryUsage().rss,
() => physicalFootprintBytes([process.pid]),
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
)
const child = startChild(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS)
let measurements
try {
await sleep(SETTLE_MS)
forceGc()
const childRss = await sampleMemory(
() => childRssBytes(child.pids[1]),
() => physicalFootprintBytes([child.pids[1]]),
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
)
const total = await sampleMemory(
() => process.memoryUsage().rss + childRssBytes(child.pids[1]),
() => physicalFootprintBytes(child.pids),
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
)
const performance = await sampleProductionPerformance(child, {
heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
sampleMs: PRODUCTION_SAMPLE_MS,
sleep
})
measurements = {
rssBytes: childRss.rssBytes,
summedProcessRssDeltaBytes: Math.max(0, total.rssBytes - before.rssBytes),
physicalFootprintDeltaBytes: Math.max(
0,
total.physicalFootprintBytes - before.physicalFootprintBytes
),
startupMs: child.startupMs,
...performance
}
} finally {
await child.shutdown()
}
rmSync(markerPath, { force: true })
return {
...measurements,
blockedMainThreadVerified: await verifyBoundary(markerPath, startChild)
}
}
async function measureWorker(markerPath) {
forceGc()
await sleep(SETTLE_MS)
forceGc()
const before = await sampleMemory(
() => process.memoryUsage().rss,
() => physicalFootprintBytes([process.pid]),
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
)
const worker = startWorker(markerPath, PRODUCTION_TIMEOUT_MS, PRODUCTION_CHECK_INTERVAL_MS)
let measurements
try {
await sleep(SETTLE_MS)
forceGc()
const after = await sampleMemory(
() => process.memoryUsage().rss,
() => physicalFootprintBytes([process.pid]),
{ sampleCount: SAMPLE_COUNT, sampleIntervalMs: SAMPLE_INTERVAL_MS, sleep }
)
const performance = await sampleProductionPerformance(worker, {
heartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
sampleMs: PRODUCTION_SAMPLE_MS,
sleep
})
const rssBytes = Math.max(0, after.rssBytes - before.rssBytes)
measurements = {
rssBytes,
summedProcessRssDeltaBytes: rssBytes,
physicalFootprintDeltaBytes: Math.max(
0,
after.physicalFootprintBytes - before.physicalFootprintBytes
),
startupMs: worker.startupMs,
...performance
}
} finally {
await worker.shutdown()
}
rmSync(markerPath, { force: true })
return {
...measurements,
blockedMainThreadVerified: await verifyBoundary(markerPath, startWorker)
}
}
async function runInternal() {
if (process.platform !== 'darwin') {
throw new Error('The production watchdog is macOS-only; run this benchmark on macOS')
}
const { app } = await import('electron')
const boundary = process.env[BOUNDARY_ENV]
const profileDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-'))
app.setPath('userData', profileDir)
try {
await app.whenReady()
const markerPath = path.join(profileDir, 'main-thread-hang.json')
const result =
boundary === 'child'
? await measureChild(markerPath)
: boundary === 'worker'
? await measureWorker(markerPath)
: (() => {
throw new Error(`Unsupported boundary: ${boundary}`)
})()
process.stdout.write(`${RESULT_PREFIX}${JSON.stringify(result)}\n`)
} finally {
app.quit()
rmSync(profileDir, { recursive: true, force: true })
}
}
function parseArgs(argv) {
const options = { boundary: '', trials: DEFAULT_TRIALS, output: '' }
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = argv[index + 1]
if (arg === '--boundary' || arg === '--trials' || arg === '--output') {
if (!value) {
throw new Error(`Missing value for ${arg}`)
}
options[arg.slice(2)] = arg === '--trials' ? Number(value) : value
index += 1
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
if (!['child', 'worker'].includes(options.boundary)) {
throw new Error('--boundary must be child or worker')
}
if (!Number.isInteger(options.trials) || options.trials < 1) {
throw new Error('--trials must be a positive integer')
}
return options
}
function electronPath() {
const requirePath = import.meta.resolve('electron')
const electronModulePath = fileURLToPath(requirePath)
return execFileSync(
process.execPath,
['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`],
{
encoding: 'utf8'
}
)
}
function runTrial(executable, boundary) {
for (let attempt = 1; attempt <= MAX_LAUNCH_ATTEMPTS; attempt += 1) {
const env = { ...process.env, [INTERNAL_ENV]: '1', [BOUNDARY_ENV]: boundary }
delete env.ELECTRON_RUN_AS_NODE
const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-watchdog-bench-launcher-'))
writeFileSync(
path.join(launcherDir, 'package.json'),
JSON.stringify({ name: 'orca-watchdog-benchmark', main: 'main.cjs' })
)
writeFileSync(
path.join(launcherDir, 'main.cjs'),
`import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => {
console.error(error)
process.exitCode = 1
})\n`
)
let result
try {
result = spawnSync(executable, ['--js-flags=--expose-gc', launcherDir], {
cwd: repoRoot,
env,
encoding: 'utf8',
timeout: 90_000
})
} finally {
rmSync(launcherDir, { recursive: true, force: true })
}
if (result.status !== 0) {
throw new Error(
`Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n` +
`${result.stderr || result.stdout}`
)
}
const line = result.stdout.split('\n').find((candidate) => candidate.startsWith(RESULT_PREFIX))
if (line) {
return { ...JSON.parse(line.slice(RESULT_PREFIX.length)), launchAttempts: attempt }
}
if (attempt === MAX_LAUNCH_ATTEMPTS || result.stderr || result.stdout) {
throw new Error(`Electron trial did not report a result (status ${result.status})`)
}
}
throw new Error('Electron trial exhausted launcher attempts')
}
function runBenchmark() {
if (process.platform !== 'darwin') {
throw new Error('The production watchdog is macOS-only; run this benchmark on macOS')
}
if (!existsSync(entryPath)) {
throw new Error(`Missing ${entryPath}; run pnpm exec electron-vite build first`)
}
const options = parseArgs(process.argv.slice(2))
const builtEntry = readFileSync(entryPath, 'utf8')
const hasChildContract = builtEntry.includes('ORCA_HANG_WATCHDOG_PARENT_PID')
const hasWorkerContract = builtEntry.includes('workerData') && builtEntry.includes('parentPort')
if (
(options.boundary === 'child' && !hasChildContract) ||
(options.boundary === 'worker' && !hasWorkerContract)
) {
throw new Error(
`Built watchdog does not implement the requested ${options.boundary} boundary; rebuild the matching revision`
)
}
const executable = electronPath()
const results = Array.from({ length: options.trials }, () =>
runTrial(executable, options.boundary)
)
const rssBytes = results.map((result) => result.rssBytes)
const summedProcessRssDeltaBytes = results.map((result) => result.summedProcessRssDeltaBytes)
const physicalFootprintDeltaBytes = results.map((result) => result.physicalFootprintDeltaBytes)
const startupMs = results.map((result) => result.startupMs)
const cpuMs = results.map((result) => result.cpuMs)
const eventLoopDelayP95Ms = results.map((result) => result.eventLoopDelayP95Ms)
const eventLoopDelayP99Ms = results.map((result) => result.eventLoopDelayP99Ms)
const eventLoopDelayMaxMs = results.map((result) => result.eventLoopDelayMaxMs)
const report = {
benchmark: 'hang-watchdog-memory',
boundary: options.boundary,
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: repoRoot,
encoding: 'utf8'
}).trim(),
electron: execFileSync(executable, ['-e', 'process.stdout.write(process.versions.electron)'], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
encoding: 'utf8'
}).trim(),
settleMs: SETTLE_MS,
samplesPerTrial: SAMPLE_COUNT,
productionHeartbeatIntervalMs: PRODUCTION_HEARTBEAT_INTERVAL_MS,
productionCheckIntervalMs: PRODUCTION_CHECK_INTERVAL_MS,
productionSampleMs: PRODUCTION_SAMPLE_MS,
trials: options.trials,
rssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))),
medianRssMiB: Number((median(rssBytes) / MIB).toFixed(2)),
summedProcessRssDeltaMiB: summedProcessRssDeltaBytes.map((value) =>
Number((value / MIB).toFixed(2))
),
medianSummedProcessRssDeltaMiB: Number((median(summedProcessRssDeltaBytes) / MIB).toFixed(2)),
physicalFootprintDeltaMiB: physicalFootprintDeltaBytes.map((value) =>
Number((value / MIB).toFixed(2))
),
medianPhysicalFootprintDeltaMiB: Number((median(physicalFootprintDeltaBytes) / MIB).toFixed(2)),
startupMs: startupMs.map((value) => Number(value.toFixed(3))),
medianStartupMs: Number(median(startupMs).toFixed(3)),
cpuMs: cpuMs.map((value) => Number(value.toFixed(2))),
medianCpuMs: Number(median(cpuMs).toFixed(2)),
eventLoopDelayP95Ms: eventLoopDelayP95Ms.map((value) => Number(value.toFixed(3))),
medianEventLoopDelayP95Ms: Number(median(eventLoopDelayP95Ms).toFixed(3)),
eventLoopDelayP99Ms: eventLoopDelayP99Ms.map((value) => Number(value.toFixed(3))),
medianEventLoopDelayP99Ms: Number(median(eventLoopDelayP99Ms).toFixed(3)),
eventLoopDelayMaxMs: eventLoopDelayMaxMs.map((value) => Number(value.toFixed(3))),
medianEventLoopDelayMaxMs: Number(median(eventLoopDelayMaxMs).toFixed(3)),
heartbeatCounts: results.map((result) => result.heartbeatCount),
launchAttempts: results.map((result) => result.launchAttempts),
blockedMainThreadVerified: results.every((result) => result.blockedMainThreadVerified)
}
const serialized = `${JSON.stringify(report, null, 2)}\n`
process.stdout.write(serialized)
if (options.output) {
writeFileSync(path.resolve(options.output), serialized)
}
}
if (process.env[INTERNAL_ENV] === '1') {
await runInternal()
} else {
runBenchmark()
}
@@ -0,0 +1,108 @@
import { execFileSync } from 'node:child_process'
import { monitorEventLoopDelay } from 'node:perf_hooks'
export function median(values) {
const sorted = [...values].sort((left, right) => left - right)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
export async function sampleMemory(readRss, readPhysicalFootprint, options) {
const rssSamples = []
const physicalFootprintSamples = []
for (let index = 0; index < options.sampleCount; index += 1) {
rssSamples.push(readRss())
physicalFootprintSamples.push(readPhysicalFootprint())
await options.sleep(options.sampleIntervalMs)
}
return {
rssBytes: median(rssSamples),
physicalFootprintBytes: median(physicalFootprintSamples)
}
}
export function childRssBytes(pid) {
const raw = execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], {
encoding: 'utf8'
}).trim()
const rssKiB = Number(raw)
if (!Number.isFinite(rssKiB) || rssKiB <= 0) {
throw new Error(`Could not read watchdog child RSS for PID ${pid}`)
}
return rssKiB * 1024
}
export function parsePhysicalFootprintBytes(output, processCount) {
const match =
processCount > 1
? output.match(/^Summary Footprint:\s+(\d+) B$/m)
: output.match(/^[^\s].*\sFootprint:\s+(\d+) B/m)
const bytes = Number(match?.[1])
return Number.isFinite(bytes) && bytes > 0 ? bytes : null
}
export function physicalFootprintBytes(pids) {
const pidArgs = pids.flatMap((pid) => ['--pid', String(pid)])
const output = execFileSync(
'/usr/bin/footprint',
[...pidArgs, '--format', 'bytes', '--noCategories'],
{ encoding: 'utf8' }
)
const bytes = parsePhysicalFootprintBytes(output, pids.length)
if (bytes === null) {
throw new Error(`Could not read physical footprint for PIDs ${pids.join(', ')}`)
}
return bytes
}
export function parseProcessCpuTimeMs(raw) {
if (!raw.trim()) {
return null
}
const parts = raw.split(':').map(Number)
if (!parts.length || parts.some((part) => !Number.isFinite(part))) {
return null
}
const seconds = parts.reduce((total, part) => total * 60 + part, 0)
const milliseconds = seconds * 1_000
return milliseconds >= 0 ? milliseconds : null
}
function processCpuTimeMs(pid) {
const raw = execFileSync('ps', ['-o', 'time=', '-p', String(pid)], {
encoding: 'utf8'
}).trim()
const milliseconds = parseProcessCpuTimeMs(raw)
if (milliseconds === null) {
throw new Error(`Could not read CPU time for PID ${pid}`)
}
return milliseconds
}
function combinedCpuTimeMs(pids) {
return pids.reduce((total, pid) => total + processCpuTimeMs(pid), 0)
}
export async function sampleProductionPerformance(boundary, options) {
const loopDelay = monitorEventLoopDelay({ resolution: 10 })
let heartbeatCount = 0
const heartbeat = setInterval(() => {
heartbeatCount += 1
boundary.sendHeartbeat()
}, options.heartbeatIntervalMs)
const cpuBefore = combinedCpuTimeMs(boundary.pids)
loopDelay.enable()
try {
await options.sleep(options.sampleMs)
} finally {
loopDelay.disable()
clearInterval(heartbeat)
}
return {
cpuMs: Math.max(0, combinedCpuTimeMs(boundary.pids) - cpuBefore),
heartbeatCount,
eventLoopDelayP95Ms: loopDelay.percentile(95) / 1e6,
eventLoopDelayP99Ms: loopDelay.percentile(99) / 1e6,
eventLoopDelayMaxMs: loopDelay.max / 1e6
}
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
parsePhysicalFootprintBytes,
parseProcessCpuTimeMs
} from './hang-watchdog-process-metrics.mjs'
describe('hang watchdog process metrics', () => {
it('uses the de-duplicated summary for multiple processes', () => {
const output = `
Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page)
phys_footprint: 5100000 B
Electron Helper [102]: 64-bit Footprint: 2000000 B (16384 bytes per page)
phys_footprint: 2100000 B
Summary Footprint: 6259264 B
`
expect(parsePhysicalFootprintBytes(output, 2)).toBe(6_259_264)
})
it('uses the process footprint rather than auxiliary accounting for one process', () => {
const output = `
Electron [101]: 64-bit Footprint: 5000000 B (16384 bytes per page)
phys_footprint: 5100000 B
`
expect(parsePhysicalFootprintBytes(output, 1)).toBe(5_000_000)
})
it('rejects missing or zero footprint summaries', () => {
expect(parsePhysicalFootprintBytes('phys_footprint: 100 B', 2)).toBeNull()
expect(parsePhysicalFootprintBytes('Summary Footprint: 0 B', 2)).toBeNull()
})
it.each([
['0:00.04', 40],
['1:02.50', 62_500],
['2:01:02.50', 7_262_500]
])('parses ps CPU time %s', (value, expected) => {
expect(parseProcessCpuTimeMs(value)).toBe(expected)
})
it('rejects invalid CPU times', () => {
expect(parseProcessCpuTimeMs('')).toBeNull()
expect(parseProcessCpuTimeMs('not-a-time')).toBeNull()
expect(parseProcessCpuTimeMs('-1:00')).toBeNull()
})
})
+43
View File
@@ -0,0 +1,43 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
export function createHourlyBuildVersion(baseVersion, date) {
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion)
if (!match) {
throw new Error(`Package version is not valid semver: ${baseVersion}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Hourly build timestamp is invalid.')
}
const pad = (value, width = 2) => String(value).padStart(width, '0')
const stamp = [
pad(date.getUTCFullYear(), 4),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes())
].join('')
// Why: drop any -rc.N tail. Keeping it makes every hourly semver-NEWER than the
// RC it was cut from (1.4.160-rc.3-hourly.X > 1.4.160-rc.3), which would let an
// ordinary RC-channel check offer untested hourly builds to RC users. Stripping
// to the base parks hourlies below both rc.N and stable ('hourly' < 'rc'
// alphabetically), reachable only by an explicit pinned jump.
return `${match[1]}-hourly.${stamp}`
}
export function getHourlyBuildIdentity(now = new Date()) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
return { commit, version: createHourlyBuildVersion(packageJson.version, now) }
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getHourlyBuildIdentity()
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(`version=${identity.version}\ncommit=${identity.commit}\n`)
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { createHourlyBuildVersion } from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createHourlyBuildVersion', () => {
it('stamps the version with a zero-padded UTC timestamp', () => {
expect(createHourlyBuildVersion('1.4.160', new Date('2026-07-28T04:05:00Z'))).toBe(
'1.4.160-hourly.202607280405'
)
})
// Why: main's package.json carries the in-flight RC tail. Keeping it would make
// every hourly semver-NEWER than the RC it was cut from (1.4.160-rc.3-hourly.X >
// 1.4.160-rc.3), so an ordinary RC-channel check would offer untested hourly
// builds to RC users. Dropping it parks hourlies below both rc.N and stable,
// reachable only by an explicit pinned jump.
it('drops an in-flight rc tail so hourlies never outrank the rc series', () => {
const version = createHourlyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z'))
expect(version).toBe('1.4.160-hourly.202607281400')
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
expect(compareAppVersions('1.4.160-rc.3-hourly.202607281400', '1.4.160-rc.3')).toBeGreaterThan(
0
)
})
it('rejects invalid input', () => {
expect(() => createHourlyBuildVersion('nope', new Date())).toThrow(/valid semver/)
expect(() => createHourlyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
@@ -0,0 +1,614 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { execFileSync, fork } from 'node:child_process'
import { existsSync, mkdtempSync, openSync, readFileSync, writeFileSync } from 'node:fs'
import net from 'node:net'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
median,
percentile,
processSnapshot,
sampleProcess
} from './macos-computer-helper-owner-loss-metrics.mjs'
import {
benchmarkTrialNeedsCleanup,
parseBenchmarkTrialResult,
processIdentityIsCurrent,
signalProcessIdentity,
spawnBenchmarkProcess,
throwBenchmarkTrialFailures,
writeProcessRecord
} from './macos-computer-helper-owner-loss-processes.mjs'
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
const INTERNAL_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_INTERNAL'
const EXPECTATION_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_EXPECTATION'
const HELPER_RECORD_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_HELPER_RECORD_PATH'
const RESULT_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_RESULT_PATH'
const ACTIVE_REQUEST_COUNT = 100_000
const DEFAULT_TRIALS = 3
const OWNER_HOLD_MS = 31_000
const PROCESS_EXIT_TIMEOUT_MS = 5_000
const RETAIN_PROOF_MS = 3_000
const TRIAL_TIMEOUT_MS = OWNER_HOLD_MS + 4 * PROCESS_EXIT_TIMEOUT_MS + 120_000
const MIB = 1024 * 1024
const scriptPath = import.meta.filename
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const metricsPath = path.join(import.meta.dirname, 'macos-computer-helper-owner-loss-metrics.mjs')
const processCleanupPath = path.join(
import.meta.dirname,
'macos-computer-helper-owner-loss-processes.mjs'
)
const trialCleanupPath = path.join(
import.meta.dirname,
'macos-computer-helper-owner-loss-trial-cleanup.mjs'
)
const sidecarPath = path.join(repoRoot, 'out', 'main', 'computer-sidecar.js')
const helperAppPath = path.join(
repoRoot,
'native',
'computer-use-macos',
'.build',
'release',
'Orca Computer Use.app'
)
const helperPath = path.join(helperAppPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function waitForProcessExit(identity, timeoutMs) {
const startedAt = performance.now()
while (performance.now() - startedAt < timeoutMs) {
if (!processIdentityIsCurrent(identity)) {
return performance.now() - startedAt
}
await sleep(50)
}
return null
}
async function stopProcess(identity) {
if (!identity || !processIdentityIsCurrent(identity)) {
return
}
signalProcessIdentity(identity, helperPath, 'SIGTERM')
if ((await waitForProcessExit(identity, 2_000)) !== null) {
return
}
signalProcessIdentity(identity, helperPath, 'SIGKILL')
await waitForProcessExit(identity, 2_000)
}
function startSidecar() {
const errors = []
const child = fork(sidecarPath, [], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
ORCA_COMPUTER_SIDECAR: '1',
ORCA_COMPUTER_MACOS_HELPER_APP_PATH: helperAppPath
}
})
child.on('error', (error) => errors.push(error.stack ?? error.message))
child.stderr?.on('data', (chunk) => errors.push(String(chunk)))
return { child, errors }
}
function requestSidecar(sidecar, id, method) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup()
reject(new Error(`Sidecar ${method} request timed out: ${sidecar.errors.join('')}`))
}, 10_000)
const onMessage = (message) => {
if (message?.id !== id) {
return
}
cleanup()
if (message.ok) {
resolve(message.result)
} else {
reject(new Error(`Sidecar ${method} failed: ${JSON.stringify(message.error)}`))
}
}
const onExit = (code, signal) => {
cleanup()
reject(
new Error(
`Sidecar exited during ${method}: ${JSON.stringify({ code, signal, stderr: sidecar.errors.join('') })}`
)
)
}
const onError = (error) => {
cleanup()
reject(new Error(`Sidecar ${method} process error: ${error.message}`))
}
const cleanup = () => {
clearTimeout(timeout)
sidecar.child.off('message', onMessage)
sidecar.child.off('exit', onExit)
sidecar.child.off('error', onError)
}
sidecar.child.on('message', onMessage)
sidecar.child.once('exit', onExit)
sidecar.child.once('error', onError)
try {
sidecar.child.send({ id, method, params: {} })
} catch (error) {
onError(error)
}
})
}
async function waitForHelper(sidecarPid) {
const deadline = Date.now() + 10_000
while (Date.now() < deadline) {
const output = execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,command='], {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
for (const line of output.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)
if (
match &&
Number(match[2]) === sidecarPid &&
Number(match[3]) === Number(match[1]) &&
match[4].includes(helperPath) &&
match[4].includes(' --agent ')
) {
return { pid: Number(match[1]), pgid: Number(match[3]), command: match[4] }
}
}
await sleep(50)
}
throw new Error(`Could not find helper owned by sidecar ${sidecarPid}`)
}
function socketPathFromCommand(command) {
const match = command.match(/ --agent (.+?) --token-file /)
if (!match) {
throw new Error(`Could not read helper socket path from command: ${command}`)
}
return match[1]
}
function connectInvalidPeer(socketPath) {
return new Promise((resolve, reject) => {
const socket = net.createConnection(socketPath)
let accepted = false
const timeout = setTimeout(() => {
socket.destroy()
reject(new Error('Invalid-peer connection timed out'))
}, 5_000)
let buffer = ''
const cleanup = () => {
clearTimeout(timeout)
socket.off('error', onError)
socket.off('data', onData)
}
const onError = (error) => {
if (accepted) {
return
}
cleanup()
reject(error)
}
const onData = (chunk) => {
buffer += chunk
const newline = buffer.indexOf('\n')
if (newline < 0) {
return
}
let response
try {
response = JSON.parse(buffer.slice(0, newline))
} catch (error) {
cleanup()
socket.destroy()
reject(error)
return
}
if (response.ok !== false || response.error?.code !== 'permission_denied') {
cleanup()
socket.destroy()
reject(new Error(`Invalid peer was not rejected: ${JSON.stringify(response)}`))
return
}
clearTimeout(timeout)
socket.off('data', onData)
accepted = true
resolve(socket)
}
socket.setEncoding('utf8')
socket.on('error', onError)
socket.on('data', onData)
socket.once('connect', () => {
socket.write(
`${JSON.stringify({ id: 991, method: 'handshake', params: {}, token: 'invalid' })}\n`
)
})
})
}
async function waitForChildExit(child, timeoutMs) {
if (child.exitCode !== null || child.signalCode !== null) {
return true
}
return await Promise.race([
new Promise((resolve) => child.once('exit', () => resolve(true))),
sleep(timeoutMs).then(() => false)
])
}
async function startAuthenticatedSession() {
const sidecar = startSidecar()
let helper
try {
const capabilitiesResult = requestSidecar(sidecar, 1, 'capabilities').then(
(capabilities) => ({ capabilities }),
(error) => ({ error })
)
helper = await waitForHelper(sidecar.child.pid)
const helperRecordPath = process.env[HELPER_RECORD_PATH_ENV]
if (!helperRecordPath) {
throw new Error('Missing helper process record path')
}
writeProcessRecord(helperRecordPath, helper)
const { capabilities, error } = await capabilitiesResult
if (error) {
throw error
}
if (capabilities?.protocolVersion !== 1) {
throw new Error(`Unexpected helper handshake: ${JSON.stringify(capabilities)}`)
}
return { authenticated: capabilities.protocolVersion === 1, sidecar, helper }
} catch (error) {
sidecar.child.kill('SIGKILL')
await stopProcess(helper)
throw error
}
}
async function exerciseActiveRequests(sidecar) {
const latencies = []
const startedAt = performance.now()
for (let index = 0; index < ACTIVE_REQUEST_COUNT; index += 1) {
const requestStartedAt = performance.now()
const result = await requestSidecar(sidecar, 10_000 + index, 'listApps')
if (!Array.isArray(result?.apps)) {
throw new Error(`Unexpected listApps response: ${JSON.stringify(result)}`)
}
latencies.push(performance.now() - requestStartedAt)
}
const totalMs = performance.now() - startedAt
return {
totalMs,
requestsPerSecond: (ACTIVE_REQUEST_COUNT * 1_000) / totalMs,
medianLatencyMs: median(latencies),
p95LatencyMs: percentile(latencies, 0.95),
maxLatencyMs: Math.max(...latencies)
}
}
async function verifyGracefulClose() {
const { sidecar, helper } = await startAuthenticatedSession()
try {
const startedAt = performance.now()
sidecar.child.disconnect()
if (!(await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS))) {
throw new Error('Sidecar did not exit after graceful IPC close')
}
const helperExitMs = await waitForProcessExit(helper, PROCESS_EXIT_TIMEOUT_MS)
if (helperExitMs === null) {
throw new Error('Helper did not exit after graceful owner close')
}
return Math.round(performance.now() - startedAt)
} finally {
sidecar.child.kill('SIGKILL')
await stopProcess(helper)
}
}
async function runInternalTrial(expectation) {
let sidecar
let helper
let invalidPeer
let invalidPeerRejected = false
try {
const session = await startAuthenticatedSession()
sidecar = session.sidecar
helper = session.helper
const authenticatedAt = performance.now()
const initial = await sampleProcess(helper.pid)
const activeRequests = await exerciseActiveRequests(sidecar)
const remainingHoldMs = Math.max(0, OWNER_HOLD_MS - (performance.now() - authenticatedAt))
await sleep(remainingHoldMs)
const connected = await sampleProcess(helper.pid)
const invalidSocketPath = socketPathFromCommand(helper.command)
invalidPeer = await connectInvalidPeer(invalidSocketPath)
invalidPeerRejected = true
const survivedClaimDeadline =
performance.now() - authenticatedAt >= OWNER_HOLD_MS && isProcessAlive(helper.pid)
sidecar.child.kill('SIGKILL')
await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS)
const abruptExitMs = await waitForProcessExit(
helper,
expectation === 'reaped' ? PROCESS_EXIT_TIMEOUT_MS : RETAIN_PROOF_MS
)
const helperExitedAfterAbruptLoss = abruptExitMs !== null
if (expectation === 'reaped' && !helperExitedAfterAbruptLoss) {
throw new Error('Expected helper to exit after abrupt authenticated owner loss')
}
if (expectation === 'retained' && helperExitedAfterAbruptLoss) {
throw new Error('Expected baseline helper to remain after abrupt owner loss')
}
const postLossRssBytes = helperExitedAfterAbruptLoss ? 0 : processSnapshot(helper.pid).rssBytes
await stopProcess(helper)
helper = null
invalidPeer.destroy()
invalidPeer = null
const gracefulExitMs = await verifyGracefulClose()
return {
authenticated: session.authenticated,
survivedClaimDeadline,
invalidPeerRejectedAndDidNotRetain: invalidPeerRejected && helperExitedAfterAbruptLoss,
connectedRssBytes: connected.rssBytes,
connectedCpuMilliseconds: Math.max(
0,
Math.round((connected.cpuTimeSeconds - initial.cpuTimeSeconds) * 1_000)
),
activeRequests,
cpuSampleMs: Math.round(performance.now() - authenticatedAt),
helperExitedAfterAbruptLoss,
abruptExitMs: abruptExitMs === null ? null : Math.round(abruptExitMs),
postLossRssBytes,
gracefulExitMs
}
} finally {
invalidPeer?.destroy()
sidecar?.child.kill('SIGKILL')
await stopProcess(helper)
}
}
function parseArgs(argv) {
const options = { expect: '', trials: DEFAULT_TRIALS, output: '' }
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = argv[index + 1]
if (arg === '--expect' || arg === '--trials' || arg === '--output') {
if (!value) {
throw new Error(`Missing value for ${arg}`)
}
options[arg.slice(2)] = arg === '--trials' ? Number(value) : value
index += 1
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
if (!['retained', 'reaped'].includes(options.expect)) {
throw new Error('--expect must be retained or reaped')
}
if (!Number.isInteger(options.trials) || options.trials < 1) {
throw new Error('--trials must be a positive integer')
}
return options
}
function electronPath() {
const electronModulePath = fileURLToPath(import.meta.resolve('electron'))
return execFileSync(
process.execPath,
['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`],
{ encoding: 'utf8' }
)
}
function buildArtifacts() {
execFileSync('pnpm', ['exec', 'electron-vite', 'build'], {
cwd: repoRoot,
stdio: 'inherit'
})
execFileSync('pnpm', ['build:computer-macos'], {
cwd: repoRoot,
stdio: 'inherit'
})
}
function artifactSha256(artifactPath) {
return createHash('sha256').update(readFileSync(artifactPath)).digest('hex')
}
function runTrial(executable, expectation) {
let launcherDir
let trialTempDir
let helperRecordPath
let resultPath
let stderrPath
let stdoutPath
let result
let serializedResult
let parsedResult
let parsedResultAvailable = false
let trialError
let cleanupError
let stderrDescriptor
let stdoutDescriptor
let trialOutput = ''
try {
launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-helper-owner-bench-launcher-'))
trialTempDir = mkdtempSync(path.join(path.sep, 'tmp', 'orca-owner-bench-'))
helperRecordPath = path.join(launcherDir, 'helper.json')
resultPath = path.join(launcherDir, 'result.json')
stderrPath = path.join(launcherDir, 'stderr.log')
stdoutPath = path.join(launcherDir, 'stdout.log')
writeFileSync(
path.join(launcherDir, 'package.json'),
JSON.stringify({ name: 'orca-helper-owner-benchmark', main: 'main.cjs' })
)
writeFileSync(
path.join(launcherDir, 'main.cjs'),
`import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => {
console.error(error)
process.exitCode = 1
})\n`
)
const env = {
...process.env,
TMPDIR: trialTempDir,
[INTERNAL_ENV]: '1',
[EXPECTATION_ENV]: expectation,
[HELPER_RECORD_PATH_ENV]: helperRecordPath,
[RESULT_PATH_ENV]: resultPath
}
delete env.ELECTRON_RUN_AS_NODE
stderrDescriptor = openSync(stderrPath, 'w')
stdoutDescriptor = openSync(stdoutPath, 'w')
result = spawnBenchmarkProcess(executable, [launcherDir], {
cwd: repoRoot,
env,
stdio: ['ignore', stdoutDescriptor, stderrDescriptor],
timeout: TRIAL_TIMEOUT_MS
})
if (result.status === 0 && existsSync(resultPath)) {
serializedResult = readFileSync(resultPath, 'utf8')
parsedResult = parseBenchmarkTrialResult(serializedResult)
parsedResultAvailable = true
}
} catch (error) {
trialError = error
} finally {
const failedTrial = benchmarkTrialNeedsCleanup(result, parsedResultAvailable)
const trialMarker = trialTempDir ? `TMPDIR=${trialTempDir}` : undefined
const cleanup = cleanupOwnerLossTrial({
failed: failedTrial,
pid: result?.pid,
marker: trialMarker,
recordPath: helperRecordPath,
helperPath,
tempDir: trialTempDir,
stderrDescriptor,
stdoutDescriptor,
outputPaths: [stderrPath, stdoutPath],
launcherDir
})
cleanupError = cleanup.error
trialOutput = cleanup.output
}
if (!trialError && result?.status !== 0) {
trialError = new Error(
`Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n${trialOutput}`
)
}
if (!trialError && !serializedResult) {
trialError = new Error(`Electron trial did not write a result:\n${trialOutput}`)
}
throwBenchmarkTrialFailures(trialError, cleanupError)
return parsedResult
}
function runBenchmark() {
if (process.platform !== 'darwin') {
throw new Error('The computer-use helper owner benchmark is macOS-only')
}
const options = parseArgs(process.argv.slice(2))
const dirty = execFileSync('git', ['status', '--porcelain'], {
cwd: repoRoot,
encoding: 'utf8'
}).trim()
if (dirty) {
throw new Error('Commit or stash changes before running the provenance-bound benchmark')
}
buildArtifacts()
if (!existsSync(sidecarPath) || !existsSync(helperPath)) {
throw new Error('Fresh production sidecar/helper build did not produce the expected artifacts')
}
const executable = electronPath()
const results = Array.from({ length: options.trials }, () => runTrial(executable, options.expect))
const rssBytes = results.map((result) => result.connectedRssBytes)
const cpuMilliseconds = results.map((result) => result.connectedCpuMilliseconds)
const activeRequestTotals = results.map((result) => result.activeRequests.totalMs)
const activeRequestRates = results.map((result) => result.activeRequests.requestsPerSecond)
const activeRequestMedians = results.map((result) => result.activeRequests.medianLatencyMs)
const activeRequestP95s = results.map((result) => result.activeRequests.p95LatencyMs)
const activeRequestMaxes = results.map((result) => result.activeRequests.maxLatencyMs)
const postLossRssBytes = results.map((result) => result.postLossRssBytes)
const report = {
benchmark: 'macos-computer-helper-authenticated-owner-loss',
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: repoRoot,
encoding: 'utf8'
}).trim(),
artifacts: {
sidecarSha256: artifactSha256(sidecarPath),
helperSha256: artifactSha256(helperPath)
},
sources: {
benchmarkSha256: artifactSha256(scriptPath),
metricsSha256: artifactSha256(metricsPath),
processCleanupSha256: artifactSha256(processCleanupPath),
trialCleanupSha256: artifactSha256(trialCleanupPath)
},
expectation: options.expect,
trials: options.trials,
ownerHoldMs: OWNER_HOLD_MS,
activeRequestCount: ACTIVE_REQUEST_COUNT,
authenticated: results.every((result) => result.authenticated),
survivedClaimDeadline: results.every((result) => result.survivedClaimDeadline),
invalidPeerRejectedAndDidNotRetain: results.every(
(result) => result.invalidPeerRejectedAndDidNotRetain
),
connectedRssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))),
medianConnectedRssMiB: Number((median(rssBytes) / MIB).toFixed(2)),
connectedCpuMilliseconds: cpuMilliseconds,
medianConnectedCpuMilliseconds: median(cpuMilliseconds),
activeRequestTotalMs: activeRequestTotals.map((value) => Number(value.toFixed(3))),
medianActiveRequestTotalMs: Number(median(activeRequestTotals).toFixed(3)),
activeRequestsPerSecond: activeRequestRates.map((value) => Number(value.toFixed(2))),
medianActiveRequestsPerSecond: Number(median(activeRequestRates).toFixed(2)),
activeRequestMedianLatencyMs: activeRequestMedians.map((value) => Number(value.toFixed(3))),
medianActiveRequestMedianLatencyMs: Number(median(activeRequestMedians).toFixed(3)),
activeRequestP95LatencyMs: activeRequestP95s.map((value) => Number(value.toFixed(3))),
medianActiveRequestP95LatencyMs: Number(median(activeRequestP95s).toFixed(3)),
activeRequestMaxLatencyMs: activeRequestMaxes.map((value) => Number(value.toFixed(3))),
helperExitedAfterAbruptLoss: results.map((result) => result.helperExitedAfterAbruptLoss),
abruptExitMs: results.map((result) => result.abruptExitMs),
postLossRssMiB: postLossRssBytes.map((value) => Number((value / MIB).toFixed(2))),
medianPostLossRssMiB: Number((median(postLossRssBytes) / MIB).toFixed(2)),
gracefulExitMs: results.map((result) => result.gracefulExitMs)
}
const serialized = `${JSON.stringify(report, null, 2)}\n`
process.stdout.write(serialized)
if (options.output) {
writeFileSync(path.resolve(options.output), serialized)
}
}
if (process.env[INTERNAL_ENV] === '1') {
const { app } = await import('electron')
await app.whenReady()
try {
const result = await runInternalTrial(process.env[EXPECTATION_ENV])
writeFileSync(process.env[RESULT_PATH_ENV], JSON.stringify(result))
} finally {
app.quit()
}
} else {
runBenchmark()
}
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { signalValidatedProcessGroup } from './macos-computer-helper-owner-loss-processes.mjs'
describe('macOS helper owner-loss benchmark group recovery', () => {
it('retains uncertain stop state across cleanup stage failures', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
const groupState = { stopped: false }
let scanCount = 0
let continueAttempts = 0
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount >= 3) {
throw new Error(
scanCount === 3 ? 'post-stop inspection failed' : 'final inspection failed'
)
}
return members
},
signalProcess: (pid, signal) => {
if (pid === -41 && signal === 'SIGCONT') {
continueAttempts += 1
if (continueAttempts === 1) {
throw new Error('first compensation failed')
}
}
}
}
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
).toThrow('Benchmark process group signal recovery failed')
expect(groupState.stopped).toBe(true)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('final inspection failed')
expect(continueAttempts).toBe(2)
expect(groupState.stopped).toBe(false)
})
it('retains an uncertain anchor stop across cleanup stage failures', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
const groupState = { stopped: false, anchorPid: null }
let scanCount = 0
let continueAttempts = 0
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount >= 2) {
throw new Error(
scanCount === 2 ? 'post-anchor inspection failed' : 'final inspection failed'
)
}
return members
},
signalProcess: (pid, signal) => {
if (pid === 41 && signal === 'SIGCONT') {
continueAttempts += 1
if (continueAttempts === 1) {
throw new Error('first anchor compensation failed')
}
}
}
}
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
).toThrow('Benchmark process group signal recovery failed')
expect(groupState.anchorPid).toBe(41)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('final inspection failed')
expect(continueAttempts).toBe(2)
expect(groupState.anchorPid).toBeNull()
})
it('recovers a retained anchor before selecting a new one', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const firstAnchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
const finalAnchor = { pid: 42, pgid: 41, command: `/child ${marker}` }
const groupState = { stopped: false, anchorPid: null }
let scanCount = 0
let firstAnchorContinues = 0
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount === 2) {
throw new Error('post-anchor inspection failed')
}
return scanCount === 1 ? [firstAnchor] : [finalAnchor]
},
signalProcess: (pid, signal) => {
if (pid === 41 && signal === 'SIGCONT') {
firstAnchorContinues += 1
if (firstAnchorContinues === 1) {
throw new Error('first anchor compensation failed')
}
}
}
}
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
).toThrow('Benchmark process group signal recovery failed')
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(true)
expect(firstAnchorContinues).toBe(2)
expect(groupState).toEqual({ stopped: false, anchorPid: null })
})
it('preserves group authority errors when compensation fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const ownershipError = 'Benchmark process group no longer belongs to this trial'
let thrown
try {
signalValidatedProcessGroup(
41,
marker,
'SIGKILL',
{ stopped: true },
{
processIdentities: () => [{ pid: 41, pgid: 41, command: '/unrelated' }],
signalProcess: () => {
throw new Error('resume denied')
}
}
)
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors.map((error) => error.message)).toEqual([ownershipError, 'resume denied'])
})
})
@@ -0,0 +1,62 @@
import { execFileSync } from 'node:child_process'
const SAMPLE_COUNT = 5
const SAMPLE_INTERVAL_MS = 200
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function median(values) {
const sorted = [...values].sort((left, right) => left - right)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
export function percentile(values, fraction) {
const sorted = [...values].sort((left, right) => left - right)
return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]
}
function parseCpuTimeSeconds(value) {
const [dayOrTime, clock] = value.includes('-') ? value.split('-', 2) : [null, value]
const days = dayOrTime === null ? 0 : Number(dayOrTime)
const parts = clock.split(':').map(Number)
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
throw new Error(`Invalid process CPU time: ${value}`)
}
const seconds = parts.pop() ?? 0
const minutes = parts.pop() ?? 0
const hours = parts.pop() ?? 0
return days * 86_400 + hours * 3_600 + minutes * 60 + seconds
}
export function processSnapshot(pid) {
const raw = execFileSync(
'ps',
['-o', 'rss=', '-o', 'time=', '-o', 'command=', '-p', String(pid)],
{ encoding: 'utf8' }
).trim()
const match = raw.match(/^(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
throw new Error(`Could not inspect process ${pid}: ${raw}`)
}
return {
rssBytes: Number(match[1]) * 1024,
cpuTimeSeconds: parseCpuTimeSeconds(match[2]),
command: match[3]
}
}
export async function sampleProcess(pid) {
const samples = []
for (let index = 0; index < SAMPLE_COUNT; index += 1) {
samples.push(processSnapshot(pid))
await sleep(SAMPLE_INTERVAL_MS)
}
return {
rssBytes: median(samples.map((sample) => sample.rssBytes)),
cpuTimeSeconds: samples.at(-1).cpuTimeSeconds,
command: samples.at(-1).command
}
}
@@ -0,0 +1,418 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
const PROCESS_EXIT_TIMEOUT_MS = 2_000
const PROCESS_POLL_MS = 25
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
const processIdentityOperations = {
executePs: execFileSync,
signalProcess: process.kill.bind(process)
}
export function processIdentity(pid, operations = processIdentityOperations) {
if (!Number.isInteger(pid) || pid <= 0) {
return null
}
try {
const output = operations
.executePs('ps', ['-p', String(pid), '-o', 'pid=,pgid=,command='], {
encoding: 'utf8'
})
.trim()
const match = output.match(/^(\d+)\s+(\d+)\s+(.+)$/)
if (!match) {
throw new Error(`Could not parse process identity for ${pid}`)
}
return { pid: Number(match[1]), pgid: Number(match[2]), command: match[3] }
} catch (error) {
try {
operations.signalProcess(pid, 0)
} catch (lookupError) {
if (lookupError?.code === 'ESRCH') {
return null
}
}
throw error
}
}
function matchingDetachedProcesses(identities, expectedCommandFragments) {
return identities.filter(
(identity) =>
identity.pgid === identity.pid &&
expectedCommandFragments.every((fragment) => identity.command.includes(fragment))
)
}
const matchingProcessOperations = {
processIdentities,
signalProcessIdentity,
waitForIdentityExit
}
export function killProcessMatchingCommand(
expectedCommandFragments,
operations = matchingProcessOperations
) {
const matches = matchingDetachedProcesses(
operations.processIdentities(),
expectedCommandFragments
)
if (matches.length === 0) {
return false
}
const errors = []
for (const match of matches) {
try {
if (operations.signalProcessIdentity(match, expectedCommandFragments[0], 'SIGKILL')) {
operations.waitForIdentityExit(match)
}
} catch (error) {
errors.push(error)
}
}
try {
const remaining = matchingDetachedProcesses(
operations.processIdentities(),
expectedCommandFragments
)
if (remaining.length > 0) {
errors.push(
new Error(
`Benchmark helper cleanup left matching processes: ${remaining
.map((identity) => identity.pid)
.join(', ')}`
)
)
}
} catch (error) {
errors.push(error)
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(errors, 'Benchmark exact-command cleanup failed')
}
return true
}
function sleepSync(milliseconds) {
Atomics.wait(sleepBuffer, 0, 0, milliseconds)
}
function validateDetachedIdentity(identity, expectedCommandFragment) {
if (
!Number.isInteger(identity?.pid) ||
identity.pid <= 0 ||
identity.pgid !== identity.pid ||
typeof identity.command !== 'string' ||
!identity.command.includes(expectedCommandFragment)
) {
throw new Error('Recorded benchmark helper identity is invalid')
}
}
function sameIdentity(left, right) {
return left?.pid === right?.pid && left?.pgid === right?.pgid && left?.command === right?.command
}
function processIdentities(includeEnvironment = false) {
const args = includeEnvironment
? ['eww', '-axo', 'pid=,pgid=,command=']
: ['-axo', 'pid=,pgid=,command=']
const output = execFileSync('ps', args, {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
return output
.split('\n')
.map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/))
.filter(Boolean)
.map((match) => ({
pid: Number(match[1]),
pgid: Number(match[2]),
command: match[3]
}))
}
function waitForIdentityExit(identity) {
const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS
while (Date.now() < deadline) {
if (!processIdentityIsCurrent(identity)) {
return true
}
sleepSync(PROCESS_POLL_MS)
}
throw new Error(`Recorded benchmark helper ${identity.pid} did not exit`)
}
export function spawnBenchmarkProcess(executable, args, options) {
return spawnSync(executable, args, {
...options,
detached: true,
killSignal: 'SIGKILL'
})
}
export function runBenchmarkCleanupStages(stages) {
const errors = []
for (const stage of stages) {
try {
stage()
} catch (error) {
errors.push(error)
}
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(errors, 'Benchmark trial cleanup failed')
}
}
export function throwBenchmarkTrialFailures(trialError, cleanupError) {
if (trialError && cleanupError) {
throw new AggregateError([trialError, cleanupError], 'Electron trial and cleanup failed')
}
if (trialError) {
throw trialError
}
if (cleanupError) {
throw cleanupError
}
}
export function parseBenchmarkTrialResult(serializedResult) {
return JSON.parse(serializedResult)
}
export function benchmarkTrialNeedsCleanup(spawnResult, parsedResultAvailable) {
return spawnResult?.status !== 0 || !parsedResultAvailable
}
const processGroupSignalOperations = {
processIdentities,
signalProcess: process.kill.bind(process)
}
function compensateStoppedGroup(pgid, groupState, operations) {
const errors = []
const targets = [
groupState.stopped ? [-pgid, 'stopped'] : null,
groupState.anchorPid ? [groupState.anchorPid, 'anchorPid'] : null
].filter(Boolean)
for (const [pid, stateKey] of targets) {
try {
operations.signalProcess(pid, 'SIGCONT')
groupState[stateKey] = stateKey === 'stopped' ? false : null
} catch (error) {
if (error?.code === 'ESRCH') {
groupState[stateKey] = stateKey === 'stopped' ? false : null
} else {
errors.push(error)
}
}
}
return errors
}
export function signalValidatedProcessGroup(
pgid,
environmentFragment,
signal,
groupState = { stopped: false, anchorPid: null },
operations = processGroupSignalOperations
) {
if (!Number.isInteger(pgid) || pgid <= 0) {
return false
}
let members
try {
members = operations.processIdentities(true).filter((identity) => identity.pgid === pgid)
} catch (error) {
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(
[error, ...recoveryErrors],
'Benchmark process group recovery failed before validation'
)
}
throw error
}
if (members.length === 0) {
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(recoveryErrors, 'Benchmark missing process group recovery failed')
}
return false
}
if (members.some((identity) => !identity.command.includes(environmentFragment))) {
const ownershipError = new Error('Benchmark process group no longer belongs to this trial')
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(
[ownershipError, ...recoveryErrors],
'Benchmark process group authority recovery failed'
)
}
throw ownershipError
}
if (groupState.anchorPid) {
try {
operations.signalProcess(groupState.anchorPid, 'SIGCONT')
groupState.anchorPid = null
} catch (error) {
if (error?.code === 'ESRCH') {
groupState.anchorPid = null
} else {
throw new AggregateError([error], 'Benchmark pending anchor recovery failed')
}
}
}
const anchor = members[0]
try {
operations.signalProcess(anchor.pid, 'SIGSTOP')
groupState.anchorPid = anchor.pid
const stoppedAnchor = operations
.processIdentities(true)
.find((identity) => identity.pid === anchor.pid)
if (!sameIdentity(stoppedAnchor, anchor)) {
throw new Error('Benchmark process group anchor changed before signaling')
}
operations.signalProcess(-pgid, 'SIGSTOP')
groupState.stopped = true
groupState.anchorPid = null
const stoppedMembers = operations
.processIdentities(true)
.filter((identity) => identity.pgid === pgid)
if (
stoppedMembers.length === 0 ||
stoppedMembers.some((identity) => !identity.command.includes(environmentFragment))
) {
throw new Error('Benchmark process group changed before signaling')
}
if (signal !== 'SIGSTOP') {
operations.signalProcess(-pgid, signal)
if (signal !== 'SIGKILL') {
operations.signalProcess(-pgid, 'SIGCONT')
}
groupState.stopped = false
groupState.anchorPid = null
}
return true
} catch (error) {
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(
[error, ...recoveryErrors],
'Benchmark process group signal recovery failed'
)
}
if (error.code === 'ESRCH') {
return false
}
throw error
}
}
export function writeProcessRecord(recordPath, processIdentity) {
const temporaryPath = `${recordPath}.${process.pid}.tmp`
writeFileSync(temporaryPath, JSON.stringify(processIdentity))
renameSync(temporaryPath, recordPath)
}
export function processIdentityIsCurrent(identity) {
return sameIdentity(processIdentity(identity?.pid), identity)
}
const processSignalOperations = {
processIdentity,
signalProcess: process.kill.bind(process)
}
export function signalProcessIdentity(
identity,
expectedCommandFragment,
signal,
operations = processSignalOperations
) {
validateDetachedIdentity(identity, expectedCommandFragment)
const currentIdentity = operations.processIdentity(identity.pid)
if (!currentIdentity) {
return false
}
if (!sameIdentity(currentIdentity, identity)) {
throw new Error('Recorded benchmark helper PID now belongs to another process')
}
let stopped = false
try {
operations.signalProcess(identity.pid, 'SIGSTOP')
stopped = true
const stoppedIdentity = operations.processIdentity(identity.pid)
if (!sameIdentity(stoppedIdentity, identity)) {
throw new Error('Recorded benchmark helper PID changed before signaling')
}
operations.signalProcess(-identity.pgid, signal)
if (signal !== 'SIGKILL') {
operations.signalProcess(-identity.pgid, 'SIGCONT')
}
stopped = false
return true
} catch (error) {
let resumeError
if (stopped) {
try {
operations.signalProcess(identity.pid, 'SIGCONT')
} catch (caught) {
if (caught?.code !== 'ESRCH') {
resumeError = caught
}
}
}
if (resumeError) {
throw new AggregateError([error, resumeError], 'Benchmark helper signal recovery failed')
}
if (error.code === 'ESRCH') {
return false
}
throw error
}
}
export function killRecordedProcess(recordPath, expectedCommandFragment) {
if (!existsSync(recordPath)) {
return false
}
const record = JSON.parse(readFileSync(recordPath, 'utf8'))
if (!signalProcessIdentity(record, expectedCommandFragment, 'SIGKILL')) {
return false
}
return waitForIdentityExit(record)
}
export function killRecordedAndMatchingProcesses(
recordPath,
recordedCommandFragment,
matchingCommandFragments
) {
const errors = []
try {
killRecordedProcess(recordPath, recordedCommandFragment)
} catch (error) {
errors.push(error)
}
try {
killProcessMatchingCommand(matchingCommandFragments)
} catch (error) {
errors.push(error)
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(errors, 'Benchmark helper cleanup failed')
}
}
@@ -0,0 +1,558 @@
import { execFileSync, spawn } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
benchmarkTrialNeedsCleanup,
killProcessMatchingCommand,
killRecordedAndMatchingProcesses,
killRecordedProcess,
parseBenchmarkTrialResult,
processIdentity,
runBenchmarkCleanupStages,
signalProcessIdentity,
signalValidatedProcessGroup,
spawnBenchmarkProcess,
throwBenchmarkTrialFailures,
writeProcessRecord
} from './macos-computer-helper-owner-loss-processes.mjs'
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
const describeMacOS = process.platform === 'darwin' ? describe : describe.skip
const spawnedPids = new Set()
const temporaryDirectories = new Set()
afterEach(() => {
for (const pid of spawnedPids) {
try {
process.kill(pid, 'SIGKILL')
} catch {}
}
spawnedPids.clear()
for (const temporaryDirectory of temporaryDirectories) {
rmSync(temporaryDirectory, { recursive: true, force: true })
}
temporaryDirectories.clear()
})
describeMacOS('macOS helper owner-loss benchmark process cleanup', () => {
it('enforces a hard timeout when the trial ignores SIGTERM', () => {
const startedAt = Date.now()
const result = spawnBenchmarkProcess(
process.execPath,
['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 1_000)"],
{ stdio: 'ignore', timeout: 100 }
)
expect(result.error?.code).toBe('ETIMEDOUT')
expect(result.signal).toBe('SIGKILL')
expect(Date.now() - startedAt).toBeLessThan(2_000)
expect(() => process.kill(result.pid, 0)).toThrow()
})
it('runs every cleanup stage before aggregating errors', () => {
const completed = []
let thrown
try {
runBenchmarkCleanupStages([
() => {
completed.push(1)
throw new Error('first failure')
},
() => {
completed.push(2)
},
() => {
completed.push(3)
throw new Error('last failure')
}
])
} catch (error) {
thrown = error
}
expect(completed).toEqual([1, 2, 3])
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors.map((error) => error.message)).toEqual(['first failure', 'last failure'])
})
it('preserves malformed-result and cleanup failures', () => {
let trialError
try {
parseBenchmarkTrialResult('{malformed')
} catch (error) {
trialError = error
}
const cleanupError = new Error('cleanup failed')
let thrown
try {
throwBenchmarkTrialFailures(trialError, cleanupError)
} catch (error) {
thrown = error
}
expect(trialError).toBeInstanceOf(SyntaxError)
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors).toEqual([trialError, cleanupError])
})
it('cleans up a status-zero trial whose result could not be parsed', () => {
expect(benchmarkTrialNeedsCleanup(undefined, false)).toBe(true)
expect(benchmarkTrialNeedsCleanup({ status: 0 }, false)).toBe(true)
expect(benchmarkTrialNeedsCleanup({ status: 0 }, true)).toBe(false)
expect(benchmarkTrialNeedsCleanup({ status: 1 }, true)).toBe(true)
})
it('removes a launcher directory after partial trial setup', () => {
const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-owner-partial-setup-test-'))
temporaryDirectories.add(launcherDir)
const cleanup = cleanupOwnerLossTrial({
failed: true,
launcherDir,
outputPaths: []
})
expect(cleanup.error).toBeUndefined()
expect(existsSync(launcherDir)).toBe(false)
temporaryDirectories.delete(launcherDir)
})
it('kills a timed-out trial group only after validating its environment', async () => {
const temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'orca-owner-benchmark-group-test-'))
temporaryDirectories.add(temporaryDirectory)
const childPidPath = path.join(temporaryDirectory, 'child.pid')
const environmentName = `ORCA_OWNER_GROUP_${process.pid}`
const environmentValue = `${Date.now()}`
const fixture = `
const { spawn } = require('node:child_process')
const { writeFileSync } = require('node:fs')
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
stdio: 'ignore'
})
writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid))
setInterval(() => {}, 1000)
`
const result = spawnBenchmarkProcess(process.execPath, ['-e', fixture], {
env: { ...process.env, [environmentName]: environmentValue },
stdio: 'ignore',
timeout: 100
})
const childPid = Number(readFileSync(childPidPath, 'utf8'))
spawnedPids.add(childPid)
const environmentFragment = `${environmentName}=${environmentValue}`
const groupState = { stopped: false }
expect(() =>
signalValidatedProcessGroup(result.pid, `${environmentName}=wrong`, 'SIGSTOP')
).toThrow('Benchmark process group no longer belongs to this trial')
expect(() => process.kill(childPid, 0)).not.toThrow()
expect(
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGSTOP', groupState)
).toBe(true)
expect(
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGKILL', groupState)
).toBe(true)
await expect
.poll(() => {
try {
process.kill(childPid, 0)
return true
} catch {
return false
}
})
.toBe(false)
spawnedPids.delete(childPid)
})
it('resumes the group after post-stop revalidation fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
{ pid: 42, pgid: 41, command: `/child ${marker}` }
]
const signals = []
let scanCount = 0
expect(() =>
signalValidatedProcessGroup(
41,
marker,
'SIGKILL',
{ stopped: false },
{
processIdentities: () => {
scanCount += 1
if (scanCount === 3) {
throw new Error('transient group inspection failure')
}
return members
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
)
).toThrow('transient group inspection failure')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[-41, 'SIGSTOP'],
[-41, 'SIGCONT']
])
})
it('compensates a possible stop after group anchor replacement', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
const signals = []
let scanCount = 0
expect(() =>
signalValidatedProcessGroup(
41,
marker,
'SIGKILL',
{ stopped: false },
{
processIdentities: () => {
scanCount += 1
return scanCount === 1 ? [anchor] : [replacement]
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
)
).toThrow('Benchmark process group anchor changed before signaling')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[41, 'SIGCONT']
])
})
it('resumes a previously frozen group when final inspection fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
const signals = []
let scanCount = 0
const groupState = { stopped: false }
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount === 4) {
throw new Error('transient final inspection failure')
}
return members
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('transient final inspection failure')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[-41, 'SIGSTOP'],
[-41, 'SIGCONT']
])
})
it('resumes a previously frozen group when final anchor stop fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
{ pid: 42, pgid: 41, command: `/child ${marker}` }
]
const signals = []
let finalCall = false
const groupState = { stopped: false }
const missingProcessError = Object.assign(new Error('anchor exited'), { code: 'ESRCH' })
const operations = {
processIdentities: () => members,
signalProcess: (pid, signal) => {
signals.push([pid, signal])
if (finalCall && pid === 41 && signal === 'SIGSTOP') {
throw missingProcessError
}
}
}
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
finalCall = true
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(false)
expect(signals.at(-1)).toEqual([-41, 'SIGCONT'])
})
it('resumes a previously frozen group after final anchor replacement', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
const child = { pid: 42, pgid: 41, command: `/child ${marker}` }
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
const signals = []
let scanCount = 0
const groupState = { stopped: false }
const operations = {
processIdentities: () => {
scanCount += 1
return scanCount === 5 ? [replacement, child] : [anchor, child]
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('Benchmark process group anchor changed before signaling')
expect(signals.slice(-2)).toEqual([
[-41, 'SIGCONT'],
[41, 'SIGCONT']
])
})
it('kills a recorded helper in a separate process group', async () => {
const temporaryDirectory = mkdtempSync(
path.join(tmpdir(), 'orca-owner-benchmark-cleanup-test-')
)
temporaryDirectories.add(temporaryDirectory)
const recordPath = path.join(temporaryDirectory, 'helper.json')
const marker = `orca-owner-cleanup-${process.pid}-${Date.now()}`
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
detached: true,
stdio: 'ignore'
})
spawnedPids.add(helper.pid)
helper.unref()
const exited = new Promise((resolve) => helper.once('exit', resolve))
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
encoding: 'utf8'
}).trim()
const processGroup = Number(
execFileSync('ps', ['-p', String(helper.pid), '-o', 'pgid='], {
encoding: 'utf8'
}).trim()
)
writeProcessRecord(recordPath, { pid: helper.pid, pgid: processGroup, command })
expect(processGroup).toBe(helper.pid)
expect(killRecordedProcess(recordPath, marker)).toBe(true)
await exited
expect(() => process.kill(helper.pid, 0)).toThrow()
spawnedPids.delete(helper.pid)
})
it('kills every unrecorded helper using its unique trial command', async () => {
const marker = `orca-owner-unrecorded-${process.pid}-${Date.now()}`
const helpers = Array.from({ length: 2 }, () =>
spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
detached: true,
stdio: 'ignore'
})
)
for (const helper of helpers) {
spawnedPids.add(helper.pid)
helper.unref()
}
const exited = Promise.all(
helpers.map((helper) => new Promise((resolve) => helper.once('exit', resolve)))
)
expect(killProcessMatchingCommand([process.execPath, marker])).toBe(true)
await exited
for (const helper of helpers) {
expect(() => process.kill(helper.pid, 0)).toThrow()
spawnedPids.delete(helper.pid)
}
})
it('continues exact-match cleanup after an earlier match fails', () => {
const marker = `orca-owner-multiple-${process.pid}-${Date.now()}`
const matches = [
{ pid: 41, pgid: 41, command: `/helper ${marker}` },
{ pid: 42, pgid: 42, command: `/helper ${marker}` }
]
const attempted = []
let scanCount = 0
expect(() =>
killProcessMatchingCommand(['/helper', marker], {
processIdentities: () => {
scanCount += 1
return scanCount === 1 ? matches : [matches[0]]
},
signalProcessIdentity: (identity) => {
attempted.push(identity.pid)
if (identity.pid === matches[0].pid) {
throw new Error('identity changed')
}
return true
},
waitForIdentityExit: () => {}
})
).toThrow('Benchmark exact-command cleanup failed')
expect(attempted).toEqual([41, 42])
})
it('does not treat an identity query failure as process exit', () => {
const queryError = new Error('transient ps failure')
expect(() =>
processIdentity(41, {
executePs: () => {
throw queryError
},
signalProcess: () => {}
})
).toThrow(queryError)
})
it('resumes a helper when post-stop identity inspection fails', () => {
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
const signals = []
let inspectionCount = 0
expect(() =>
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
processIdentity: () => {
inspectionCount += 1
if (inspectionCount === 2) {
throw new Error('transient ps failure after stop')
}
return identity
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
})
).toThrow('transient ps failure after stop')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[41, 'SIGCONT']
])
})
it('compensates a possible stop after helper PID replacement', () => {
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
const signals = []
let inspectionCount = 0
expect(() =>
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
processIdentity: () => {
inspectionCount += 1
return inspectionCount === 1 ? identity : replacement
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
})
).toThrow('Recorded benchmark helper PID changed before signaling')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[41, 'SIGCONT']
])
})
it('preserves helper identity errors when compensation fails', () => {
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
let inspectionCount = 0
let thrown
try {
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
processIdentity: () => {
inspectionCount += 1
return inspectionCount === 1 ? identity : replacement
},
signalProcess: (_pid, signal) => {
if (signal === 'SIGCONT') {
throw new Error('resume denied')
}
}
})
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors.map((error) => error.message)).toEqual([
'Recorded benchmark helper PID changed before signaling',
'resume denied'
])
})
it('treats a missing PID as process exit after an identity query failure', () => {
const missingProcessError = Object.assign(new Error('missing process'), { code: 'ESRCH' })
expect(
processIdentity(41, {
executePs: () => {
throw new Error('ps found no process')
},
signalProcess: () => {
throw missingProcessError
}
})
).toBeNull()
})
it('runs unique-command cleanup after an invalid process record', async () => {
const temporaryDirectory = mkdtempSync(
path.join(tmpdir(), 'orca-owner-benchmark-fallback-test-')
)
temporaryDirectories.add(temporaryDirectory)
const recordPath = path.join(temporaryDirectory, 'helper.json')
const marker = `orca-owner-invalid-record-${process.pid}-${Date.now()}`
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
detached: true,
stdio: 'ignore'
})
spawnedPids.add(helper.pid)
helper.unref()
const exited = new Promise((resolve) => helper.once('exit', resolve))
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
encoding: 'utf8'
}).trim()
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
expect(() =>
killRecordedAndMatchingProcesses(recordPath, marker, [process.execPath, marker])
).toThrow('Recorded benchmark helper identity is invalid')
await exited
expect(() => process.kill(helper.pid, 0)).toThrow()
spawnedPids.delete(helper.pid)
})
it('rejects a record that is not a detached process-group identity', () => {
const temporaryDirectory = mkdtempSync(
path.join(tmpdir(), 'orca-owner-benchmark-identity-test-')
)
temporaryDirectories.add(temporaryDirectory)
const recordPath = path.join(temporaryDirectory, 'helper.json')
const marker = `orca-owner-invalid-identity-${process.pid}-${Date.now()}`
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
stdio: 'ignore'
})
spawnedPids.add(helper.pid)
helper.unref()
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
encoding: 'utf8'
}).trim()
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
expect(() => killRecordedProcess(recordPath, marker)).toThrow(
'Recorded benchmark helper identity is invalid'
)
expect(() => process.kill(helper.pid, 0)).not.toThrow()
})
})
@@ -0,0 +1,63 @@
import { closeSync, existsSync, readFileSync, rmSync } from 'node:fs'
import {
killRecordedAndMatchingProcesses,
runBenchmarkCleanupStages,
signalValidatedProcessGroup
} from './macos-computer-helper-owner-loss-processes.mjs'
export function cleanupOwnerLossTrial(options) {
const groupState = { stopped: false, anchorPid: null }
let error
let output = ''
try {
runBenchmarkCleanupStages([
() => {
if (options.failed && Number.isInteger(options.pid) && options.marker) {
signalValidatedProcessGroup(options.pid, options.marker, 'SIGSTOP', groupState)
}
},
() => {
if (options.failed && options.recordPath && options.tempDir) {
killRecordedAndMatchingProcesses(options.recordPath, options.helperPath, [
options.helperPath,
options.tempDir
])
}
},
() => {
if (options.failed && Number.isInteger(options.pid) && options.marker) {
signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL', groupState)
}
},
() => {
if (options.stderrDescriptor !== undefined) {
closeSync(options.stderrDescriptor)
}
},
() => {
if (options.stdoutDescriptor !== undefined) {
closeSync(options.stdoutDescriptor)
}
},
() => {
output = (options.outputPaths ?? [])
.filter((outputPath) => outputPath && existsSync(outputPath))
.map((outputPath) => readFileSync(outputPath, 'utf8'))
.join('')
},
() => {
if (options.launcherDir) {
rmSync(options.launcherDir, { recursive: true, force: true })
}
},
() => {
if (options.tempDir) {
rmSync(options.tempDir, { recursive: true, force: true })
}
}
])
} catch (caught) {
error = caught
}
return { error, output }
}
@@ -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'),
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
describe('packaged hang watchdog worker contract', () => {
it('boots the worker from app.asar in PR checks', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const smokeSource = readFileSync(
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
'utf8'
)
const smokeStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Smoke packaged hang watchdog worker'
)
expect(smokeStep.run).toBe(
'xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked'
)
expect(smokeSource).toContain(
"process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]"
)
})
// Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion.
it('fails the smoke when the packaged worker never reports success', () => {
const smokeSource = readFileSync(
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
'utf8'
)
expect(smokeSource).toContain('app.exit(1)')
expect(smokeSource).not.toContain('app.quit()')
expect(smokeSource).toContain('if (!result.stdout.includes(SUCCESS_LINE))')
})
})
@@ -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')
}
})
})
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# Provisions the credentials hourly-mac-build.yml uses to publish into
# stablyai/orca-hourly. GITHUB_TOKEN cannot be used: it is scoped to the repo
# running the workflow, and hourly artifacts are published to a different one.
#
# A GitHub App is used rather than a PAT because its private key does not expire
# — no yearly rotation — and it belongs to the org rather than to the person who
# created it, so it survives that person leaving.
#
# The key is read from a file and piped straight into `gh secret set`. It is never
# echoed, never passed as a command-line argument (argv is world-readable via
# `ps`), and never copied anywhere on disk.
#
# Usage: bash config/scripts/setup-hourly-release-token.sh [path/to/key.pem]
#
set -euo pipefail
# Guard: xtrace would echo the key to stderr on every expansion. Test before
# disabling, or the check reads the state this line just cleared and never fires.
if [[ -o xtrace ]]; then
echo "Refusing to run with xtrace enabled; it would echo the private key." >&2
exit 1
fi
set +x
MAIN_REPO="stablyai/orca"
HOURLY_REPO="stablyai/orca-hourly"
APP_ID_SECRET="HOURLY_RELEASE_APP_ID"
APP_KEY_SECRET="HOURLY_RELEASE_APP_PRIVATE_KEY"
fail() {
echo "error: $*" >&2
exit 1
}
command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com"
gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login"
# Setting repo secrets requires admin; check before asking for anything.
if [[ "$(gh api "repos/$MAIN_REPO" --jq '.permissions.admin' 2>/dev/null)" != "true" ]]; then
fail "You need admin on $MAIN_REPO to set repository secrets."
fi
gh api "repos/$HOURLY_REPO" --jq '.full_name' >/dev/null 2>&1 ||
fail "$HOURLY_REPO does not exist or you cannot see it."
cat <<EOF
Create a GitHub App (one time — the key never expires)
──────────────────────────────────────────────────────
1. Open: https://github.com/organizations/stablyai/settings/apps/new
2. Name .................. orca-hourly-release
Homepage URL .......... https://github.com/$HOURLY_REPO
Webhook ............... UNCHECK "Active"
3. Repository permissions -> Contents: Read and write
(leave everything else alone)
4. "Where can this app be installed?" -> Only on this account
5. Create, then note the App ID shown at the top of the page.
6. Generate a private key (bottom of the page) — a .pem downloads.
7. Install App -> Only select repositories -> $HOURLY_REPO
EOF
read -rp "App ID (numeric): " APP_ID
[[ "$APP_ID" =~ ^[0-9]+$ ]] || fail "App ID must be numeric, got: ${APP_ID:-<empty>}"
KEY_PATH="${1:-}"
if [[ -z "$KEY_PATH" ]]; then
read -rp "Path to the downloaded .pem: " KEY_PATH
fi
# Expand a leading ~ so a pasted path works without quoting rules.
KEY_PATH="${KEY_PATH/#\~/$HOME}"
[[ -r "$KEY_PATH" ]] || fail "Cannot read key file: $KEY_PATH"
grep -q "BEGIN.*PRIVATE KEY" "$KEY_PATH" ||
fail "$KEY_PATH does not look like a PEM private key."
echo "Storing $APP_ID_SECRET in $MAIN_REPO..."
printf '%s' "$APP_ID" | gh secret set "$APP_ID_SECRET" --repo "$MAIN_REPO" ||
fail "Could not set $APP_ID_SECRET."
# Piped on stdin so the key never appears in argv or in shell history.
echo "Storing $APP_KEY_SECRET in $MAIN_REPO..."
gh secret set "$APP_KEY_SECRET" --repo "$MAIN_REPO" <"$KEY_PATH" ||
fail "Could not set $APP_KEY_SECRET."
echo
echo "Done. Both secrets are set on $MAIN_REPO."
echo
echo "Delete your local copy of the key — the workflow reads it from the secret,"
echo "and a .pem sitting in ~/Downloads is a standing credential:"
echo " rm '$KEY_PATH'"
echo
echo "Smoke-test the pipeline without waiting for the hour (after this merges):"
echo " gh workflow run hourly-mac-build.yml --repo $MAIN_REPO -f force=true"
echo " gh run watch --repo $MAIN_REPO"
@@ -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,179 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Worker } from 'node:worker_threads'
const INTERNAL_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_INTERNAL'
const ASAR_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_ASAR'
const TIMEOUT_MS = 100
const CHECK_INTERVAL_MS = 20
const POLL_TIMEOUT_MS = 5_000
const SUCCESS_LINE = '[packaged-watchdog-smoke] app.asar worker detected and recovered a stall'
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
}
function readAppDirArg(argv) {
const explicit = argv.find((arg) => arg.startsWith('--app-dir='))
if (explicit) {
return explicit.slice('--app-dir='.length)
}
if (process.platform === 'darwin') {
return 'dist/mac-arm64/Orca.app'
}
if (process.platform === 'win32') {
return 'dist/win-unpacked'
}
return 'dist/linux-unpacked'
}
function getResourcesDir(appDir) {
return process.platform === 'darwin' || appDir.endsWith('.app')
? join(appDir, 'Contents', 'Resources')
: join(appDir, 'resources')
}
function readMarker(markerPath) {
try {
return JSON.parse(readFileSync(markerPath, 'utf8'))
} catch {
return null
}
}
async function waitForMarker(markerPath, predicate, workerError) {
const deadline = Date.now() + POLL_TIMEOUT_MS
while (Date.now() < deadline) {
if (workerError.current) {
throw workerError.current
}
const marker = readMarker(markerPath)
if (predicate(marker)) {
return marker
}
await sleep(CHECK_INTERVAL_MS)
}
throw new Error(`Timed out waiting for packaged watchdog marker at ${markerPath}`)
}
async function waitForExit(worker, workerError) {
const exitCode = await Promise.race([
new Promise((resolveExit) => worker.once('exit', resolveExit)),
sleep(POLL_TIMEOUT_MS).then(() => {
throw new Error('Timed out waiting for packaged watchdog worker to exit')
})
])
if (workerError.current) {
throw workerError.current
}
if (exitCode !== 0) {
throw new Error(`Packaged watchdog worker exited with code ${exitCode}`)
}
}
async function runInternal() {
const appAsar = process.env[ASAR_ENV]
if (!appAsar) {
throw new Error(`Missing ${ASAR_ENV}`)
}
const { app } = await import('electron')
const tempRoot = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-smoke-'))
const markerPath = join(tempRoot, 'main-thread-hang.json')
const entryPath = join(appAsar, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
let worker
try {
await app.whenReady()
if (!existsSync(entryPath)) {
throw new Error(`Packaged watchdog entry is missing from app.asar: ${entryPath}`)
}
const workerError = { current: null }
worker = new Worker(entryPath, {
workerData: {
parentPid: process.pid,
markerPath,
timeoutMs: TIMEOUT_MS,
checkIntervalMs: CHECK_INTERVAL_MS
}
})
worker.once('error', (error) => {
workerError.current = error
})
await waitForMarker(markerPath, (marker) => marker?.selfRecovered === false, workerError)
worker.postMessage({ type: 'heartbeat' })
await waitForMarker(markerPath, (marker) => marker?.selfRecovered === true, workerError)
worker.postMessage({ type: 'shutdown' })
await waitForExit(worker, workerError)
worker = undefined
console.log(SUCCESS_LINE)
} finally {
await worker?.terminate()
rmSync(tempRoot, { recursive: true, force: true })
}
}
function runSmoke() {
const appDir = resolve(readAppDirArg(process.argv.slice(2)))
const appAsar = join(getResourcesDir(appDir), 'app.asar')
if (!existsSync(appAsar)) {
throw new Error(`Packaged app archive is missing: ${appAsar}`)
}
const require = createRequire(import.meta.url)
const executable = require('electron')
const launcherDir = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-launcher-'))
const launcherPath = join(launcherDir, 'main.cjs')
writeFileSync(
join(launcherDir, 'package.json'),
JSON.stringify({ name: 'orca-packaged-watchdog-smoke', main: 'main.cjs' })
)
writeFileSync(
launcherPath,
`import(${JSON.stringify(pathToFileURL(import.meta.filename).href)}).catch((error) => {
console.error(error)
process.exitCode = 1
})\n`
)
const env = { ...process.env, [INTERNAL_ENV]: '1', [ASAR_ENV]: appAsar, NODE_PATH: '' }
delete env.ELECTRON_RUN_AS_NODE
try {
const electronArgs =
process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]
const result = spawnSync(executable, electronArgs, {
env,
encoding: 'utf8',
timeout: 15_000
})
if (result.status !== 0) {
throw new Error(
`Packaged watchdog smoke failed (${result.error?.message ?? result.signal ?? result.status}):\n` +
`${result.stderr || result.stdout}`
)
}
// Why: Electron discards process.exitCode, so status 0 alone can't prove the worker ran.
if (!result.stdout.includes(SUCCESS_LINE)) {
throw new Error(
`Packaged watchdog smoke did not report success:\n${result.stderr || result.stdout}`
)
}
process.stdout.write(result.stdout)
} finally {
rmSync(launcherDir, { recursive: true, force: true })
}
}
if (process.env[INTERNAL_ENV] === '1') {
// Why: a graceful quit exits 0 regardless of process.exitCode; only app.exit propagates failure.
const { app } = await import('electron')
try {
await runInternal()
app.exit(0)
} catch (error) {
console.error(error)
app.exit(1)
}
} else {
runSmoke()
}
@@ -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)
}
})
})
+1 -2
View File
@@ -12,13 +12,12 @@
"../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",
"../src/main/claude/hook-service.ts",
"../src/main/claude/statusline-script.ts",
"../src/main/claude-accounts/keychain.ts",
"../src/main/codex/codex-app-server-capability-cache.ts",
"../src/main/codex/codex-app-server-capability-signal.ts",
"../src/main/codex/codex-app-server-client.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
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 11m">
<title>downloads: 11m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 12m">
<title>downloads: 12m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">11m</text>
<text x="90" y="14">11m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">12m</text>
<text x="90" y="14">12m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+51 -15
View File
@@ -6,10 +6,17 @@ import tailwindcss from '@tailwindcss/vite'
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
import packageJson from './package.json' with { type: 'json' }
const BUNDLED_MAIN_DEPENDENCIES = new Set(['@xterm/headless', '@xterm/addon-serialize'])
const BUNDLED_MAIN_DEPENDENCIES = new Set([
'@xterm/headless',
'@xterm/addon-serialize',
// Why: Windows NSIS deploys app.asar before external resources; bootstrap must
// not race the later resources/node_modules copy.
'zod'
])
const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter(
(dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency)
)
const BOOTSTRAP_FATAL_EXIT_GUARD_KEY = '__ORCA_BOOTSTRAP_FATAL_EXIT_GUARD__'
function isExternalMainModule(source: string): boolean {
if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) {
@@ -164,19 +171,47 @@ function createStartupDiagnosticsBanner(chunkName: string): string {
`
}
function createStartupDiagnosticsBootstrapPlugin() {
export function createBootstrapFatalExitBanner(): string {
// Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement.
return `
;(() => {
const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)}
if (typeof globalThis[guardKey] === 'function') {
return
}
let exitScheduled = false
const exitAfterBootstrapFailure = () => {
if (exitScheduled) {
return
}
exitScheduled = true
process.exitCode = 1
setImmediate(() => process.exit(1))
}
globalThis[guardKey] = () => {
process.off('uncaughtException', exitAfterBootstrapFailure)
delete globalThis[guardKey]
}
process.once('uncaughtException', exitAfterBootstrapFailure)
})();
`
}
function createMainBootstrapPlugin() {
return {
name: 'orca-startup-diagnostics-bootstrap',
name: 'orca-main-bootstrap',
generateBundle(_options, bundle) {
const mainChunk = bundle['index.js']
if (!mainChunk || mainChunk.type !== 'chunk') {
return
}
// Why: source-level startup diagnostics run after Rollup's generated
// prelude and require() list. Mutate the final emitted chunk so macOS
// launch failures can identify the earliest JS boundary reached.
mainChunk.code = createStartupDiagnosticsBanner(mainChunk.fileName) + mainChunk.code
// Why: source guards and diagnostics run after Rollup's generated require
// prelude, too late to handle a missing bootstrap dependency.
mainChunk.code =
createBootstrapFatalExitBanner() +
createStartupDiagnosticsBanner(mainChunk.fileName) +
mainChunk.code
}
}
}
@@ -186,10 +221,10 @@ export const electronViteConfig: UserConfig = {
build: {
// Why: daemon-entry.js is asar-unpacked so child_process.fork() can
// execute it from disk. Node's module resolution from the unpacked
// directory cannot reach into app.asar, so pure-JS dependencies used
// by the daemon must be bundled rather than externalized.
// directory cannot reach into app.asar; startup-critical pure JS must
// also survive a partially copied Windows resources tree.
externalizeDeps: {
exclude: ['@xterm/headless', '@xterm/addon-serialize']
exclude: [...BUNDLED_MAIN_DEPENDENCIES]
},
rollupOptions: {
// Why: native dependencies must resolve from packaged node_modules,
@@ -208,9 +243,8 @@ export const electronViteConfig: UserConfig = {
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
// Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked
// main thread (macOS 26 AppKit scene-update deadlock) and can record
// the stall for the next launch to report.
// Why: a worker thread survives the macOS 26 AppKit main-thread deadlock
// without paying for another Electron process.
'main-thread-hang-watchdog-entry': resolve(
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
),
@@ -224,7 +258,9 @@ export const electronViteConfig: UserConfig = {
// this path for `orca agent hooks ...`, so it must survive rebuilds.
'agent-hooks/managed-agent-hook-controls': resolve(
'src/main/agent-hooks/managed-agent-hook-controls.ts'
)
),
// 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.
@@ -233,7 +269,7 @@ export const electronViteConfig: UserConfig = {
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js'
},
plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()]
plugins: [createMainBootstrapPlugin(), createPlainNodeEntryGuardPlugin()]
}
},
// Why: compile-time substitution for the telemetry gate. See the block
+97 -95
View File
@@ -114,7 +114,7 @@ import {
loadTerminalAccessoryLayout
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
import {
clearTerminalLiveInputFocusTimer,
focusTerminalLiveInputTarget,
@@ -127,6 +127,11 @@ import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-sen
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate'
import {
buildTerminalSendParams,
TERMINAL_INPUT_SEND_OPTIONS
} from '../../../../src/terminal/terminal-send-request'
import {
getTerminalCommandKeyboardType,
getTerminalLiveInputKeyboardType
@@ -198,6 +203,11 @@ import {
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
import {
createInitialSessionAutoCreateState,
useInitialSessionTerminalAutoCreate,
useWorktreeSessionTabsLoaded
} from '../../../../src/session/use-initial-session-terminal-autocreate'
import {
buildMarkdownDiskFallbackDoc,
shouldReadMarkdownFromDiskAfterReadTabFailure
@@ -239,6 +249,7 @@ import {
getActiveTabIdForHandle,
isFileExistsErrorMessage,
isGestureMouseTrackingMode,
isTerminalPhoneDisplayMode,
MOBILE_SESSION_STATUS_LABELS,
TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY,
TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS,
@@ -890,7 +901,7 @@ export default function SessionScreen() {
const appliedSessionTabsRevisionRef = useRef(0)
// Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back.
const closedTabTombstonesRef = useRef<Map<string, number>>(new Map())
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
const [terminalsLoaded, setTerminalsLoaded] = useWorktreeSessionTabsLoaded(worktreeId)
const [input, setInput] = useState('')
// Why: baseline terminal zoom reloaded on focus so a Settings → Terminal change applies in place (panes stay mounted).
const [terminalTextScale, setTerminalTextScale] = useState(1)
@@ -1032,7 +1043,7 @@ export default function SessionScreen() {
// Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render).
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise<boolean>) | null>(null)
const initialEmptySessionAutoCreateRef = useRef<string | null>(null)
const initialSessionAutoCreateRef = useRef(createInitialSessionAutoCreateState())
const markdownSaveSeqRef = useRef<Map<string, number>>(new Map())
const markdownSaveInFlightRef = useRef<Set<string>>(new Set())
const subscribeSeqRef = useRef<Map<string, number>>(new Map())
@@ -1058,18 +1069,18 @@ export default function SessionScreen() {
activeHandleRef,
activeSessionTabType: activeSessionTab?.type,
activeSessionTabTypeRef,
connected: connState === 'connected',
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
})
const canSend =
connState === 'connected' &&
activeHandle != null &&
activeSessionTab?.type !== 'markdown' &&
activeSessionTab?.type !== 'file' &&
activeSessionTab?.type !== 'browser'
const { canCompose, canSend } = resolveMobileTerminalInputGate({
connState,
activeHandle,
activeSessionTabType: activeSessionTab?.type
})
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
const [browserScreencastSupported, setBrowserScreencastSupported] = useState<boolean | null>(null)
// Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel.
@@ -1774,6 +1785,7 @@ export default function SessionScreen() {
nextTabs = [...orphanedDraftTabs, ...nextTabs]
}
sessionTabsRef.current = nextTabs
initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs))
const terminalTabs = getTerminalRecordsFromSessionTabs(nextTabs)
@@ -2632,7 +2644,7 @@ export default function SessionScreen() {
pendingActiveTerminalHandleRef.current = null
pendingBrowserFocusPageIdRef.current = null
pendingTerminalActivationAttemptRef.current = null
initialEmptySessionAutoCreateRef.current = null
initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState()
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
@@ -2991,7 +3003,8 @@ export default function SessionScreen() {
}, [activeSessionTab, fileDocs, readFileTab])
async function handleSend() {
if (!client || !activeHandle || sendingRef.current) {
// Why: the return key still submits while offline; hold the composed text instead of firing a doomed RPC (#6713).
if (!client || !activeHandle || sendingRef.current || !canSend) {
return
}
sendingRef.current = true
@@ -3000,15 +3013,17 @@ export default function SessionScreen() {
setInput('')
try {
await client.sendRequest('terminal.send', {
terminal: activeHandle,
text,
enter: true,
// Why: presence-lock take-floor; marks this phone active so multi-mobile contention resolves to the last actor.
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
// Why: fail now and restore the text — a send parked across a reconnect would execute long after the tap.
await client.sendRequest(
'terminal.send',
buildTerminalSendParams({
terminal: activeHandle,
text,
enter: true,
deviceToken: deviceTokenRef.current
}),
TERMINAL_INPUT_SEND_OPTIONS
)
} catch {
setInput(text)
} finally {
@@ -3025,29 +3040,15 @@ export default function SessionScreen() {
if (accessoryCommit.kind !== 'allow-raw') {
return
}
const currentClient = clientRef.current
// Why: async IME flushing can outlive the original terminal selection.
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
await sendTerminalLiveAccessoryRawBytes({
client: clientRef.current,
targetHandle,
activeHandle: activeHandleRef.current,
activeSessionTabType: activeSessionTabTypeRef.current
activeSessionTabType: activeSessionTabTypeRef.current,
connState: connStateRef.current,
bytes: input.bytes,
deviceToken: deviceTokenRef.current
})
if (!currentClient || !rawSendTarget || connStateRef.current !== 'connected') {
return
}
await currentClient
.sendRequest('terminal.send', {
terminal: rawSendTarget,
text: input.bytes,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
.then(
() => undefined,
() => undefined
)
}
const sendLiveTerminalInput = useCallback(
@@ -3071,15 +3072,19 @@ export default function SessionScreen() {
) {
return false
}
// Why: live-mirror deltas queued behind a dying send drain into the connect
// wait and replay stale bytes after reconnect (#6713's `YZZYecho …` corruption).
return rpc
.sendRequest('terminal.send', {
terminal: handle,
text,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
.sendRequest(
'terminal.send',
buildTerminalSendParams({
terminal: handle,
text,
enter: false,
deviceToken: deviceTokenRef.current
}),
TERMINAL_INPUT_SEND_OPTIONS
)
.then(isTerminalSendRpcAccepted, () => false)
},
[showToast]
@@ -3341,14 +3346,17 @@ export default function SessionScreen() {
terminalGestureInputInFlightRef.current.add(handle)
try {
await rpc.sendRequest('terminal.send', {
terminal: handle,
text: queued.bytes,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
// Why: gesture arrows parked across a reconnect would move a TUI long after the swipe.
await rpc.sendRequest(
'terminal.send',
buildTerminalSendParams({
terminal: handle,
text: queued.bytes,
enter: false,
deviceToken: deviceTokenRef.current
}),
TERMINAL_INPUT_SEND_OPTIONS
)
} catch {
// Transient failure
} finally {
@@ -3830,14 +3838,15 @@ export default function SessionScreen() {
subscribeToTerminal(createdHandle)
if (options?.initialPrompt?.trim()) {
void client
.sendRequest('terminal.send', {
terminal: createdHandle,
text: options.initialPrompt,
enter: options.enter !== false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
.sendRequest(
'terminal.send',
buildTerminalSendParams({
terminal: createdHandle,
text: options.initialPrompt,
enter: options.enter !== false,
deviceToken: deviceTokenRef.current
})
)
.then((sendResponse) => {
if (!sendResponse.ok) {
throw new Error(
@@ -4161,14 +4170,6 @@ export default function SessionScreen() {
})
const closeWithBulkActions = createCloseWithBulkActions(handleCloseSessionTab, bulkCloseActions)
const isPhoneMode = (handle: string | null): boolean => {
if (!handle) {
return false
}
const mode = terminalModes.get(handle)
return mode === 'auto' || mode === 'phone' || mode === undefined
}
const visibleTabs: MobileSessionTab[] = sessionTabs
const activeMarkdownTab = activeSessionTab?.type === 'markdown' ? activeSessionTab : null
const activeFileTab = activeSessionTab?.type === 'file' ? activeSessionTab : null
@@ -4228,22 +4229,20 @@ export default function SessionScreen() {
const showEmptyState =
connState === 'connected' && terminalsLoaded && visibleTabs.length === 0 && !activeHandle
useEffect(() => {
if (
!client ||
!showEmptyState ||
creating ||
creatingBrowser ||
creatingMarkdown ||
initialEmptySessionAutoCreateRef.current === worktreeId
) {
return
}
// Why: a sleeping/new workspace can hydrate with zero tabs; create the first terminal once so mobile isn't blank.
initialEmptySessionAutoCreateRef.current = worktreeId
setCreateError('')
void handleCreateTerminal()
}, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId])
// Why: a newly created workspace can hydrate with zero tabs before its first terminal exists.
useInitialSessionTerminalAutoCreate({
client,
newlyCreatedWorkspace: created === '1',
connState,
terminalsLoaded,
visibleTabCount: visibleTabs.length,
activeHandle,
createInFlight: creating || creatingBrowser || creatingMarkdown,
stateRef: initialSessionAutoCreateRef,
worktreeId,
consumeCreationRoute: () => router.setParams({ created: undefined }),
createTerminal: () => void handleCreateTerminal()
})
// Why: reconnect trickles to 90s at its give-up cap; surface tap-to-retry so recovery needn't wait it out (issue #5049).
const connectionVerdict = classifyConnection({
@@ -4828,12 +4827,12 @@ export default function SessionScreen() {
}
}}
accessibilityLabel={
isPhoneMode(activeHandle)
isTerminalPhoneDisplayMode(activeHandle, terminalModes)
? 'Switch to desktop mode'
: 'Switch to phone mode'
}
>
{isPhoneMode(activeHandle) ? (
{isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? (
<Monitor
size={14}
color={canSend ? colors.textSecondary : colors.textMuted}
@@ -4850,9 +4849,10 @@ export default function SessionScreen() {
styles.accessoryKey,
liveInputEnabled && styles.accessoryKeyActive,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
!canCompose && styles.accessoryKeyDisabled
]}
disabled={!canSend}
// Why: offline, live mode is dead but the buffered box still composes — keep the escape hatch tappable (#6713).
disabled={!canCompose}
onPress={toggleLiveInput}
accessibilityLabel={
liveInputEnabled
@@ -4865,7 +4865,7 @@ export default function SessionScreen() {
color={
liveInputEnabled
? colors.bgBase
: canSend
: canCompose
? colors.textSecondary
: colors.textMuted
}
@@ -5060,7 +5060,8 @@ export default function SessionScreen() {
autocompleteEnabled
)}
returnKeyType="send"
editable={canSend}
// Why: composing is local — an outage must not lock the field or discard typed text (#6713).
editable={canCompose}
onSubmitEditing={() => void handleSend()}
/>
<MobileTerminalInputActions
@@ -5212,11 +5213,12 @@ export default function SessionScreen() {
nativeChatTranscriptIsLocalReadable,
onDismiss: () => setActionTarget(null),
onToggleChat: toggleTabChatView,
isPhoneMode,
isPhoneMode: (handle) => isTerminalPhoneDisplayMode(handle, terminalModes),
onToggleDisplayMode: (handle) => void toggleDisplayMode(handle),
onRename: setRenameTarget,
onClear: (target) => void handleClearTerminal(target),
onClose: (target) => void handleCloseTerminal(target),
onCloseSessionTab: (tab) => void handleCloseSessionTab(tab),
bulkCloseActions
})}
onClose={() => setActionTarget(null)}
+88 -97
View File
@@ -60,6 +60,13 @@ import {
resolveMobileSyntaxLanguage
} from '../../../src/session/mobile-file-syntax'
import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary'
import { buildGitLabCheckSummary } from '../../../src/tasks/gitlab-check-summary'
import {
getHostedMergeLabel,
getHostedReviewLabel,
getHostedReviewSignalTone,
getHostedChecksLabel
} from '../../../src/tasks/mobile-hosted-check-status'
import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params'
import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability'
import {
@@ -138,11 +145,13 @@ import {
import type {
BaseRefSearchResult,
GitHubOwnerRepo,
ProviderCheckSummary,
PersistedTrustedOrcaHooks,
SparsePreset,
TuiAgent
} from '../../../../src/shared/types'
import type { SshConnectionState } from '../../../../src/shared/ssh-types'
import type { HostedReviewDecision } from '../../../../src/shared/hosted-review'
import {
githubProjectHost,
githubProjectIdentityKey as githubProjectKey
@@ -181,31 +190,20 @@ type GitHubWorkItem = {
reviewDecision?: string | null
reviewRequests?: GitHubAssignableUser[]
latestReviews?: GitHubPRReviewSummary[]
checksSummary?: GitHubPRCheckSummary
checksSummary?: ProviderCheckSummary
mergeable?: GitHubPRMergeableState
mergeStateStatus?: string | null
}
type GitHubAssignableUser = {
login: string
name?: string | null
avatarUrl?: string | null
}
type GitHubPRReviewSummary = {
login: string
state?: string | null
avatarUrl?: string | null
}
type GitHubPRCheckSummary = {
state: 'success' | 'failure' | 'pending' | 'none'
total: number
passed: number
failed: number
pending: number
}
type GitHubPRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
type GitHubPRReviewerRow = {
@@ -214,13 +212,11 @@ type GitHubPRReviewerRow = {
avatarUrl?: string | null
stateLabel: string
}
type GitHubRepoSources = {
issues: GitHubOwnerRepo | null
prs: GitHubOwnerRepo | null
upstreamCandidate: GitHubOwnerRepo | null
}
type TaskRuntimeStatus = {
capabilities?: string[]
}
@@ -229,7 +225,6 @@ type TasksSupportState =
| { kind: 'unknown'; client: RpcClient | null }
| { kind: 'supported'; client: RpcClient }
| { kind: 'unsupported'; client: RpcClient }
type GitLabWorkItem = {
id: string
type: 'issue' | 'mr'
@@ -244,6 +239,10 @@ type GitLabWorkItem = {
baseRefName?: string
isCrossRepository?: boolean
projectRef?: { host: string; path: string }
checksSummary?: ProviderCheckSummary
mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
reviewDecision?: HostedReviewDecision
reviewerCount?: number
repoId: string
repoName: string
}
@@ -1262,23 +1261,6 @@ function hostedBranchSummary(item: TaskItem): { head: string; base: string } | n
return null
}
function getGitHubChecksLabel(item: GitHubWorkItem): string {
const summary = item.checksSummary
if (!summary) {
return 'Checks'
}
if (summary.total === 0) {
return 'No checks'
}
if (summary.failed > 0) {
return `${summary.failed} failing`
}
if (summary.pending > 0) {
return `${summary.pending} pending`
}
return `${summary.passed}/${summary.total} passed`
}
function getGitHubMergeLabel(item: GitHubWorkItem): string {
if (item.mergeable === undefined && item.mergeStateStatus === undefined) {
return 'Merge'
@@ -1382,46 +1364,6 @@ function getHostedStateConfirmLabel(pending: PendingHostedStateChange): string {
return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget}`
}
function getGitHubPRSignalTone(
item: GitHubWorkItem,
signal: 'review' | 'checks' | 'merge'
): 'neutral' | 'success' | 'warning' | 'danger' {
if (signal === 'review') {
if (item.reviewDecision === 'APPROVED') {
return 'success'
}
if (item.reviewDecision === 'CHANGES_REQUESTED') {
return 'danger'
}
if (item.reviewRequests && item.reviewRequests.length > 0) {
return 'warning'
}
return 'neutral'
}
if (signal === 'checks') {
if (item.checksSummary?.state === 'success') {
return 'success'
}
if (item.checksSummary?.state === 'failure') {
return 'danger'
}
if (item.checksSummary?.state === 'pending') {
return 'warning'
}
return 'neutral'
}
if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') {
return 'danger'
}
if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') {
return 'warning'
}
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
return 'success'
}
return 'neutral'
}
function mergeGitHubAssignableUsers(
users: GitHubAssignableUser[],
seeds: GitHubAssignableUser[]
@@ -4345,7 +4287,7 @@ export default function MobileTasksScreen() {
const details = response.result as {
body?: string
comments?: DetailComment[]
item?: { labels?: string[] }
item?: { labels?: string[]; mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' }
assignees?: string[]
pipelineJobs?: Array<{
id?: number
@@ -4355,6 +4297,8 @@ export default function MobileTasksScreen() {
webUrl?: string | null
duration?: number | null
}>
reviewers?: unknown[]
approvalState?: { approvalsRequired: number | null; approvalsLeft: number | null }
} | null
if (!details) {
throw new Error('Details not found')
@@ -4368,6 +4312,44 @@ export default function MobileTasksScreen() {
assignees: details.assignees ?? [],
pipelineJobs: details.pipelineJobs ?? []
})
const checksSummary = buildGitLabCheckSummary(details.pipelineJobs ?? [])
const reviewDecision: Exclude<HostedReviewDecision, null> | undefined =
details.approvalState?.approvalsRequired && details.approvalState.approvalsLeft === 0
? 'approved'
: details.approvalState?.approvalsLeft && details.approvalState.approvalsLeft > 0
? 'review_required'
: undefined
const hydratedStatus = {
...(details.item?.mergeable !== undefined ? { mergeable: details.item.mergeable } : {}),
...(reviewDecision !== undefined ? { reviewDecision } : {}),
...(details.reviewers !== undefined ? { reviewerCount: details.reviewers.length } : {})
}
setActionItem((current) =>
current?.provider === 'gitlab' && current.source.id === actionItem.source.id
? {
...current,
source: {
...current.source,
checksSummary,
...hydratedStatus
}
}
: current
)
setItems((current) =>
current.map((candidate) =>
candidate.provider === 'gitlab' && candidate.source.id === actionItem.source.id
? {
...candidate,
source: {
...candidate.source,
checksSummary,
...hydratedStatus
}
}
: candidate
)
)
}
return
}
@@ -9689,6 +9671,7 @@ export default function MobileTasksScreen() {
const item = entry.item
const repo = taskRepositoryMeta(item, reposById)
const isGitHubPr = item.provider === 'github' && item.source.type === 'pr'
const isGitLabMr = item.provider === 'gitlab' && item.source.type === 'mr'
const githubPrDelta = isGitHubPr ? formatGitHubPRDelta(item.source) : null
const branchSummary = hostedBranchSummary(item)
return (
@@ -9734,45 +9717,53 @@ export default function MobileTasksScreen() {
</Text>
</View>
) : null}
{isGitHubPr ? (
{isGitHubPr || isGitLabMr ? (
<View style={styles.prSignalRow}>
{githubPrDelta ? (
{isGitHubPr && githubPrDelta ? (
<View style={styles.prSignalChip}>
<Text style={styles.prSignalText} numberOfLines={1}>
{githubPrDelta}
</Text>
</View>
) : null}
{isGitHubPr || isGitLabMr ? (
<View
style={[
styles.prSignalChip,
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'review'))
]}
>
<Text style={styles.prSignalText} numberOfLines={1}>
{isGitHubPr
? getGitHubReviewSummary(item.source)
: getHostedReviewLabel(item.source)}
</Text>
</View>
) : null}
<View
style={[
styles.prSignalChip,
getPrSignalToneStyle(getGitHubPRSignalTone(item.source, 'review'))
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'checks'))
]}
>
<Text style={styles.prSignalText} numberOfLines={1}>
{getGitHubReviewSummary(item.source)}
</Text>
</View>
<View
style={[
styles.prSignalChip,
getPrSignalToneStyle(getGitHubPRSignalTone(item.source, 'checks'))
]}
>
<Text style={styles.prSignalText} numberOfLines={1}>
{getGitHubChecksLabel(item.source)}
</Text>
</View>
<View
style={[
styles.prSignalChip,
getPrSignalToneStyle(getGitHubPRSignalTone(item.source, 'merge'))
]}
>
<Text style={styles.prSignalText} numberOfLines={1}>
{getGitHubMergeLabel(item.source)}
{getHostedChecksLabel(item.source)}
</Text>
</View>
{isGitHubPr || isGitLabMr ? (
<View
style={[
styles.prSignalChip,
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'merge'))
]}
>
<Text style={styles.prSignalText} numberOfLines={1}>
{isGitHubPr
? getGitHubMergeLabel(item.source)
: getHostedMergeLabel(item.source)}
</Text>
</View>
) : null}
</View>
) : null}
</View>
+11 -4
View File
@@ -1,7 +1,7 @@
import type {
CheckStatus,
GitHubAssignableUser,
GitHubPRCheckSummary,
ProviderCheckSummary,
GitHubPRMergeMethod,
GitHubPRMergeMethodSettings,
GitHubPRReviewSummary,
@@ -182,12 +182,18 @@ export function readMergeMethodSettings(value: unknown): GitHubPRMergeMethodSett
}
}
export function readCheckSummary(value: unknown): GitHubPRCheckSummary | undefined {
export function readCheckSummary(value: unknown): ProviderCheckSummary | undefined {
if (!isRecord(value)) {
return undefined
}
const state = value.state
if (state !== 'success' && state !== 'failure' && state !== 'pending' && state !== 'none') {
if (
state !== 'success' &&
state !== 'failure' &&
state !== 'pending' &&
state !== 'neutral' &&
state !== 'none'
) {
return undefined
}
return {
@@ -195,6 +201,7 @@ export function readCheckSummary(value: unknown): GitHubPRCheckSummary | undefin
total: readNumber(value.total) ?? 0,
passed: readNumber(value.passed) ?? 0,
failed: readNumber(value.failed) ?? 0,
pending: readNumber(value.pending) ?? 0
pending: readNumber(value.pending) ?? 0,
neutral: readNumber(value.neutral) ?? 0
}
}
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import {
shouldAutoCreateInitialSessionTerminal,
type InitialSessionTerminalAutoCreateInput
} from './initial-session-terminal'
function baseInput(
overrides: Partial<InitialSessionTerminalAutoCreateInput> = {}
): InitialSessionTerminalAutoCreateInput {
return {
newlyCreatedWorkspace: true,
connected: true,
tabsLoaded: true,
visibleTabCount: 0,
hasActiveTerminalHandle: false,
createInFlight: false,
sawSessionTabs: false,
autoCreatedForWorktree: false,
...overrides
}
}
/**
* Replays the route state the session screen actually holds
* (`app/h/[hostId]/session/[worktreeId].tsx`): `applySessionTabs` publishes the
* tab list and flips `terminalsLoaded`, `handleCloseSessionTab` prunes the
* closed tab and nulls the active handle.
*/
class SessionRouteState {
newlyCreatedWorkspace = true
tabsLoaded = false
tabIds: string[] = []
activeHandle: string | null = null
sawSessionTabs = false
autoCreatedForWorktree = false
applySessionTabs(tabIds: string[], activeHandle: string | null = tabIds[0] ? 'pty-1' : null) {
this.tabIds = tabIds
this.activeHandle = activeHandle
if (tabIds.length > 0) {
this.sawSessionTabs = true
}
this.tabsLoaded = true
}
closeSessionTab(tabId: string) {
this.tabIds = this.tabIds.filter((candidate) => candidate !== tabId)
this.activeHandle = null
}
gate(): boolean {
return shouldAutoCreateInitialSessionTerminal({
newlyCreatedWorkspace: this.newlyCreatedWorkspace,
connected: true,
tabsLoaded: this.tabsLoaded,
visibleTabCount: this.tabIds.length,
hasActiveTerminalHandle: this.activeHandle !== null,
createInFlight: false,
sawSessionTabs: this.sawSessionTabs,
autoCreatedForWorktree: this.autoCreatedForWorktree
})
}
}
describe('shouldAutoCreateInitialSessionTerminal', () => {
it('creates the first terminal for a workspace that hydrates with nothing', () => {
const route = new SessionRouteState()
route.applySessionTabs([])
expect(route.gate()).toBe(true)
})
it('does not re-create after the user closes the last tab (#9717, #7345)', () => {
const route = new SessionRouteState()
route.applySessionTabs(['tab-1'])
expect(route.gate()).toBe(false)
route.closeSessionTab('tab-1')
expect(route.tabIds).toHaveLength(0)
expect(route.activeHandle).toBeNull()
// Emptiness that follows a populated list is a close, not a cold hydrate.
expect(route.gate()).toBe(false)
})
it('does not re-create after the consumed creation route remounts empty', () => {
const creationRoute = new SessionRouteState()
creationRoute.applySessionTabs([])
expect(creationRoute.gate()).toBe(true)
creationRoute.newlyCreatedWorkspace = false
creationRoute.autoCreatedForWorktree = true
creationRoute.applySessionTabs(['tab-1'])
creationRoute.closeSessionTab('tab-1')
expect(creationRoute.gate()).toBe(false)
const reopenedRoute = new SessionRouteState()
reopenedRoute.newlyCreatedWorkspace = creationRoute.newlyCreatedWorkspace
reopenedRoute.applySessionTabs([])
expect(reopenedRoute.gate()).toBe(false)
})
it('does not create from stale empty state while switching ordinary workspace routes', () => {
expect(
shouldAutoCreateInitialSessionTerminal(
baseInput({
newlyCreatedWorkspace: false,
tabsLoaded: true,
visibleTabCount: 0
})
)
).toBe(false)
})
it('does not re-create when the host drops every tab mid-session', () => {
const route = new SessionRouteState()
route.applySessionTabs(['tab-1', 'tab-2'])
route.applySessionTabs([], null)
expect(route.gate()).toBe(false)
})
it('stays armed while an empty workspace is still loading', () => {
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ tabsLoaded: false }))).toBe(false)
})
it('waits for the host connection', () => {
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ connected: false }))).toBe(false)
})
it('defers to a lagging snapshot that still has a streaming terminal', () => {
expect(
shouldAutoCreateInitialSessionTerminal(baseInput({ hasActiveTerminalHandle: true }))
).toBe(false)
})
it('does not stack a second create on an in-flight one', () => {
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ createInFlight: true }))).toBe(false)
})
it('fires at most once per workspace on this route', () => {
expect(
shouldAutoCreateInitialSessionTerminal(baseInput({ autoCreatedForWorktree: true }))
).toBe(false)
})
it('does not fire while tabs are visible', () => {
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ visibleTabCount: 2 }))).toBe(false)
})
})
@@ -0,0 +1,46 @@
// Mirrors the desktop `shouldAutoCreateInitialTerminal` gate: a newly created
// workspace that hydrates empty gets one terminal so the session isn't blank.
// Kept pure (no react-native imports) so the gate is unit-testable.
export type InitialSessionTerminalAutoCreateInput = {
/** Navigation came directly from creating this workspace. */
newlyCreatedWorkspace: boolean
/** Host connection is up and an RPC client is attached. */
connected: boolean
/** At least one session-tab snapshot has been applied on this route. */
tabsLoaded: boolean
/** Session tabs currently rendered on the phone. */
visibleTabCount: number
/** A terminal is still streaming even though the tab list reads empty. */
hasActiveTerminalHandle: boolean
/** A terminal, browser, or markdown create is already in flight. */
createInFlight: boolean
/** This route has shown at least one session tab for this workspace. */
sawSessionTabs: boolean
/** This route already auto-created a terminal for this workspace. */
autoCreatedForWorktree: boolean
}
/**
* Whether to auto-create the first terminal of a newly created mobile session.
*
* `sawSessionTabs` is the resurrection guard: emptiness that *follows* a
* populated tab list was produced by a close (the user's, or the host dropping
* a dead pane), and re-creating there spawns a brand-new terminal the user
* never asked for — issues #9717 / #7345. Only a workspace that has shown
* nothing since its creation route opened is eligible.
*/
export function shouldAutoCreateInitialSessionTerminal(
input: InitialSessionTerminalAutoCreateInput
): boolean {
if (!input.newlyCreatedWorkspace || !input.connected || !input.tabsLoaded) {
return false
}
if (input.visibleTabCount > 0 || input.hasActiveTerminalHandle) {
return false
}
if (input.createInFlight || input.autoCreatedForWorktree) {
return false
}
return !input.sawSessionTabs
}
@@ -10,6 +10,8 @@ function extname(filePath: string): string {
const EXT_TO_LANGUAGE: Record<string, string> = {
'.ts': 'typescript',
'.tsx': 'typescript',
'.cts': 'typescript',
'.mts': 'typescript',
'.js': 'javascript',
'.jsx': 'javascript',
'.mjs': 'javascript',
@@ -11,9 +11,12 @@ import type { MobileDiffLine } from './mobile-diff-lines'
describe('mobile file syntax highlighting', () => {
it('detects common source languages from file paths', () => {
expect(detectMobileFileLanguage('src/App.tsx')).toBe('typescript')
expect(detectMobileFileLanguage('config/vitest.config.mts')).toBe('typescript')
expect(detectMobileFileLanguage('C:\\repo\\scripts\\postinstall.CTS')).toBe('typescript')
expect(detectMobileFileLanguage('scripts/deploy.sh')).toBe('shell')
expect(detectMobileFileLanguage('Dockerfile')).toBe('dockerfile')
expect(resolveMobileSyntaxLanguage('src/App.tsx')).toBe('typescript')
expect(resolveMobileSyntaxLanguage('worktrees/feature/build.cts')).toBe('typescript')
expect(resolveMobileSyntaxLanguage('Dockerfile')).toBe('plaintext')
})
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
describe('isTerminalPhoneDisplayMode', () => {
it('uses phone mode for automatic, phone, and unreported terminals', () => {
const modes = new Map([
['auto', 'auto'],
['phone', 'phone']
] as const)
expect(isTerminalPhoneDisplayMode('auto', modes)).toBe(true)
expect(isTerminalPhoneDisplayMode('phone', modes)).toBe(true)
expect(isTerminalPhoneDisplayMode('missing', modes)).toBe(true)
})
it('rejects absent handles and desktop terminals', () => {
const modes = new Map([['desktop', 'desktop']] as const)
expect(isTerminalPhoneDisplayMode(null, modes)).toBe(false)
expect(isTerminalPhoneDisplayMode('desktop', modes)).toBe(false)
})
})
@@ -34,6 +34,17 @@ export function isGestureMouseTrackingMode(
return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'
}
export function isTerminalPhoneDisplayMode(
handle: string | null,
terminalModes: ReadonlyMap<string, 'auto' | 'phone' | 'desktop'>
): boolean {
if (!handle) {
return false
}
const mode = terminalModes.get(handle)
return mode === undefined || mode === 'auto' || mode === 'phone'
}
export function getActiveTabIdForHandle(
tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>,
terminalHandle: string | null
@@ -9,6 +9,10 @@ const reconciliationHookSource = readFileSync(
new URL('./use-mobile-session-tabs-reconciliation.ts', import.meta.url),
'utf8'
)
const autoCreateHookSource = readFileSync(
new URL('./use-initial-session-terminal-autocreate.ts', import.meta.url),
'utf8'
)
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
@@ -19,18 +23,49 @@ function sliceBetween(startPattern: string, endPattern: string): string {
}
describe('mobile session startup', () => {
it('auto-creates one terminal for an initially empty connected session', () => {
expect(source).toContain('const initialEmptySessionAutoCreateRef = useRef<string | null>(null)')
expect(source).toContain('initialEmptySessionAutoCreateRef.current = null')
const autoCreateEffect = sliceBetween(
'if (\n !client ||\n !showEmptyState',
'const terminalSummary ='
it('auto-creates one terminal for a newly created empty session', () => {
expect(source).toContain('useWorktreeSessionTabsLoaded(worktreeId)')
expect(source).toContain(
'initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState()'
)
expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current === worktreeId')
expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current = worktreeId')
expect(autoCreateEffect).toContain("setCreateError('')")
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
const autoCreateCall = sliceBetween(
'useInitialSessionTerminalAutoCreate({',
'const connectionVerdict ='
)
expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef')
expect(autoCreateCall).toContain(
'consumeCreationRoute: () => router.setParams({ created: undefined })'
)
expect(autoCreateCall).toContain("newlyCreatedWorkspace: created === '1'")
expect(autoCreateCall).toContain('visibleTabCount: visibleTabs.length')
expect(autoCreateCall).toContain('createTerminal: () => void handleCreateTerminal()')
expect(autoCreateHookSource).toContain('shouldAutoCreateInitialSessionTerminal({')
expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree === worktreeId')
expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree = worktreeId')
expect(autoCreateHookSource).toContain("connState === 'connected'")
expect(autoCreateHookSource).toContain('(visibleTabCount > 0 || activeHandle !== null)')
// Why: both callbacks are re-created every render, so the effect must reach them
// through useEffectEvent rather than deps or a render-time ref write.
expect(autoCreateHookSource).toContain('useEffectEvent(args.consumeCreationRoute)')
expect(autoCreateHookSource).toContain('useEffectEvent(args.createTerminal)')
expect(autoCreateHookSource).toContain('consumeCreationRoute()')
expect(autoCreateHookSource).toContain('createTerminal()')
})
it('arms the auto-create only until the route has published a tab (#9717)', () => {
// Emptiness after a populated list is a close, not a cold hydrate.
expect(source).toContain(
'initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0'
)
const autoCreateCall = sliceBetween(
'useInitialSessionTerminalAutoCreate({',
'const connectionVerdict ='
)
expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef')
expect(autoCreateHookSource).toContain('sawSessionTabs: stateRef.current.sawSessionTabs')
})
it('delegates stream ownership while retaining the exact terminal polling cadence', () => {
@@ -9,24 +9,38 @@ vi.mock('lucide-react-native', () => ({
SquareTerminal: vi.fn()
}))
type SheetArgs = Parameters<typeof getMobileTerminalActionSheetActions>[0]
function buildActions(overrides: Partial<SheetArgs> = {}) {
return getMobileTerminalActionSheetActions({
target: { handle: 'terminal-1' },
tabs: [],
isTabChatView: () => false,
nativeChatTranscriptIsLocalReadable: true,
onDismiss: vi.fn(),
onToggleChat: vi.fn(),
isPhoneMode: () => false,
onToggleDisplayMode: vi.fn(),
onRename: vi.fn(),
onClear: vi.fn(),
onClose: vi.fn(),
onCloseSessionTab: vi.fn(),
...overrides
})
}
const terminalTab = (id: string, handle: string | null) => ({
type: 'terminal',
id,
terminal: handle
})
describe('getMobileTerminalActionSheetActions', () => {
it('defers Rename until after the action sheet closes', () => {
const target = { handle: 'terminal-1' }
const onDismiss = vi.fn()
const onRename = vi.fn()
const actions = getMobileTerminalActionSheetActions({
target,
tabs: [],
isTabChatView: () => false,
nativeChatTranscriptIsLocalReadable: true,
onDismiss,
onToggleChat: vi.fn(),
isPhoneMode: () => false,
onToggleDisplayMode: vi.fn(),
onRename,
onClear: vi.fn(),
onClose: vi.fn()
})
const actions = buildActions({ target, onDismiss, onRename })
const rename = actions.find((action) => action.label === 'Rename')
expect(rename).toMatchObject({ closeBeforePress: true })
@@ -35,4 +49,51 @@ describe('getMobileTerminalActionSheetActions', () => {
expect(onRename).toHaveBeenCalledWith(target)
expect(onDismiss).not.toHaveBeenCalled()
})
// Why: handle-only close lets the pending-terminal effect resurrect the tab (#6927, #7345).
it('closes the handle through its session tab, not the handle-only path', () => {
const onClose = vi.fn()
const onCloseSessionTab = vi.fn()
const tab = terminalTab('tab-1::leaf-1', 'terminal-1')
const actions = buildActions({
target: { handle: 'terminal-1' },
tabs: [tab],
onClose,
onCloseSessionTab
})
actions.find((action) => action.label === 'Close')?.onPress()
expect(onCloseSessionTab).toHaveBeenCalledWith(tab)
expect(onClose).not.toHaveBeenCalled()
})
it('closes the split leaf that owns the handle, not its sibling', () => {
const onCloseSessionTab = vi.fn()
const first = terminalTab('tab-1::leaf-1', 'terminal-1')
const second = terminalTab('tab-1::leaf-2', 'terminal-2')
const actions = buildActions({
target: { handle: 'terminal-2' },
tabs: [first, second],
onCloseSessionTab
})
actions.find((action) => action.label === 'Close')?.onPress()
expect(onCloseSessionTab).toHaveBeenCalledWith(second)
})
it('falls back to the handle close when no session tab owns the handle', () => {
const onClose = vi.fn()
const onCloseSessionTab = vi.fn()
const target = { handle: 'terminal-9' }
const actions = buildActions({
target,
tabs: [terminalTab('tab-1::leaf-1', 'terminal-1'), terminalTab('tab-2::leaf-1', null)],
onClose,
onCloseSessionTab
})
actions.find((action) => action.label === 'Close')?.onPress()
expect(onClose).toHaveBeenCalledWith(target)
expect(onCloseSessionTab).not.toHaveBeenCalled()
})
})
@@ -7,9 +7,12 @@ type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null }
/** Builds the terminal long-press menu without adding another action block to the
* already dense session route. Native chat stays first as the view switch. */
export function getMobileTerminalActionSheetActions<Target extends { handle: string }>(args: {
export function getMobileTerminalActionSheetActions<
Target extends { handle: string },
Tab extends TerminalTab
>(args: {
target: Target | null
tabs: readonly TerminalTab[]
tabs: readonly Tab[]
isTabChatView: (tabId: string) => boolean
nativeChatTranscriptIsLocalReadable: boolean
onDismiss: () => void
@@ -18,7 +21,10 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
onToggleDisplayMode: (handle: string) => void
onRename: (target: Target) => void
onClear: (target: Target) => void
/** Fallback for a live handle with no matching session tab. */
onClose: (target: Target) => void
/** Preferred path runs host teardown and records the local tombstone. */
onCloseSessionTab: (tab: Tab) => void
/** Appended after Close; receives the pressed tab's id so the session route's
* bulk-close builder can resolve the anchor itself. */
bulkCloseActions?: (anchorTabId: string | undefined, dismiss: () => void) => ActionSheetAction[]
@@ -28,6 +34,7 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
return []
}
const phoneMode = args.isPhoneMode(target.handle)
const sessionTab = args.tabs.find((tab) => tab.terminal === target.handle)
return [
...getMobileNativeChatToggleActions({
terminalHandle: target.handle,
@@ -65,12 +72,13 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
destructive: true,
onPress: () => {
args.onDismiss()
if (sessionTab) {
args.onCloseSessionTab(sessionTab)
return
}
args.onClose(target)
}
},
...(args.bulkCloseActions?.(
args.tabs.find((tab) => tab.terminal === target.handle)?.id,
args.onDismiss
) ?? [])
...(args.bulkCloseActions?.(sessionTab?.id, args.onDismiss) ?? [])
]
}
@@ -0,0 +1,189 @@
import { createElement, type RefObject } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
useInitialSessionTerminalAutoCreate,
useWorktreeSessionTabsLoaded
} from './use-initial-session-terminal-autocreate'
type HarnessProps = {
newlyCreatedWorkspace: boolean
terminalsLoaded: boolean
visibleTabCount: number
worktreeId: string
connected?: boolean
hasClient?: boolean
}
describe('useInitialSessionTerminalAutoCreate', () => {
let renderer: ReactTestRenderer | null = null
let stateRef: RefObject<{
autoCreatedForWorktree: string | null
sawSessionTabs: boolean
}>
const consumeCreationRoute = vi.fn()
const createTerminal = vi.fn()
function Harness(props: HarnessProps): null {
useInitialSessionTerminalAutoCreate({
client: props.hasClient === false ? null : {},
newlyCreatedWorkspace: props.newlyCreatedWorkspace,
connState: props.connected === false ? 'disconnected' : 'connected',
terminalsLoaded: props.terminalsLoaded,
visibleTabCount: props.visibleTabCount,
activeHandle: null,
createInFlight: false,
stateRef,
worktreeId: props.worktreeId,
consumeCreationRoute,
createTerminal
})
return null
}
async function render(props: HarnessProps): Promise<void> {
await act(async () => {
if (renderer) {
renderer.update(createElement(Harness, props))
} else {
renderer = create(createElement(Harness, props))
}
})
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
stateRef = {
current: { autoCreatedForWorktree: null, sawSessionTabs: false }
}
consumeCreationRoute.mockClear()
createTerminal.mockClear()
vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
throw new Error(String(args[0]))
}
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
})
it('waits for the new worktree snapshot after route reuse', async () => {
await render({
newlyCreatedWorkspace: false,
terminalsLoaded: true,
visibleTabCount: 0,
worktreeId: 'existing'
})
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: false,
visibleTabCount: 0,
worktreeId: 'new'
})
expect(consumeCreationRoute).not.toHaveBeenCalled()
expect(createTerminal).not.toHaveBeenCalled()
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: true,
visibleTabCount: 0,
worktreeId: 'new'
})
expect(consumeCreationRoute).not.toHaveBeenCalled()
expect(createTerminal).toHaveBeenCalledOnce()
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: true,
visibleTabCount: 1,
worktreeId: 'new'
})
expect(consumeCreationRoute).toHaveBeenCalledOnce()
expect(createTerminal).toHaveBeenCalledOnce()
})
it('consumes a populated creation route before a later remount', async () => {
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: true,
visibleTabCount: 1,
worktreeId: 'new'
})
expect(consumeCreationRoute).toHaveBeenCalledOnce()
expect(createTerminal).not.toHaveBeenCalled()
act(() => renderer?.unmount())
renderer = null
stateRef = {
current: { autoCreatedForWorktree: null, sawSessionTabs: false }
}
await render({
newlyCreatedWorkspace: false,
terminalsLoaded: true,
visibleTabCount: 0,
worktreeId: 'new'
})
expect(createTerminal).not.toHaveBeenCalled()
})
it.each([
{ connected: false, hasClient: true },
{ connected: true, hasClient: false }
])(
'keeps the creation route armed until a client reconnects (connected=$connected, hasClient=$hasClient)',
async ({ connected, hasClient }) => {
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: true,
visibleTabCount: 0,
worktreeId: 'new',
connected,
hasClient
})
expect(consumeCreationRoute).not.toHaveBeenCalled()
expect(createTerminal).not.toHaveBeenCalled()
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: true,
visibleTabCount: 0,
worktreeId: 'new'
})
expect(consumeCreationRoute).not.toHaveBeenCalled()
expect(createTerminal).toHaveBeenCalledOnce()
}
)
})
describe('useWorktreeSessionTabsLoaded', () => {
let renderer: ReactTestRenderer | null = null
let current: readonly [boolean, (loaded: boolean) => void] | null = null
function Harness({ worktreeId }: { worktreeId: string }): null {
current = useWorktreeSessionTabsLoaded(worktreeId)
return null
}
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
current = null
})
it('does not carry a loaded snapshot across a reused route', async () => {
await act(async () => {
renderer = create(createElement(Harness, { worktreeId: 'existing' }))
})
act(() => current?.[1](true))
expect(current?.[0]).toBe(true)
await act(async () => {
renderer?.update(createElement(Harness, { worktreeId: 'new' }))
})
expect(current?.[0]).toBe(false)
})
})
@@ -0,0 +1,106 @@
import { useEffect, useEffectEvent, useReducer, type RefObject } from 'react'
import { shouldAutoCreateInitialSessionTerminal } from './initial-session-terminal'
type InitialSessionTerminalAutoCreateState = {
autoCreatedForWorktree: string | null
sawSessionTabs: boolean
}
/**
* Fresh auto-create bookkeeping. The route must re-create this on every worktree
* change, or a revisit inherits the previous workspace's `sawSessionTabs`.
*/
export function createInitialSessionAutoCreateState(): InitialSessionTerminalAutoCreateState {
return { autoCreatedForWorktree: null, sawSessionTabs: false }
}
type InitialSessionTerminalAutoCreateArgs = {
client: unknown
newlyCreatedWorkspace: boolean
connState: string
terminalsLoaded: boolean
visibleTabCount: number
activeHandle: string | null
createInFlight: boolean
stateRef: RefObject<InitialSessionTerminalAutoCreateState>
worktreeId: string
consumeCreationRoute: () => void
createTerminal: () => void
}
/**
* Creates the first terminal of a newly created mobile workspace that hydrates
* empty, at most once per route. See initial-session-terminal.
*/
export function useInitialSessionTerminalAutoCreate(
args: InitialSessionTerminalAutoCreateArgs
): void {
const {
client,
newlyCreatedWorkspace,
connState,
terminalsLoaded,
visibleTabCount,
activeHandle,
createInFlight,
stateRef,
worktreeId
} = args
// Why: the route re-creates both callbacks every render; useEffectEvent keeps them
// out of the deps without mutating a ref during render.
const consumeCreationRoute = useEffectEvent(args.consumeCreationRoute)
const createTerminal = useEffectEvent(args.createTerminal)
useEffect(() => {
if (
newlyCreatedWorkspace &&
client &&
connState === 'connected' &&
terminalsLoaded &&
(visibleTabCount > 0 || activeHandle !== null)
) {
consumeCreationRoute()
}
if (
!client ||
!shouldAutoCreateInitialSessionTerminal({
newlyCreatedWorkspace,
connected: connState === 'connected',
tabsLoaded: terminalsLoaded,
visibleTabCount,
hasActiveTerminalHandle: activeHandle !== null,
createInFlight,
sawSessionTabs: stateRef.current.sawSessionTabs,
autoCreatedForWorktree: stateRef.current.autoCreatedForWorktree === worktreeId
})
) {
return
}
stateRef.current.autoCreatedForWorktree = worktreeId
createTerminal()
}, [
activeHandle,
client,
connState,
createInFlight,
newlyCreatedWorkspace,
stateRef,
terminalsLoaded,
visibleTabCount,
worktreeId
])
}
/**
* Tracks "tabs hydrated" per worktree so a reused route reports `false` until the
* new workspace's own snapshot lands, rather than inheriting the old one's.
*/
export function useWorktreeSessionTabsLoaded(
worktreeId: string
): readonly [boolean, (loaded: boolean) => void] {
const [loadedForWorktree, setLoaded] = useReducer(
(_current: string | null, loaded: boolean) => (loaded ? worktreeId : null),
null
)
return [loadedForWorktree === worktreeId, setLoaded]
}
+21 -6
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { buildGitHubCheckSummary } from './github-check-summary'
import { buildGitLabCheckSummary } from './gitlab-check-summary'
describe('buildGitHubCheckSummary', () => {
it('returns none for empty check lists', () => {
@@ -8,7 +9,8 @@ describe('buildGitHubCheckSummary', () => {
total: 0,
passed: 0,
failed: 0,
pending: 0
pending: 0,
neutral: 0
})
})
@@ -24,22 +26,35 @@ describe('buildGitHubCheckSummary', () => {
total: 3,
passed: 1,
failed: 1,
pending: 1
pending: 1,
neutral: 0
})
})
it('marks all completed non-failing checks as successful', () => {
it('keeps neutral and unknown terminal conclusions out of passed', () => {
expect(
buildGitHubCheckSummary([
{ status: 'completed', conclusion: 'success' },
{ status: 'completed', conclusion: 'neutral' }
])
).toEqual({
state: 'success',
state: 'neutral',
total: 2,
passed: 2,
passed: 1,
failed: 0,
pending: 0
pending: 0,
neutral: 1
})
})
it('rolls up GitLab jobs with unknown terminal statuses as neutral', () => {
expect(buildGitLabCheckSummary([{ status: 'success' }, { status: 'future_status' }])).toEqual({
state: 'neutral',
total: 2,
passed: 1,
failed: 0,
pending: 0,
neutral: 1
})
})
})
+20 -5
View File
@@ -4,11 +4,12 @@ export type GitHubCheckLike = {
}
export type GitHubCheckSummary = {
state: 'success' | 'failure' | 'pending' | 'none'
state: 'success' | 'failure' | 'pending' | 'neutral' | 'none'
total: number
passed: number
failed: number
pending: number
neutral: number
}
function isFailedCheck(check: GitHubCheckLike): boolean {
@@ -28,18 +29,32 @@ function isPendingCheck(check: GitHubCheckLike): boolean {
export function buildGitHubCheckSummary(checks: GitHubCheckLike[]): GitHubCheckSummary {
let failed = 0
let pending = 0
let neutral = 0
for (const check of checks) {
if (isFailedCheck(check)) {
if (isFailedCheck(check) || check.conclusion === 'action_required') {
failed += 1
} else if (isPendingCheck(check)) {
pending += 1
} else if (check.conclusion === 'success') {
continue
} else {
neutral += 1
}
}
const total = checks.length
const passed = Math.max(0, total - failed - pending)
const state = total === 0 ? 'none' : failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success'
const passed = total - failed - pending - neutral
const state =
total === 0
? 'none'
: failed > 0
? 'failure'
: pending > 0
? 'pending'
: neutral > 0
? 'neutral'
: 'success'
return { state, total, passed, failed, pending }
return { state, total, passed, failed, pending, neutral }
}
+37
View File
@@ -0,0 +1,37 @@
import { buildGitHubCheckSummary, type GitHubCheckLike } from './github-check-summary'
import type { ProviderCheckSummary } from '../../../src/shared/types'
type GitLabPipelineJobLike = { status: string }
export function buildGitLabCheckSummary(jobs: GitLabPipelineJobLike[]): ProviderCheckSummary {
return buildGitHubCheckSummary(
jobs.map((job): GitHubCheckLike => {
const status = job.status.toLowerCase()
const nonterminal = [
'created',
'pending',
'scheduled',
'waiting_for_callback',
'waiting_for_resource',
'preparing'
].includes(status)
return {
status: status === 'running' ? 'in_progress' : nonterminal ? 'queued' : 'completed',
conclusion:
status === 'success'
? 'success'
: status === 'failed'
? 'failure'
: ['canceled', 'canceling'].includes(status)
? 'cancelled'
: status === 'skipped'
? 'skipped'
: ['manual', 'action_required'].includes(status)
? 'action_required'
: status === 'running' || nonterminal
? 'pending'
: null
}
})
)
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import type { HostedReviewDecision } from '../../../src/shared/hosted-review'
import {
getHostedChecksLabel,
getHostedReviewLabel,
getHostedReviewSignalTone
} from './mobile-hosted-check-status'
describe('mobile hosted check status', () => {
it('renders a hydrated neutral GitLab summary as unresolved checks', () => {
const summary = {
state: 'neutral' as const,
total: 2,
passed: 1,
failed: 0,
pending: 0,
neutral: 1
}
expect(getHostedChecksLabel({ checksSummary: summary })).toBe('Unresolved checks')
expect(getHostedReviewSignalTone({ checksSummary: summary }, 'checks')).toBe('neutral')
})
it('accepts provider-neutral GitHub status fields without a shared work-item type', () => {
expect(
getHostedReviewSignalTone(
{ reviewDecision: 'approved', reviewRequests: [], mergeable: 'UNKNOWN' },
'review'
)
).toBe('success')
})
it('renders hydrated GitLab approval, merge, and check states', () => {
expect(getHostedReviewLabel({ reviewDecision: 'review_required', reviewerCount: 2 })).toBe(
'Review required'
)
expect(
getHostedReviewSignalTone(
{
reviewDecision: 'review_required',
reviewerCount: 2,
mergeable: 'MERGEABLE',
checksSummary: {
state: 'success',
total: 1,
passed: 1,
failed: 0,
pending: 0,
neutral: 0
}
},
'review'
)
).toBe('warning')
expect(getHostedReviewSignalTone({ mergeable: 'MERGEABLE' }, 'merge')).toBe('success')
expect(getHostedReviewSignalTone({ reviewDecision: 'approved' }, 'review')).toBe('success')
expect(getHostedReviewSignalTone({ mergeable: 'CONFLICTING' }, 'merge')).toBe('danger')
})
it('renders a shared typed GitLab review decision', () => {
const reviewDecision: HostedReviewDecision = 'changes_requested'
expect(getHostedReviewLabel({ reviewDecision })).toBe('Changes requested')
expect(getHostedReviewSignalTone({ reviewDecision }, 'review')).toBe('danger')
})
it('keeps missing GitLab enrichment neutral', () => {
expect(getHostedReviewLabel({})).toBe('No reviewers')
expect(getHostedReviewSignalTone({}, 'review')).toBe('neutral')
expect(getHostedReviewSignalTone({}, 'merge')).toBe('neutral')
})
})
@@ -0,0 +1,106 @@
import type { ProviderCheckSummary, PRMergeableState } from '../../../src/shared/types'
export type MobileHostedReviewStatus = {
checksSummary?: ProviderCheckSummary
reviewDecision?: string | null
reviewRequests?: readonly unknown[]
reviewerCount?: number
mergeable?: PRMergeableState
mergeStateStatus?: string | null
}
export function getHostedReviewLabel(item: MobileHostedReviewStatus): string {
if (item.reviewDecision === 'approved' || item.reviewDecision === 'APPROVED') {
return 'Approved'
}
if (item.reviewDecision === 'changes_requested' || item.reviewDecision === 'CHANGES_REQUESTED') {
return 'Changes requested'
}
if (item.reviewDecision === 'review_required' || item.reviewDecision === 'REVIEW_REQUIRED') {
return 'Review required'
}
const reviewerCount = item.reviewerCount ?? item.reviewRequests?.length
return reviewerCount
? `${reviewerCount} reviewer${reviewerCount === 1 ? '' : 's'}`
: 'No reviewers'
}
export function getHostedMergeLabel(item: MobileHostedReviewStatus): string {
if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') {
return 'Conflicts'
}
if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') {
return 'Behind'
}
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
return 'Able to merge'
}
return 'Unknown'
}
export function getHostedChecksLabel(item: { checksSummary?: ProviderCheckSummary }): string {
const summary = item.checksSummary
if (!summary) {
return 'Checks'
}
if (summary.total === 0) {
return 'No checks'
}
if (summary.failed > 0) {
return `${summary.failed} failing`
}
if (summary.pending > 0) {
return `${summary.pending} pending`
}
return summary.state === 'neutral'
? 'Unresolved checks'
: `${summary.passed}/${summary.total} passed`
}
export function getHostedReviewSignalTone(
item: MobileHostedReviewStatus,
signal: 'review' | 'checks' | 'merge'
): 'neutral' | 'success' | 'warning' | 'danger' {
if (signal === 'review') {
if (item.reviewDecision === 'approved' || item.reviewDecision === 'APPROVED') {
return 'success'
}
if (
item.reviewDecision === 'changes_requested' ||
item.reviewDecision === 'CHANGES_REQUESTED'
) {
return 'danger'
}
if (
item.reviewDecision === 'review_required' ||
item.reviewDecision === 'REVIEW_REQUIRED' ||
item.reviewerCount !== undefined ||
item.reviewRequests?.length
) {
return 'warning'
}
return 'neutral'
}
if (signal === 'checks') {
if (item.checksSummary?.state === 'success') {
return 'success'
}
if (item.checksSummary?.state === 'failure') {
return 'danger'
}
if (item.checksSummary?.state === 'pending') {
return 'warning'
}
return 'neutral'
}
if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') {
return 'danger'
}
if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') {
return 'warning'
}
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
return 'success'
}
return 'neutral'
}
@@ -0,0 +1,194 @@
// @vitest-environment happy-dom
import { Terminal, type ITerminalInitOnlyOptions, type ITerminalOptions } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html'
type CursorCoreService = {
isCursorHidden: boolean
isCursorInitialized: boolean
}
type ListenerOwner = Pick<EventTarget, 'addEventListener' | 'removeEventListener'>
function cursorCoreService(terminal: Terminal): CursorCoreService {
const core = (terminal as unknown as { _core?: { coreService?: CursorCoreService } })._core
expect(core, 'xterm private _core compatibility').toBeDefined()
expect(core?.coreService, 'xterm private coreService compatibility').toBeDefined()
expect(typeof core?.coreService?.isCursorHidden).toBe('boolean')
expect(typeof core?.coreService?.isCursorInitialized).toBe('boolean')
return core!.coreService!
}
function listenerOwner(target: EventTarget): ListenerOwner {
let owner = target as ListenerOwner | null
while (owner) {
if (Object.hasOwn(owner, 'addEventListener') && Object.hasOwn(owner, 'removeEventListener')) {
return owner
}
owner = Object.getPrototypeOf(owner) as ListenerOwner | null
}
throw new Error(`No event-listener owner for ${target.constructor.name}`)
}
function eventListenerOwners(): ListenerOwner[] {
const targets: EventTarget[] = [
document,
document.defaultView!,
document.documentElement,
document.body,
document.createElement('div'),
document.createElement('textarea'),
document.createElement('canvas')
]
return [...new Set(targets.map(listenerOwner))]
}
function trackEventListenerCleanup(): () => {
added: number
removed: number
unreleased: string[]
} {
const registrations: Array<{
capture: boolean
listener: EventListenerOrEventListenerObject
removed: boolean
target: EventTarget
type: string
}> = []
const capture = (options?: boolean | AddEventListenerOptions | EventListenerOptions): boolean =>
typeof options === 'boolean' ? options : (options?.capture ?? false)
const activeRegistration = (
target: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions | EventListenerOptions
) =>
registrations.find(
(entry) =>
!entry.removed &&
entry.target === target &&
entry.type === type &&
entry.listener === listener &&
entry.capture === capture(options)
)
for (const owner of eventListenerOwners()) {
const addEventListener = owner.addEventListener
const removeEventListener = owner.removeEventListener
vi.spyOn(owner, 'addEventListener').mockImplementation(function (
this: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
) {
addEventListener.call(this, type, listener, options)
if (!activeRegistration(this, type, listener, options)) {
registrations.push({
capture: capture(options),
listener,
removed: false,
target: this,
type
})
}
})
vi.spyOn(owner, 'removeEventListener').mockImplementation(function (
this: EventTarget,
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions
) {
removeEventListener.call(this, type, listener, options)
const registration = activeRegistration(this, type, listener, options)
if (registration) {
registration.removed = true
}
})
}
return () => ({
added: registrations.length,
removed: registrations.filter((registration) => registration.removed).length,
unreleased: registrations
.filter((registration) => !registration.removed)
.map((registration) => registration.type)
})
}
function write(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, resolve))
}
describe('xterm caret rendering oracle', () => {
beforeEach(() => {
vi.stubGlobal(
'OffscreenCanvas',
class {
getContext(): Pick<CanvasRenderingContext2D, 'font' | 'measureText'> {
return { font: '', measureText: () => ({ width: 8 }) as TextMetrics }
}
}
)
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('renders and hides an unfocused main-buffer caret', async () => {
const listenerCleanup = trackEventListenerCleanup()
const options: ITerminalOptions & ITerminalInitOnlyOptions = MOBILE_TERMINAL_CARET_OPTIONS
const terminal = new Terminal(options)
const container = document.createElement('div')
document.body.append(container)
try {
terminal.open(container)
await write(terminal, '\x1b[?25h\x1b[2K\x1b[1G> hello\x1b[?2004h\x1b[1;3H')
terminal.refresh(0, terminal.rows - 1)
await vi.waitFor(() => expect(container.textContent).toContain('hello'))
const coreService = cursorCoreService(terminal)
expect({
initialized: coreService.isCursorInitialized,
rendered: container.querySelector('.xterm-cursor') !== null
}).toEqual({ initialized: true, rendered: true })
await write(terminal, '\x1b[?25l')
terminal.refresh(0, terminal.rows - 1)
await vi.waitFor(() => expect(container.querySelector('.xterm-cursor')).toBeNull())
expect({
hidden: coreService.isCursorHidden,
rendered: container.querySelector('.xterm-cursor') !== null
}).toEqual({ hidden: true, rendered: false })
} finally {
// Why: teardown only — an assertion here would mask the body failure and strand the container.
terminal.dispose()
container.remove()
}
expect(container.querySelector('.xterm')).toBeNull()
const cleanup = listenerCleanup()
expect(cleanup.added).toBeGreaterThan(0)
expect(cleanup.removed).toBe(cleanup.added)
expect(cleanup.unreleased).toEqual([])
})
it('releases listeners across 25 terminal lifecycles', () => {
const listenerCleanup = trackEventListenerCleanup()
for (let cycle = 0; cycle < 25; cycle += 1) {
const terminal = new Terminal(MOBILE_TERMINAL_CARET_OPTIONS)
const container = document.createElement('div')
document.body.append(container)
terminal.open(container)
terminal.dispose()
expect(container.querySelector('.xterm')).toBeNull()
container.remove()
}
const cleanup = listenerCleanup()
expect(cleanup.added).toBeGreaterThan(0)
expect(cleanup.removed).toBe(cleanup.added)
expect(cleanup.unreleased).toEqual([])
})
})
@@ -0,0 +1,136 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { resolveMobileTerminalInputGate } from './terminal-input-connection-gate'
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
function routeSlice(anchorStart: string, anchorEnd: string): string {
const start = sessionRouteSource.indexOf(anchorStart)
expect(start).toBeGreaterThanOrEqual(0)
// Why: a duplicated start anchor would silently slice the wrong region.
expect(sessionRouteSource.indexOf(anchorStart, start + 1)).toBe(-1)
const end = sessionRouteSource.indexOf(anchorEnd, start)
expect(end).toBeGreaterThan(start)
return sessionRouteSource.slice(start, end + anchorEnd.length)
}
describe('terminal input connection gate', () => {
it('Given a live connection on a terminal tab Then composing and sending are both allowed', () => {
expect(
resolveMobileTerminalInputGate({
connState: 'connected',
activeHandle: 'terminal-a',
activeSessionTabType: 'terminal'
})
).toEqual({ canCompose: true, canSend: true })
})
it('Given a cut connection Then composing stays available while sending is blocked', () => {
for (const connState of [
'connecting',
'handshaking',
'disconnected',
'reconnecting',
'auth-failed'
] as const) {
expect(
resolveMobileTerminalInputGate({
connState,
activeHandle: 'terminal-a',
activeSessionTabType: 'terminal'
})
).toEqual({ canCompose: true, canSend: false })
}
})
it('Given a non-terminal tab or no handle Then neither composing nor sending is allowed', () => {
for (const activeSessionTabType of ['markdown', 'file', 'browser']) {
expect(
resolveMobileTerminalInputGate({
connState: 'connected',
activeHandle: 'terminal-a',
activeSessionTabType
})
).toEqual({ canCompose: false, canSend: false })
}
expect(
resolveMobileTerminalInputGate({
connState: 'connected',
activeHandle: null,
activeSessionTabType: 'terminal'
})
).toEqual({ canCompose: false, canSend: false })
})
it('Given a lagging tab list yielding no tab Then the gate treats the type as unknown, not non-terminal', () => {
expect(
resolveMobileTerminalInputGate({
connState: 'disconnected',
activeHandle: 'terminal-a',
activeSessionTabType: undefined
})
).toEqual({ canCompose: true, canSend: false })
})
})
describe('session route offline-compose wiring', () => {
it('derives both gates from the shared resolver', () => {
expect(sessionRouteSource).toContain('resolveMobileTerminalInputGate({')
})
it('keeps the buffered command box editable offline while the live capture stays send-gated', () => {
const bufferedInput = routeSlice(
'ref={commandInputRef}',
'onSubmitEditing={() => void handleSend()}'
)
expect(bufferedInput).toContain('editable={canCompose}')
const liveCapture = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"')
expect(liveCapture).toContain('editable={canSend}')
})
it('keeps the send button connection-gated so held text cannot fire into a dead link', () => {
const sendButton = routeSlice('styles.sendButton,', 'accessibilityLabel="Send command"')
expect(sendButton).toContain('disabled={!canSend}')
})
it('holds composed text when the return key submits offline', () => {
const handleSend = routeSlice('async function handleSend()', 'sendingRef.current = true')
expect(handleSend).toContain('!canSend')
})
it('keeps the live/buffered mode toggle reachable offline', () => {
const modeToggle = routeSlice(
'liveInputEnabled && styles.accessoryKeyActive',
'onPress={toggleLiveInput}'
)
expect(modeToggle).toContain('disabled={!canCompose}')
})
it('tells the live-input commit hook about connection loss so stale mirror state resets', () => {
const hookCall = routeSlice('useTerminalLiveInputCommit({', 'setLiveInputCapture')
expect(hookCall).toContain("connected: connState === 'connected'")
})
it('keeps every keystroke-grade terminal send now-or-never so nothing replays after reconnect', () => {
// Live mirror, buffered send, and gesture arrows must all opt out of the
// connect wait — a parked send replays stale bytes into the PTY. Accessory
// keys get the same option inside terminal-live-accessory-raw-send.ts.
const optOuts = sessionRouteSource.match(/TERMINAL_INPUT_SEND_OPTIONS/g)?.length ?? 0
expect(optOuts).toBe(4)
expect(TERMINAL_INPUT_SEND_OPTIONS).toEqual({ failWhenDisconnected: true })
})
it('tags terminal sends with the device presence lock only when a token exists', () => {
expect(
buildTerminalSendParams({ terminal: 't1', text: 'ls', enter: true, deviceToken: 'tok' })
).toEqual({ terminal: 't1', text: 'ls', enter: true, client: { id: 'tok', type: 'mobile' } })
expect(
buildTerminalSendParams({ terminal: 't1', text: 'ls', enter: false, deviceToken: null })
).toEqual({ terminal: 't1', text: 'ls', enter: false })
})
})
@@ -0,0 +1,26 @@
import type { ConnectionState } from '../transport/types'
type MobileTerminalInputGateOptions = {
readonly connState: ConnectionState
readonly activeHandle: string | null
readonly activeSessionTabType: string | null | undefined
}
type MobileTerminalInputGate = {
// Why: composing is local — it must survive an outage so typed text is held, not discarded (#6713).
readonly canCompose: boolean
readonly canSend: boolean
}
export function resolveMobileTerminalInputGate({
connState,
activeHandle,
activeSessionTabType
}: MobileTerminalInputGateOptions): MobileTerminalInputGate {
const canCompose =
activeHandle != null &&
activeSessionTabType !== 'markdown' &&
activeSessionTabType !== 'file' &&
activeSessionTabType !== 'browser'
return { canCompose, canSend: canCompose && connState === 'connected' }
}
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest'
import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send'
import type { RpcClient } from '../transport/rpc-client'
function captureClient(result: Promise<unknown> = Promise.resolve({ ok: true })) {
const sendRequest = vi.fn(() => result)
return { client: { sendRequest } as unknown as Pick<RpcClient, 'sendRequest'>, sendRequest }
}
const BASE_ARGS = {
targetHandle: 'terminal-a',
activeHandle: 'terminal-a',
activeSessionTabType: 'terminal',
connState: 'connected',
bytes: '',
deviceToken: 'tok'
} as const
describe('terminal live accessory raw send', () => {
it('sends raw bytes now-or-never with the device presence tag', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
expect(sendRequest).toHaveBeenCalledWith(
'terminal.send',
{ terminal: 'terminal-a', text: '', enter: false, client: { id: 'tok', type: 'mobile' } },
// Why: an accessory key parked in the connect wait would fire into the PTY long after the tap.
{ failWhenDisconnected: true }
)
})
it('drops the bytes instead of sending while disconnected', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
expect(sendRequest).not.toHaveBeenCalled()
})
it('drops the bytes when the terminal selection went stale mid-flush', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
expect(sendRequest).not.toHaveBeenCalled()
})
it('swallows a rejected send so accessory taps never surface transport errors', async () => {
const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send')))
await expect(
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
).resolves.toBeUndefined()
})
})
@@ -0,0 +1,43 @@
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
type TerminalLiveAccessoryRawSendArgs = {
readonly client: Pick<RpcClient, 'sendRequest'> | null
readonly targetHandle: string
readonly activeHandle: string | null
readonly activeSessionTabType: string | null
readonly connState: ConnectionState
readonly bytes: string
readonly deviceToken: string | null
}
export async function sendTerminalLiveAccessoryRawBytes(
args: TerminalLiveAccessoryRawSendArgs
): Promise<void> {
// Why: async IME flushing can outlive the original terminal selection.
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle: args.targetHandle,
activeHandle: args.activeHandle,
activeSessionTabType: args.activeSessionTabType
})
if (!args.client || !rawSendTarget || args.connState !== 'connected') {
return
}
await args.client
.sendRequest(
'terminal.send',
buildTerminalSendParams({
terminal: rawSendTarget,
text: args.bytes,
enter: false,
deviceToken: args.deviceToken
}),
TERMINAL_INPUT_SEND_OPTIONS
)
.then(
() => undefined,
() => undefined
)
}
@@ -0,0 +1,26 @@
import type { SendRequestOptions } from '../transport/rpc-client'
type TerminalSendParams = {
readonly terminal: string
readonly text: string
readonly enter: boolean
readonly client?: { readonly id: string; readonly type: 'mobile' }
}
// Why: keystroke sends must never park in the connect wait — parked sends replay into the PTY after reconnect (#6713).
export const TERMINAL_INPUT_SEND_OPTIONS: SendRequestOptions = { failWhenDisconnected: true }
export function buildTerminalSendParams(args: {
terminal: string
text: string
enter: boolean
// Why: presence-lock take-floor; marks this phone active so multi-mobile contention resolves to the last actor.
deviceToken: string | null
}): TerminalSendParams {
return {
terminal: args.terminal,
text: args.text,
enter: args.enter,
...(args.deviceToken ? { client: { id: args.deviceToken, type: 'mobile' as const } } : {})
}
}
+13 -5
View File
@@ -38,6 +38,13 @@ const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
brightWhite: '#c0caf5'
}
export const MOBILE_TERMINAL_CARET_OPTIONS = {
cursorBlink: false,
cursorStyle: 'bar',
showCursorImmediately: true,
cursorInactiveStyle: 'block'
} as const
// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing.
export const XTERM_HTML = `<!DOCTYPE html>
<html>
@@ -726,11 +733,12 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
// Why: xterm suppresses parser-generated query replies when disableStdin
// is true. Native accepts only validated reply grammars from onData.
disableStdin: false,
cursorBlink: false,
cursorStyle: 'bar',
// Why: native TextInput owns mobile keyboard focus, so xterm stays inactive.
// Match its active bar while still honoring application cursor-hide sequences.
cursorInactiveStyle: 'bar',
cursorBlink: ${MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink},
cursorStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle)},
// Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret.
showCursorImmediately: ${MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately},
// A full inactive cell remains visible under the terminal's phone-fit scale.
cursorInactiveStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle)},
convertEol: false,
allowProposedApi: true
});
@@ -18,6 +18,7 @@ type TerminalStub = ReturnType<typeof makeTerminal>
type TerminalOptions = {
cursorInactiveStyle?: string
cursorStyle?: string
showCursorImmediately?: boolean
}
type RegisteredWindowListener = {
listener: EventListenerOrEventListenerObject
@@ -146,7 +147,8 @@ describe('terminal WebView init surface replacement', () => {
for (const options of terminalOptions) {
expect(options).toMatchObject({
cursorStyle: 'bar',
cursorInactiveStyle: 'bar'
cursorInactiveStyle: 'block',
showCursorImmediately: true
})
}
})
@@ -11,6 +11,8 @@ type TerminalLiveInputCommitHarness = {
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
readonly sent: readonly string[]
readonly setActiveSessionTabType: (next: string | undefined) => void
readonly setConnected: (next: boolean) => void
readonly setSendResult: (next: boolean) => void
readonly unmount: () => void
}
@@ -46,15 +48,16 @@ function createTerminalLiveInputCommitHarness({
current: new Set([activeHandle])
}
const sent: string[] = []
let currentSendResult = sendResult
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
sent.push(bytes)
return sendResult
return currentSendResult
}
}
// The hook keeps live-input state in refs, so a change handler alone never
// re-renders; only a prop change (this variable) re-runs the pending-clear effect.
// Refs never re-render; only these variables re-run the hook's clear effects.
let currentActiveSessionTabType: string | undefined = 'terminal'
let currentConnected = true
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
let renderer: ReactTestRenderer | null = null
@@ -64,6 +67,7 @@ function createTerminalLiveInputCommitHarness({
activeHandleRef,
activeSessionTabType: currentActiveSessionTabType,
activeSessionTabTypeRef,
connected: currentConnected,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
@@ -98,6 +102,15 @@ function createTerminalLiveInputCommitHarness({
renderer?.update(createElement(Harness))
})
},
setConnected: (next: boolean): void => {
currentConnected = next
act(() => {
renderer?.update(createElement(Harness))
})
},
setSendResult: (next: boolean): void => {
currentSendResult = next
},
unmount: () => {
act(() => renderer?.unmount())
}
@@ -340,4 +353,40 @@ describe('terminal live input commit hook', () => {
// Then: pending was dropped, so submit sends only the carriage return
await vi.waitFor(() => expect(sent).toEqual(['\r']))
})
it('Given bytes lost in a silent stall When the disconnect is detected Then the first post-recovery send carries no stale fragment or phantom erases', async () => {
// Given: a stalled link — the mirror sends but the PTY never accepts (#6713 second defect)
const { captures, handlers, sent, setConnected, setSendResult } =
createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('XYZZY')
await vi.waitFor(() => expect(sent).toEqual(['XYZZY']))
// When: the outage is finally detected, then the link recovers
setConnected(false)
setSendResult(true)
setConnected(true)
// Then: the capture was wiped, and fresh typing sends verbatim bytes — not
// 'XYZZY…' replayed and not DELs erasing PTY chars that never arrived
expect(captures.at(-1)).toBe('')
const sentBeforeRecovery = sent.length
handlers.handleLiveInputChange('echo CLEANLINE')
await vi.waitFor(() => expect(sent.slice(sentBeforeRecovery)).toEqual(['echo CLEANLINE']))
})
it('Given a held syllable during an outage When the disconnect is detected Then the settle timer cannot commit it later', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent, setConnected } = createTerminalLiveInputCommitHarness({
sendResult: false
})
handlers.handleLiveInputChange('한')
// When
setConnected(false)
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
// Then: the outage cleared the held text before the timer could send it
expect(sent).toEqual([])
})
})
@@ -22,6 +22,7 @@ type TerminalLiveInputCommitOptions<TTabType extends string> = {
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabType: TTabType | null | undefined
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly connected: boolean
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
@@ -45,6 +46,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
activeHandleRef,
activeSessionTabType,
activeSessionTabTypeRef,
connected,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
@@ -68,6 +70,13 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
setLiveInputCapture
})
useEffect(() => {
// Why: what reached the PTY is unknowable across an outage — stale mirror state corrupts the first post-reconnect send.
if (!connected) {
clearPendingLiveInputCommit()
}
}, [connected, clearPendingLiveInputCommit])
useEffect(() => {
const pendingHandle = pendingLiveInputHandleRef.current
if (!pendingHandle) {
@@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect } from './rpc-client'
import { classifyConnection, verdictDisplayLabel } from './connection-health'
// Issue #10119 — "mobile client fails to connect over cellular and remains stuck
// at connecting". Each scenario below is a way a cellular link fails and what the
// user is shown while it does. On the parent commit ws.onopen reset
// reconnectAttempt before the E2EE handshake completed and classifyConnection
// reverted to "Connecting…" on every redial, so no escalation assertion here
// could pass: the label looped "Connecting…" forever.
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({ publicKey: new Uint8Array(32), secretKey: new Uint8Array(32) }),
deriveSharedKey: () => new Uint8Array(32),
publicKeyFromBase64: () => new Uint8Array(32),
publicKeyToBase64: () => 'client-public-key',
encrypt: (plaintext: string) => plaintext,
decrypt: (raw: string) => raw,
decryptBytes: (bytes: Uint8Array) => bytes
}))
type CarrierBehavior =
// Carrier silently drops the SYN to a LAN/CGNAT destination: the socket sits
// CONNECTING until the client's 12s connect timeout fires.
| { kind: 'blackhole' }
// Carrier answers with an RST.
| { kind: 'refused' }
// Endpoint completes the WS upgrade, then never speaks (relay accepted the
// socket but no desktop joined the session).
| { kind: 'open-then-silent' }
// Endpoint is healthy but its e2ee_ready lands after readyAfterMs, which a
// high-latency / lossy cellular link readily pushes past the 5s budget.
| { kind: 'slow-handshake'; readyAfterMs: number }
let carrier: CarrierBehavior = { kind: 'blackhole' }
const sockets: CarrierWebSocket[] = []
class CarrierWebSocket {
static CONNECTING = 0
// Why: sendEncrypted compares readyState against WebSocket.OPEN — the mock
// must define it or the e2ee_auth write silently fails.
static OPEN = 1
static CLOSED = 3
readonly CONNECTING = 0
readyState = 0
onopen: (() => void) | null = null
onclose: ((event?: unknown) => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: ((event?: unknown) => void) | null = null
constructor(readonly endpoint: string) {
sockets.push(this)
if (carrier.kind === 'refused') {
setTimeout(() => this.fail(), 1)
return
}
if (carrier.kind === 'open-then-silent' || carrier.kind === 'slow-handshake') {
setTimeout(() => {
this.readyState = 1
this.onopen?.()
}, 200)
}
}
send(payload: string): void {
if (carrier.kind !== 'slow-handshake') {
return
}
if (payload.includes('e2ee_hello')) {
const delay = carrier.readyAfterMs
setTimeout(() => {
if (this.readyState === 1) {
this.onmessage?.({ data: JSON.stringify({ type: 'e2ee_ready' }) })
}
}, delay)
return
}
if (payload.includes('e2ee_auth')) {
setTimeout(() => {
if (this.readyState === 1) {
this.onmessage?.({ data: JSON.stringify({ type: 'e2ee_authenticated' }) })
}
}, 10)
}
}
close(): void {
if (this.readyState === 3) {
return
}
this.fail()
}
private fail(): void {
this.readyState = 3
this.onerror?.({ message: 'network error' })
this.onclose?.({ code: 1006, wasClean: false })
}
}
const originalWebSocket = globalThis.WebSocket
type Sample = { atMs: number; state: string; attempts: number; label: string }
// Walks simulated time recording what every screen would render, since
// classifyConnection is the single label source for home, host, tasks and session.
function observe(endpoint: string, durationMs: number): Sample[] {
const client = connect(endpoint, 'device-token', 'server-public-key')
const samples: Sample[] = []
const stepMs = 250
for (let atMs = 0; atMs <= durationMs; atMs += stepMs) {
const state = client.getState()
const attempts = client.getReconnectAttempt()
samples.push({
atMs,
state,
attempts,
label: verdictDisplayLabel(
classifyConnection({
state,
reconnectAttempts: attempts,
lastConnectedAt: client.getLastConnectedAt(),
endpoint,
nowMs: Date.now()
})
)
})
vi.advanceTimersByTime(stepMs)
}
client.close()
return samples
}
function isEscalated(label: string): boolean {
return label !== 'Connecting…' && label !== 'Reconnecting…'
}
function labelsAfterFirstEscalation(samples: Sample[]): Sample[] {
const first = samples.findIndex((s) => isEscalated(s.label))
expect(first).toBeGreaterThan(0)
return samples.slice(first)
}
describe('issue #10119 — what a phone shows while it cannot reach the desktop', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(console, 'log').mockImplementation(() => {})
sockets.length = 0
// @ts-expect-error test double for the RN global
globalThis.WebSocket = CarrierWebSocket
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
globalThis.WebSocket = originalWebSocket
})
it('escalates and stays escalated when the handshake never fits the 5s budget', () => {
carrier = { kind: 'slow-handshake', readyAfterMs: 6_000 }
const samples = observe('ws://192.168.0.56:6769', 900_000)
// 15 minutes of failure against a desktop that is up and answering.
expect(samples.some((s) => s.state === 'handshaking')).toBe(true)
expect(samples.some((s) => s.state === 'connected')).toBe(false)
// The failure counter survives ws.onopen, so both connection-health gates
// (warning >= 3, unreachable >= 12) are reachable.
expect(Math.max(...samples.map((s) => s.attempts))).toBeGreaterThanOrEqual(12)
expect(samples.some((s) => s.label === "Can't connect")).toBe(true)
expect(samples.some((s) => s.label === "Can't reach desktop")).toBe(true)
// Once escalated, no later redial reverts the label to "Connecting…".
const after = labelsAfterFirstEscalation(samples)
expect(after.every((s) => s.label !== 'Connecting…')).toBe(true)
// Growing backoff: the pinned counter burned a socket every ~5.6s (~160 in
// 15 min); with the tiered delays plus the 90s trickle it stays modest.
expect(sockets.length).toBeGreaterThanOrEqual(5)
expect(sockets.length).toBeLessThan(30)
})
it('escalates when the endpoint accepts the socket and then says nothing', () => {
carrier = { kind: 'open-then-silent' }
const samples = observe('ws://relay.example:443', 900_000)
expect(Math.max(...samples.map((s) => s.attempts))).toBeGreaterThanOrEqual(12)
expect(samples.some((s) => s.label === "Can't reach desktop")).toBe(true)
const after = labelsAfterFirstEscalation(samples)
expect(after.every((s) => s.label !== 'Connecting…')).toBe(true)
})
it('latches "Can\'t connect" on a blackholed LAN endpoint instead of reverting each dial', () => {
carrier = { kind: 'blackhole' }
const samples = observe('ws://192.168.0.56:6769', 120_000)
// Three 12s connect timeouts plus backoff: first escalation lands ≈ 37.5s.
const first = samples.find((s) => isEscalated(s.label))
expect(first?.atMs ?? Infinity).toBeLessThanOrEqual(40_000)
// Every later 12s dial window used to flip the label back to "Connecting…".
const after = labelsAfterFirstEscalation(samples)
expect(after.every((s) => s.label !== 'Connecting…')).toBe(true)
})
it('holds "Can\'t reach desktop" through every trickle dial once past the give-up cap', () => {
carrier = { kind: 'blackhole' }
const samples = observe('ws://192.168.0.56:6769', 900_000)
const firstUnreachable = samples.findIndex((s) => s.label === "Can't reach desktop")
expect(firstUnreachable).toBeGreaterThan(0)
// The loop has given up internally (attempts held at the cap); the label
// must say so through the 12s of every 90s trickle dial, not just between them.
const after = samples.slice(firstUnreachable)
expect(after.every((s) => s.attempts >= 12)).toBe(true)
expect(after.every((s) => s.label === "Can't reach desktop")).toBe(true)
})
it('resets the failure counter only once a handshake actually completes', () => {
carrier = { kind: 'slow-handshake', readyAfterMs: 6_000 }
const client = connect('ws://192.168.0.56:6769', 'device-token', 'server-public-key')
vi.advanceTimersByTime(40_000)
expect(client.getState()).not.toBe('connected')
expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(3)
// The link heals: the same desktop now answers inside the budget.
carrier = { kind: 'slow-handshake', readyAfterMs: 50 }
vi.advanceTimersByTime(20_000)
expect(client.getState()).toBe('connected')
expect(client.getReconnectAttempt()).toBe(0)
client.close()
})
it('contrast: an endpoint that RSTs escalates the same way', () => {
carrier = { kind: 'refused' }
const samples = observe('ws://192.168.0.56:6769', 60_000)
expect(samples.some((s) => s.label === "Can't connect")).toBe(true)
const after = labelsAfterFirstEscalation(samples)
expect(after.every((s) => s.label !== 'Connecting…')).toBe(true)
})
})
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WebSocketServer, type WebSocket as ServerWebSocket } from 'ws'
import { connect } from './rpc-client'
import { classifyConnection, verdictDisplayLabel } from './connection-health'
// Issue #10119 verification over a REAL socket, real timers, real WebSocket
// upgrade — guards the fake-timer suite in
// cellular-connecting-label-stall.test.ts against mock drift.
//
// The desktop is up and listening. The only thing wrong is that its E2EE
// handshake reply lands later than HANDSHAKE_TIMEOUT_MS (5s) — the condition a
// high-latency / lossy cellular link produces on a handshake that needs two
// round trips (e2ee_hello → e2ee_ready → e2ee_auth → e2ee_authenticated).
//
// The client must escalate past "Connecting…" and stay escalated. On the parent
// commit ws.onopen reset reconnectAttempt to 0 before the handshake succeeded,
// so getReconnectAttempt() never passed the connection-health gates and the
// label looped "Connecting…" forever.
//
// Opt-in like rpc-client-live-recovery.test.ts — needs ~22s wall-clock:
// ORCA_MOBILE_LIVE_REPRO=1 pnpm vitest run src/transport/cellular-handshake-stall-real-socket.test.ts
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({ publicKey: new Uint8Array(32), secretKey: new Uint8Array(32) }),
deriveSharedKey: () => new Uint8Array(32),
publicKeyFromBase64: () => new Uint8Array(32),
publicKeyToBase64: () => 'client-public-key',
encrypt: (plaintext: string) => plaintext,
decrypt: (raw: string) => raw,
decryptBytes: (bytes: Uint8Array) => bytes
}))
const RUN_LIVE =
process.env.ORCA_MOBILE_LIVE_REPRO === '1' || !!process.env.ORCA_MOBILE_LIVE_REPRO_FULL
// Long enough for three handshake-timeout cycles (≈17s) plus CI scheduling slack.
const OBSERVE_MS = 22_000
const SAMPLE_MS = 200
let server: WebSocketServer | null = null
const serverSockets: ServerWebSocket[] = []
// Completes the WebSocket upgrade, then answers e2ee_hello later than the
// client's 5s handshake budget allows.
async function startSlowHandshakeDesktop(replyAfterMs: number): Promise<number> {
const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 })
server = wss
wss.on('connection', (socket) => {
serverSockets.push(socket)
socket.on('message', (data) => {
const text = data.toString()
if (!text.includes('e2ee_hello')) {
return
}
setTimeout(() => {
if (socket.readyState === socket.OPEN) {
socket.send(JSON.stringify({ type: 'e2ee_ready', publicKeyB64: 'server-public-key' }))
}
}, replyAfterMs)
})
})
await new Promise<void>((resolve) => wss.once('listening', resolve))
const address = wss.address()
if (typeof address === 'string' || address === null) {
throw new Error('expected a TCP address')
}
return address.port
}
describe.runIf(RUN_LIVE)('issue #10119 — real socket, handshake slower than the 5s budget', () => {
beforeEach(() => {
serverSockets.length = 0
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(async () => {
vi.restoreAllMocks()
for (const socket of serverSockets) {
socket.terminate()
}
const wss = server
server = null
if (wss) {
await new Promise<void>((resolve) => wss.close(() => resolve()))
}
})
it('escalates and latches against a live desktop that answers 1s too late', async () => {
const port = await startSlowHandshakeDesktop(6_000)
const endpoint = `ws://127.0.0.1:${port}`
const client = connect(endpoint, 'device-token', 'server-public-key')
const labels: string[] = []
const states: string[] = []
let maxAttempts = 0
const started = Date.now()
while (Date.now() - started < OBSERVE_MS) {
const state = client.getState()
const attempts = client.getReconnectAttempt()
maxAttempts = Math.max(maxAttempts, attempts)
states.push(state)
labels.push(
verdictDisplayLabel(
classifyConnection({
state,
reconnectAttempts: attempts,
lastConnectedAt: client.getLastConnectedAt(),
endpoint
})
)
)
await new Promise((resolve) => setTimeout(resolve, SAMPLE_MS))
}
client.close()
// The dial loop really did run and really did keep failing.
expect(serverSockets.length).toBeGreaterThanOrEqual(2)
expect(states).toContain('handshaking')
expect(states).not.toContain('connected')
// The failure counter survives ws.onopen, so the warning gate (>= 3) fires.
expect(maxAttempts).toBeGreaterThanOrEqual(3)
const firstEscalated = labels.findIndex((l) => l !== 'Connecting…' && l !== 'Reconnecting…')
expect(firstEscalated).toBeGreaterThan(0)
// Once escalated, later redials never revert the label to "Connecting…".
expect(labels.slice(firstEscalated).every((l) => l !== 'Connecting…')).toBe(true)
}, 60_000)
})
// Why: vitest fails a file with zero tests; keep a sentinel for default runs.
describe.runIf(!RUN_LIVE)('real-socket handshake stall (skipped)', () => {
it('is opt-in via ORCA_MOBILE_LIVE_REPRO=1', () => {
expect(true).toBe(true)
})
})
@@ -71,6 +71,45 @@ describe('classifyConnection Tailscale hint', () => {
})
})
// Issue #10119: every redial re-enters 'connecting', which used to revert an
// escalated verdict to "Connecting…" for the whole dial window — on a loop that
// had already failed for minutes, the user mostly saw the reassuring label.
describe('classifyConnection while dialing (issue #10119)', () => {
const base = { lastConnectedAt: null, nowMs: 1_000_000 }
it('keeps the warning verdict through a redial instead of reverting to Connecting…', () => {
for (const state of ['connecting', 'handshaking'] as const) {
const verdict = classifyConnection({ ...base, state, reconnectAttempts: 3 })
expect(verdict).toMatchObject({ kind: 'warning', label: "Can't connect" })
}
})
it('keeps the unreachable verdict through a trickle dial', () => {
const verdict = classifyConnection({ ...base, state: 'connecting', reconnectAttempts: 12 })
expect(verdict).toMatchObject({ kind: 'unreachable', reason: 'never-connected' })
})
it('applies the stale heuristic while dialing too', () => {
const verdict = classifyConnection({
state: 'handshaking',
reconnectAttempts: 12,
lastConnectedAt: 900_000,
nowMs: 1_000_000
})
expect(verdict).toMatchObject({ kind: 'unreachable', reason: 'stale' })
})
it('still shows Connecting… before any failures', () => {
const verdict = classifyConnection({ ...base, state: 'connecting', reconnectAttempts: 0 })
expect(verdict).toEqual({ kind: 'normal', label: 'Connecting…' })
})
it('still shows Connecting… below the warning gate', () => {
const verdict = classifyConnection({ ...base, state: 'handshaking', reconnectAttempts: 2 })
expect(verdict).toEqual({ kind: 'normal', label: 'Connecting…' })
})
})
describe('verdictDisplayLabel', () => {
it('appends the hint to warning and unreachable labels', () => {
expect(
+5 -6
View File
@@ -61,19 +61,18 @@ export function classifyConnection(args: {
return { kind: 'auth-failed', label: 'Pairing invalid — re-pair with your desktop' }
}
// Connected / connecting / handshaking are normal.
if (state === 'connected') {
return { kind: 'normal', label: 'Connected' }
}
if (state === 'connecting' || state === 'handshaking') {
return { kind: 'normal', label: 'Connecting…' }
}
if (state === 'disconnected') {
return { kind: 'normal', label: 'Disconnected' }
}
// state === 'reconnecting' from here.
// connecting / handshaking / reconnecting from here. The gates apply to all
// three: every redial re-enters 'connecting', and letting that revert an
// escalated verdict to "Connecting…" hid the failure loop behind a reassuring
// label for most of each cycle (issue #10119).
if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) {
if (lastConnectedAt == null) {
return {
@@ -97,7 +96,7 @@ export function classifyConnection(args: {
return { kind: 'warning', label: "Can't connect", hint }
}
return { kind: 'normal', label: 'Reconnecting…' }
return { kind: 'normal', label: state === 'reconnecting' ? 'Reconnecting…' : 'Connecting…' }
}
// Why: single place that turns a verdict into display text so every screen
+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()
}
@@ -0,0 +1,165 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect } from './rpc-client'
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({
publicKey: new Uint8Array(32),
secretKey: new Uint8Array(32)
}),
deriveSharedKey: () => new Uint8Array(32),
publicKeyFromBase64: () => new Uint8Array(32),
publicKeyToBase64: () => 'client-public-key',
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
decrypt: (raw: string) => raw.replace(/^encrypted:/, ''),
decryptBytes: (bytes: Uint8Array) => bytes
}))
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSED = 3
readonly CONNECTING = MockWebSocket.CONNECTING
readonly OPEN = MockWebSocket.OPEN
readonly CLOSED = MockWebSocket.CLOSED
readyState = MockWebSocket.CONNECTING
onopen: (() => void) | null = null
onclose: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
sent: string[] = []
close = vi.fn(() => {
if (this.readyState === MockWebSocket.CLOSED) {
return
}
this.readyState = MockWebSocket.CLOSED
this.onclose?.()
})
constructor(readonly endpoint: string) {
mockSockets.push(this)
}
send(payload: string): void {
this.sent.push(payload)
}
open(): void {
this.readyState = MockWebSocket.OPEN
this.onopen?.()
this.receive(JSON.stringify({ type: 'e2ee_ready' }))
this.receive('encrypted:{"type":"e2ee_authenticated"}')
}
receive(payload: unknown): void {
this.onmessage?.({ data: payload })
}
}
const mockSockets: MockWebSocket[] = []
const originalWebSocket = globalThis.WebSocket
function track(request: Promise<unknown>): { read: () => string } {
let outcome = 'pending'
request.then(
() => {
outcome = 'resolved'
},
(error: Error) => {
outcome = error.message
}
)
return { read: () => outcome }
}
function terminalSendFrames(socket: MockWebSocket): string[] {
return socket.sent.filter((frame) => frame.includes('terminal.send'))
}
describe('mobile rpc-client connect-wait replay', () => {
beforeEach(() => {
vi.useFakeTimers()
mockSockets.length = 0
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
})
afterEach(() => {
vi.useRealTimers()
globalThis.WebSocket = originalWebSocket
})
it('Given a cut connection When a send opts into failWhenDisconnected Then it rejects now and nothing replays after reconnect', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
socket.open()
socket.close()
const request = client.sendRequest(
'terminal.send',
{ terminal: 't1', text: 'YZZY' },
{ failWhenDisconnected: true }
)
const outcome = track(request)
try {
await vi.advanceTimersByTimeAsync(0)
expect(outcome.read()).toBe('Not connected: terminal.send')
// Reconnect; the rejected keystroke must not ride the new socket.
await vi.advanceTimersByTimeAsync(500)
mockSockets[1]!.open()
await vi.advanceTimersByTimeAsync(0)
expect(terminalSendFrames(mockSockets[1]!)).toEqual([])
} finally {
client.close()
await request.catch(() => undefined)
}
})
it('Given a cut connection When a caller does not opt in Then the send parks and is delivered on the next socket', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
socket.open()
socket.close()
// Pins the default behavior that motivates the opt-out: parked requests
// replay after reconnect, which is what corrupted post-recovery input.
const request = client.sendRequest('terminal.send', { terminal: 't1', text: 'YZZY' })
const outcome = track(request)
try {
await vi.advanceTimersByTimeAsync(0)
expect(outcome.read()).toBe('pending')
await vi.advanceTimersByTimeAsync(500)
mockSockets[1]!.open()
await vi.advanceTimersByTimeAsync(0)
expect(terminalSendFrames(mockSockets[1]!)).toHaveLength(1)
} finally {
client.close()
await request.catch(() => undefined)
}
})
it('rejects requests waiting for reconnect after the retry cap', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
socket.open()
socket.close()
const waitingRequestError = client.sendRequest('status.get').then(
() => null,
(error: Error) => error
)
await vi.advanceTimersByTimeAsync(520_000)
expect(client.getState()).toBe('reconnecting')
expect(client.getReconnectAttempt()).toBe(12)
await expect(waitingRequestError).resolves.toMatchObject({
message: 'Connection retry limit reached'
})
await expect(client.sendRequest('status.get')).rejects.toThrow('Connection retry limit reached')
client.close()
})
})
@@ -89,9 +89,10 @@ describe('unauthorized close-code mapping (silent 4001)', () => {
// A desktop with a regenerated keypair can't send a decryptable e2ee_error —
// the phone only ever sees the 4001 close. Three of those must latch.
// Why 1_000: failed handshakes grow the backoff since issue #10119.
for (let i = 0; i < 3; i++) {
if (i > 0) {
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(1_000)
}
const socket = lastSocket()
socket.open()
@@ -130,9 +131,10 @@ describe('unauthorized close-code mapping (silent 4001)', () => {
it('shares one budget between decrypted e2ee_error rejections and 4001 closes', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
// Why 1_000: failed handshakes grow the backoff since issue #10119.
for (let i = 0; i < 3; i++) {
if (i > 0) {
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(1_000)
}
const socket = lastSocket()
socket.open()
+47 -28
View File
@@ -712,6 +712,47 @@ describe('mobile rpc-client connection timeout', () => {
client.close()
})
it('reconnects when the half-open socket omits its close callback', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
openAndAuthenticate(socket)
socket.emitCloseOnClose = false
client.notifyForeground()
await vi.advanceTimersByTimeAsync(8_000)
expect(socket.close).toHaveBeenCalledTimes(1)
expect(client.getState()).toBe('reconnecting')
socket.onclose?.()
expect(client.getState()).toBe('reconnecting')
await vi.advanceTimersByTimeAsync(500)
expect(mockSockets).toHaveLength(2)
openAndAuthenticate(mockSockets[1]!)
expect(client.getState()).toBe('connected')
expect(client.getReconnectAttempt()).toBe(0)
await vi.advanceTimersByTimeAsync(1_000)
expect(mockSockets).toHaveLength(2)
client.close()
})
it('coalesces repeated foreground probes while one probe is pending', async () => {
const client = connectAuthenticated().client
const socket = mockSockets[0]!
client.notifyForeground()
client.notifyForeground()
client.notifyForeground()
expect(sentRequests(socket, 'status.get')).toHaveLength(1)
await vi.advanceTimersByTimeAsync(8_000)
expect(socket.close).toHaveBeenCalledTimes(1)
expect(client.getState()).toBe('reconnecting')
await vi.advanceTimersByTimeAsync(500)
expect(mockSockets).toHaveLength(2)
client.close()
})
it('keeps a healthy connection when the foreground probe is answered', async () => {
const { client, socket } = connectAuthenticated()
@@ -894,9 +935,11 @@ describe('mobile rpc-client connection timeout', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
// Three consecutive handshake rejections (AUTH_RETRY_BUDGET = 3).
// Why 1_000: failed handshakes grow the backoff since issue #10119 —
// cycle 2 waits RECONNECT_DELAYS[1].
for (let i = 0; i < 3; i++) {
if (i > 0) {
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(1_000)
}
const socket = mockSockets[mockSockets.length - 1]!
socket.open()
@@ -913,16 +956,17 @@ describe('mobile rpc-client connection timeout', () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
// Two rejections, then a clean connect resets the budget...
// Why 1_000: failed handshakes grow the backoff since issue #10119.
for (let i = 0; i < 2; i++) {
if (i > 0) {
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(1_000)
}
const socket = mockSockets[mockSockets.length - 1]!
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
socket.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}')
}
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(1_000)
authenticate(mockSockets[mockSockets.length - 1]!)
expect(client.getState()).toBe('connected')
@@ -940,29 +984,4 @@ describe('mobile rpc-client connection timeout', () => {
client.close()
})
})
it('rejects requests waiting for reconnect after the retry cap', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const socket = mockSockets[0]!
socket.open()
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
socket.close()
const waitingRequestError = client.sendRequest('status.get').then(
() => null,
(error: Error) => error
)
await vi.advanceTimersByTimeAsync(520_000)
expect(client.getState()).toBe('reconnecting')
expect(client.getReconnectAttempt()).toBe(12)
await expect(waitingRequestError).resolves.toMatchObject({
message: 'Connection retry limit reached'
})
await expect(client.sendRequest('status.get')).rejects.toThrow('Connection retry limit reached')
client.close()
})
})
+23 -10
View File
@@ -54,6 +54,9 @@ export type SendRequestOptions = {
* against the post-connect clock, and squeezing them to the floor after a slow
* reconnect would fail sends that used to land. */
budgetSpansConnect?: boolean
/** Reject immediately when not connected — a send parked in the connect wait
* replays stale terminal bytes into the PTY after reconnect. */
failWhenDisconnected?: boolean
}
type SubscribeOptions = {
@@ -89,7 +92,7 @@ export type RpcClient = {
viewport: { cols: number; rows: number }
) => void
getState: () => ConnectionState
// 0 means never failed (reset on successful open); the UI escalates "Reconnecting…" to "Can't connect" past a threshold.
// 0 means never failed (reset once the handshake authenticates); the UI escalates "Reconnecting…" to "Can't connect" past a threshold.
getReconnectAttempt: () => number
// Last 'connected' timestamp (ms epoch); null = never connected. Lets the UI tell "never reachable" from "transient blip".
getLastConnectedAt: () => number | null
@@ -138,8 +141,7 @@ export function connect(
typeof optionsOrLegacy === 'function'
? { onStateChange: optionsOrLegacy }
: (optionsOrLegacy ?? {})
const onStateChange = options.onStateChange
const onLog = options.onLog
const { onStateChange, onLog } = options
let logCounter = 0
function emitLog(level: ConnectionLogLevel, message: string, detail?: string) {
if (!onLog) {
@@ -161,6 +163,7 @@ export function connect(
let connectTimer: ReturnType<typeof setTimeout> | null = null
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
let activityProbeTimer: ReturnType<typeof setInterval> | null = null
let activityProbeInFlight = false
let intentionallyClosed = false
// Consecutive auth rejections; tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching to avoid a needless re-pair.
let authRejectionCount = 0
@@ -220,6 +223,8 @@ export function connect(
})
if (next === 'connected') {
lastConnectedAt = Date.now()
// Why: only a completed E2EE handshake proves the path is healthy (issue #10119).
reconnectAttempt = 0
// Why: a clean handshake proves the token is valid — reset the auth retry budget.
authRejectionCount = 0
for (const waiter of connectWaiters.splice(0)) {
@@ -348,7 +353,9 @@ export function connect(
}
console.log('[net] ws.onopen', { attempt: reconnectAttempt })
clearConnectTimer()
reconnectAttempt = 0
// Why: no reconnectAttempt reset here — an open socket isn't a healthy session
// until e2ee_authenticated. Resetting pre-handshake pinned the counter at 0↔1,
// so a handshake-stall loop never escalated past "Connecting…" (issue #10119).
setState('handshaking')
emitLog('success', 'WebSocket open', 'Starting E2EE handshake')
@@ -766,15 +773,17 @@ export function connect(
// Why: stream frames can flow while control RPC is wedged; only a control response satisfies this probe.
function runActivityProbe() {
if (state !== 'connected' || !ws) {
if (state !== 'connected' || !ws || activityProbeInFlight) {
return
}
activityProbeInFlight = true
const probeWs = ws
const id = nextId()
const probeControlResponseSequence = controlResponseSequence
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
activityProbeInFlight = false
pending.delete(id)
if (controlResponseSequence > probeControlResponseSequence) {
return
@@ -787,23 +796,26 @@ export function connect(
if (timedOut) {
return
}
activityProbeInFlight = false
clearTimeout(timeout)
},
reject: () => {
if (timedOut) {
return
}
activityProbeInFlight = false
clearTimeout(timeout)
}
})
if (!sendEncrypted({ id, deviceToken, method: 'status.get' })) {
activityProbeInFlight = false
clearTimeout(timeout)
pending.delete(id)
}
}
function forceSocketReconnect(socket: WebSocket): void {
if (socket !== ws) {
function forceSocketReconnect(socket: WebSocket | null): void {
if (!socket || socket !== ws) {
return
}
if (socket.readyState === WebSocket.OPEN) {
@@ -1005,6 +1017,9 @@ export function connect(
const budget = openRpcRequestBudget(options)
const waitStart = budget.startedAt
const wasConnected = state === 'connected'
if (options?.failWhenDisconnected && !wasConnected) {
throw new Error(`Not connected: ${method}`)
}
await waitForConnected(options?.timeoutMs)
if (!wasConnected) {
console.log('[net] sendRequest waited for connect', {
@@ -1026,9 +1041,7 @@ export function connect(
})
// Why: the frame was written 30s ago — the host may have processed it.
reject(markRpcDeliveryUnknown(new Error(`Request timed out: ${method}`)))
if (requestWs) {
forceSocketReconnect(requestWs)
}
forceSocketReconnect(requestWs)
}, timeoutMs)
pending.set(id, {
@@ -2579,9 +2579,12 @@ private enum KeyMap {
}
private final class AgentRuntime: NSObject, NSApplicationDelegate {
private static let unclaimedSessionDeadline: TimeInterval = 30
private let socketPath: String
private let token: String?
private var listener: SocketListener?
private var unclaimedSessionTimeout: DispatchWorkItem?
init(socketPath: String, token: String?) {
self.socketPath = socketPath
@@ -2590,9 +2593,29 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
do {
let listener = try SocketListener(socketPath: socketPath, token: token)
let timeout = DispatchWorkItem {
fputs("computer-use agent received no authenticated session before its deadline\n", stderr)
NSApp.terminate(nil)
}
unclaimedSessionTimeout = timeout
let listener = try SocketListener(
socketPath: socketPath,
token: token,
onSessionClaimed: {
timeout.cancel()
},
onSessionClosed: {
DispatchQueue.main.async {
NSApp.terminate(nil)
}
}
)
self.listener = listener
listener.start()
DispatchQueue.main.asyncAfter(
deadline: .now() + Self.unclaimedSessionDeadline,
execute: timeout
)
} catch {
fputs("failed to start computer-use socket: \(error)\n", stderr)
NSApp.terminate(nil)
@@ -2600,6 +2623,8 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate {
}
func applicationWillTerminate(_ notification: Notification) {
unclaimedSessionTimeout?.cancel()
unclaimedSessionTimeout = nil
listener?.stop()
}
}
@@ -3505,14 +3530,26 @@ private final class ButtonTarget: NSObject {
private final class SocketListener: @unchecked Sendable {
private let socketPath: String
private let token: String?
private let onSessionClaimed: () -> Void
private let onSessionClosed: () -> Void
private let provider = Provider()
private let providerLock = NSLock()
private let sessionLock = NSLock()
private var sessionOwnership = AgentSessionOwnership()
private var lastConnectionID: UInt64 = 0
private var socketFd: Int32 = -1
private var isStopped = false
init(socketPath: String, token: String?) throws {
init(
socketPath: String,
token: String?,
onSessionClaimed: @escaping () -> Void,
onSessionClosed: @escaping () -> Void
) throws {
self.socketPath = socketPath
self.token = token
self.onSessionClaimed = onSessionClaimed
self.onSessionClosed = onSessionClosed
try bindSocket()
}
@@ -3587,14 +3624,35 @@ private final class SocketListener: @unchecked Sendable {
}
continue
}
guard let connectionID = allocateConnectionID() else {
fputs("computer-use socket exhausted connection identities\n", stderr)
close(fd)
continue
}
Thread.detachNewThread { [weak self] in
self?.handleConnection(fd)
self?.handleConnection(fd, connectionID: connectionID)
}
}
}
private func handleConnection(_ fd: Int32) {
defer { close(fd) }
private func allocateConnectionID() -> AgentSessionConnectionID? {
sessionLock.lock()
defer { sessionLock.unlock() }
guard lastConnectionID < UInt64.max else { return nil }
lastConnectionID += 1
return AgentSessionConnectionID(rawValue: lastConnectionID)
}
private func handleConnection(_ fd: Int32, connectionID: AgentSessionConnectionID) {
var registeredSession = false
var hangupMonitor: AuthenticatedConnectionHangupMonitor?
defer {
hangupMonitor?.cancel()
if registeredSession {
disconnectSession(connectionID)
}
close(fd)
}
let authorizedPeer = peerProcessId(fd).map(isAuthorizedAgentPeer) == true
let decoder = JSONDecoder()
while let line = readLine(from: fd) {
@@ -3603,6 +3661,40 @@ private final class SocketListener: @unchecked Sendable {
else {
continue
}
if !registeredSession && isAuthenticatedAgentSession(
expectedToken: token,
requestToken: request.token,
authorizedPeer: authorizedPeer
) {
let monitor: AuthenticatedConnectionHangupMonitor
do {
monitor = try AuthenticatedConnectionHangupMonitor(
fileDescriptor: fd,
onHangup: { [weak self] in
self?.disconnectSession(connectionID)
}
)
} catch {
fputs("computer-use owner monitor failed: \(error)\n", stderr)
return
}
sessionLock.lock()
let registration = sessionOwnership.registerConnection(
connectionID,
authenticated: true
)
sessionLock.unlock()
guard registration != .rejected else {
monitor.cancel()
return
}
registeredSession = true
hangupMonitor = monitor
monitor.start()
if registration == .claimed {
onSessionClaimed()
}
}
let response = handleRequest(
provider: provider,
lock: providerLock,
@@ -3613,6 +3705,15 @@ private final class SocketListener: @unchecked Sendable {
writeJSON(response, to: fd)
}
}
private func disconnectSession(_ connectionID: AgentSessionConnectionID) {
sessionLock.lock()
let shouldTerminate = sessionOwnership.disconnect(connectionID)
sessionLock.unlock()
if shouldTerminate {
onSessionClosed()
}
}
}
private func existingPathMode(_ path: String) -> mode_t? {
@@ -0,0 +1,50 @@
public struct AgentSessionConnectionID: Hashable, Sendable {
public let rawValue: UInt64
public init(rawValue: UInt64) {
self.rawValue = rawValue
}
}
public enum AgentSessionRegistration: Sendable {
case rejected
case claimed
case retained
}
public struct AgentSessionOwnership: Sendable {
private var authenticatedConnections: Set<AgentSessionConnectionID> = []
private var wasClaimed = false
private var sessionClosed = false
public init() {}
public mutating func registerConnection(
_ connection: AgentSessionConnectionID,
authenticated: Bool
) -> AgentSessionRegistration {
guard authenticated, !sessionClosed else { return .rejected }
let inserted = authenticatedConnections.insert(connection).inserted
guard inserted else { return .rejected }
guard !wasClaimed else { return .retained }
wasClaimed = true
return .claimed
}
public mutating func disconnect(_ connection: AgentSessionConnectionID) -> Bool {
guard authenticatedConnections.remove(connection) != nil else { return false }
guard wasClaimed, authenticatedConnections.isEmpty else { return false }
sessionClosed = true
return true
}
}
public func isAuthenticatedAgentSession(
expectedToken: String?,
requestToken: String?,
authorizedPeer: Bool
) -> Bool {
guard authorizedPeer else { return false }
guard let expectedToken else { return true }
return requestToken == expectedToken
}
@@ -0,0 +1,182 @@
import Darwin
import Dispatch
import Foundation
public final class AuthenticatedConnectionHangupMonitor: @unchecked Sendable {
typealias RegisterEvents = @Sendable (
Int32,
UnsafePointer<kevent>?,
Int32
) -> POSIXErrorCode?
typealias CloseDescriptor = @Sendable (Int32) -> Void
private static let cancelEventIdentifier: UInt = 1
private let closeDescriptor: CloseDescriptor
private let eventQueue: DispatchQueue
private let eventQueueDescriptor: Int32
private let onEvent: @Sendable () -> Void
private let stateLock = NSLock()
private let onHangup: @Sendable () -> Void
private var isCancelled = false
private var didReportHangup = false
private var isFinished = false
private var isStarted = false
public convenience init(
fileDescriptor: Int32,
queue: DispatchQueue = DispatchQueue(
label: "com.stablyai.orca.computer-use-owner-hangup"
),
onEvent: @escaping @Sendable () -> Void = {},
onHangup: @escaping @Sendable () -> Void
) throws {
try self.init(
fileDescriptor: fileDescriptor,
queue: queue,
registerEvents: { descriptor, events, count in
guard kevent(descriptor, events, count, nil, 0, nil) == 0 else {
return POSIXErrorCode(rawValue: errno) ?? .EIO
}
return nil
},
closeDescriptor: { descriptor in
close(descriptor)
},
onEvent: onEvent,
onHangup: onHangup
)
}
init(
fileDescriptor: Int32,
queue: DispatchQueue = DispatchQueue(
label: "com.stablyai.orca.computer-use-owner-hangup"
),
registerEvents: RegisterEvents,
closeDescriptor: @escaping CloseDescriptor,
onEvent: @escaping @Sendable () -> Void = {},
onHangup: @escaping @Sendable () -> Void
) throws {
self.closeDescriptor = closeDescriptor
eventQueue = queue
self.onEvent = onEvent
self.onHangup = onHangup
let descriptor = kqueue()
guard descriptor >= 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
guard fileDescriptor >= 0 else {
closeDescriptor(descriptor)
throw POSIXError(.EBADF)
}
eventQueueDescriptor = descriptor
var registrations = [
kevent(
ident: UInt(fileDescriptor),
filter: Int16(EVFILT_READ),
flags: UInt16(EV_ADD | EV_CLEAR),
fflags: UInt32(NOTE_LOWAT),
data: Int.max,
udata: nil
),
kevent(
ident: Self.cancelEventIdentifier,
filter: Int16(EVFILT_USER),
flags: UInt16(EV_ADD | EV_CLEAR),
fflags: 0,
data: 0,
udata: nil
)
]
let registrationError = registrations.withUnsafeMutableBufferPointer { buffer in
registerEvents(descriptor, buffer.baseAddress, Int32(buffer.count))
}
if let registrationError {
isFinished = true
closeDescriptor(descriptor)
throw POSIXError(registrationError)
}
}
deinit {
cancel()
}
public func start() {
stateLock.lock()
guard !isStarted, !isCancelled, !isFinished else {
stateLock.unlock()
return
}
isStarted = true
stateLock.unlock()
eventQueue.async { [self] in
waitForHangup()
}
}
public func cancel() {
stateLock.lock()
guard !isFinished else {
stateLock.unlock()
return
}
isCancelled = true
guard isStarted else {
isFinished = true
closeDescriptor(eventQueueDescriptor)
stateLock.unlock()
return
}
var event = kevent(
ident: Self.cancelEventIdentifier,
filter: Int16(EVFILT_USER),
flags: 0,
fflags: UInt32(NOTE_TRIGGER),
data: 0,
udata: nil
)
_ = kevent(eventQueueDescriptor, &event, 1, nil, 0, nil)
stateLock.unlock()
}
private func waitForHangup() {
defer { finish() }
while true {
var event = kevent()
let result = kevent(eventQueueDescriptor, nil, 0, &event, 1, nil)
if result < 0 && errno == EINTR {
continue
}
guard result > 0 else { return }
onEvent()
if event.filter == Int16(EVFILT_USER) {
return
}
if event.flags & UInt16(EV_EOF | EV_ERROR) != 0 {
reportHangup()
return
}
}
}
private func finish() {
stateLock.lock()
isFinished = true
closeDescriptor(eventQueueDescriptor)
stateLock.unlock()
}
private func reportHangup() {
stateLock.lock()
guard !isCancelled, !didReportHangup else {
stateLock.unlock()
return
}
didReportHangup = true
stateLock.unlock()
onHangup()
}
}
@@ -0,0 +1,104 @@
import OrcaComputerUseMacOSCore
import XCTest
final class AgentSessionOwnershipTests: XCTestCase {
func testUnclaimedDisconnectDoesNotTerminateAgent() {
var ownership = AgentSessionOwnership()
XCTAssertFalse(ownership.disconnect(connection(12)))
}
func testUnauthenticatedConnectionCannotClaimOrRetainAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: false), .rejected)
XCTAssertFalse(ownership.disconnect(connection(12)))
}
func testLastAuthenticatedDisconnectTerminatesAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
XCTAssertTrue(ownership.disconnect(connection(12)))
}
func testAgentWaitsForEveryAuthenticatedConnectionToClose() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
XCTAssertEqual(ownership.registerConnection(connection(13), authenticated: true), .retained)
XCTAssertFalse(ownership.disconnect(connection(12)))
XCTAssertTrue(ownership.disconnect(connection(13)))
}
func testDuplicateRegistrationDoesNotRetainAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .rejected)
XCTAssertTrue(ownership.disconnect(connection(12)))
}
func testClosedSessionRejectsNewConnections() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
XCTAssertTrue(ownership.disconnect(connection(12)))
XCTAssertEqual(ownership.registerConnection(connection(13), authenticated: true), .rejected)
XCTAssertFalse(ownership.disconnect(connection(13)))
}
func testStaleDisconnectCannotRemoveReusedFileDescriptorOwner() {
var ownership = AgentSessionOwnership()
let staleConnection = connection(12)
let otherConnection = connection(13)
let reusedDescriptorConnection = connection(14)
XCTAssertEqual(ownership.registerConnection(staleConnection, authenticated: true), .claimed)
XCTAssertEqual(ownership.registerConnection(otherConnection, authenticated: true), .retained)
XCTAssertFalse(ownership.disconnect(staleConnection))
XCTAssertEqual(
ownership.registerConnection(reusedDescriptorConnection, authenticated: true),
.retained
)
XCTAssertFalse(ownership.disconnect(staleConnection))
XCTAssertFalse(ownership.disconnect(otherConnection))
XCTAssertTrue(ownership.disconnect(reusedDescriptorConnection))
}
func testTokenlessSessionStillRequiresAuthorizedPeer() {
XCTAssertFalse(isAuthenticatedAgentSession(
expectedToken: nil,
requestToken: nil,
authorizedPeer: false
))
XCTAssertTrue(isAuthenticatedAgentSession(
expectedToken: nil,
requestToken: nil,
authorizedPeer: true
))
}
func testTokenSessionRequiresAuthorizedPeerAndMatchingToken() {
XCTAssertFalse(isAuthenticatedAgentSession(
expectedToken: "expected",
requestToken: "expected",
authorizedPeer: false
))
XCTAssertFalse(isAuthenticatedAgentSession(
expectedToken: "expected",
requestToken: "wrong",
authorizedPeer: true
))
XCTAssertTrue(isAuthenticatedAgentSession(
expectedToken: "expected",
requestToken: "expected",
authorizedPeer: true
))
}
}
private func connection(_ rawValue: UInt64) -> AgentSessionConnectionID {
AgentSessionConnectionID(rawValue: rawValue)
}
@@ -0,0 +1,195 @@
import Darwin
import Dispatch
@testable import OrcaComputerUseMacOSCore
import XCTest
final class AuthenticatedConnectionHangupMonitorTests: XCTestCase {
func testRejectsInvalidDescriptorDuringSetup() {
XCTAssertThrowsError(
try AuthenticatedConnectionHangupMonitor(
fileDescriptor: -1,
onHangup: {}
)
)
}
func testRegistrationFailureClosesEventQueueOnce() {
let closes = DescriptorCloseRecorder()
XCTAssertThrowsError(
try AuthenticatedConnectionHangupMonitor(
fileDescriptor: 42,
registerEvents: { _, _, _ in .EBADF },
closeDescriptor: { descriptor in
closes.recordAndClose(descriptor)
},
onHangup: {}
)
)
XCTAssertEqual(closes.count, 1)
}
func testReportsPeerCloseWhileRequestHandlingIsBlocked() throws {
let descriptors = try makeSocketPair()
let hangup = expectation(description: "peer hangup")
let processingStarted = expectation(description: "processing started")
let releaseProcessing = DispatchSemaphore(value: 0)
let processingFinished = DispatchSemaphore(value: 0)
let monitor = try AuthenticatedConnectionHangupMonitor(
fileDescriptor: descriptors.local,
onHangup: {
hangup.fulfill()
}
)
monitor.start()
DispatchQueue.global().async {
processingStarted.fulfill()
releaseProcessing.wait()
processingFinished.signal()
}
wait(for: [processingStarted], timeout: 1)
close(descriptors.peer)
wait(for: [hangup], timeout: 1)
XCTAssertEqual(processingFinished.wait(timeout: .now() + 0.05), .timedOut)
releaseProcessing.signal()
XCTAssertEqual(processingFinished.wait(timeout: .now() + 1), .success)
monitor.cancel()
close(descriptors.local)
}
func testReadableDataDoesNotLookLikeHangup() throws {
let descriptors = try makeSocketPair()
let hangup = expectation(description: "peer hangup")
let callbacks = CallbackRecorder()
let events = CallbackRecorder()
let monitor = try AuthenticatedConnectionHangupMonitor(
fileDescriptor: descriptors.local,
onEvent: {
events.record()
},
onHangup: {
callbacks.record()
hangup.fulfill()
}
)
monitor.start()
var byte: UInt8 = 7
XCTAssertEqual(write(descriptors.peer, &byte, 1), 1)
usleep(50_000)
XCTAssertEqual(callbacks.count, 0)
XCTAssertEqual(events.count, 0)
close(descriptors.peer)
wait(for: [hangup], timeout: 1)
XCTAssertEqual(events.count, 1)
monitor.cancel()
close(descriptors.local)
}
func testCancelPreventsLaterHangupCallback() throws {
let descriptors = try makeSocketPair()
let hangup = expectation(description: "peer hangup")
hangup.isInverted = true
let monitor = try AuthenticatedConnectionHangupMonitor(
fileDescriptor: descriptors.local,
onHangup: {
hangup.fulfill()
}
)
monitor.start()
monitor.cancel()
close(descriptors.peer)
wait(for: [hangup], timeout: 0.1)
close(descriptors.local)
}
func testCancelReleasesMonitorWhileSocketStaysOpen() throws {
let descriptors = try makeSocketPair()
var monitor: AuthenticatedConnectionHangupMonitor? = try AuthenticatedConnectionHangupMonitor(
fileDescriptor: descriptors.local,
onHangup: {}
)
weak var retainedMonitor = monitor
monitor?.cancel()
monitor = nil
let deadline = Date().addingTimeInterval(1)
while retainedMonitor != nil, Date() < deadline {
usleep(10_000)
}
XCTAssertNil(retainedMonitor)
close(descriptors.peer)
close(descriptors.local)
}
func testStartReportsHangupQueuedAfterSuccessfulSetup() throws {
let descriptors = try makeSocketPair()
let hangup = expectation(description: "queued peer hangup")
let monitor = try AuthenticatedConnectionHangupMonitor(
fileDescriptor: descriptors.local,
onHangup: {
hangup.fulfill()
}
)
close(descriptors.peer)
monitor.start()
wait(for: [hangup], timeout: 1)
monitor.cancel()
close(descriptors.local)
}
}
private final class CallbackRecorder: @unchecked Sendable {
private let lock = NSLock()
private var recordedCount = 0
var count: Int {
lock.lock()
defer { lock.unlock() }
return recordedCount
}
func record() {
lock.lock()
recordedCount += 1
lock.unlock()
}
}
private final class DescriptorCloseRecorder: @unchecked Sendable {
private let lock = NSLock()
private var closeCount = 0
var count: Int {
lock.lock()
defer { lock.unlock() }
return closeCount
}
func recordAndClose(_ descriptor: Int32) {
lock.lock()
closeCount += 1
let shouldClose = closeCount == 1
lock.unlock()
if shouldClose {
close(descriptor)
}
}
}
private func makeSocketPair() throws -> (local: Int32, peer: Int32) {
var descriptors: [Int32] = [0, 0]
guard socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0 else {
throw POSIXError(.EIO)
}
return (descriptors[0], descriptors[1])
}
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.162-rc.0",
"version": "1.4.163-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -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",
@@ -103,8 +105,10 @@
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
"bench:macos-computer-helper-owner-loss": "node config/scripts/macos-computer-helper-owner-loss-benchmark.mjs",
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
"bench:hang-watchdog-memory": "pnpm run ensure:electron-runtime && node config/scripts/hang-watchdog-memory-benchmark.mjs",
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs",
"bench:worktree-deletion": "node tools/benchmarks/worktree-deletion-dev-bench.mjs",
"bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",
+5
View File
@@ -232,6 +232,11 @@ describe('supportsBrowserPageFlag', () => {
it('does not expose browser page targeting on local agent discovery', () => {
expect(supportsBrowserPageFlag(['agent-context'])).toBe(false)
})
it('does not expose browser page targeting on host-local account commands', () => {
expect(supportsBrowserPageFlag(['account', 'add'])).toBe(false)
expect(supportsBrowserPageFlag(['account', 'list'])).toBe(false)
})
})
describe('validateCommandAndFlags', () => {
+2
View File
@@ -152,6 +152,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
}
if (
[
'account',
'automations',
'project',
'repo',
@@ -198,6 +199,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
return (
(commandPath.length === 1 &&
[
'account',
'automations',
'project',
'repo',
+5
View File
@@ -17,6 +17,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
keys: ['claude-teams', 'open', 'serve', 'status'],
load: async () => (await import('./handlers/core.js')).CORE_HANDLERS
},
{
name: 'account',
keys: ['account add', 'account list'],
load: async () => (await import('./handlers/account.js')).ACCOUNT_HANDLERS
},
{
name: 'automations',
keys: [
+565
View File
@@ -0,0 +1,565 @@
import { EventEmitter } from 'node:events'
import { createHash } from 'node:crypto'
import { existsSync } from 'node:fs'
import { delimiter } from 'node:path'
import type * as NodeFs from 'node:fs'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
deleteKeychainMock,
getVersionManagerBinPathsMock,
readKeychainMock,
resolveCliCommandMock,
rmSyncMock,
spawnMock,
writeKeychainMock
} = vi.hoisted(() => ({
deleteKeychainMock: vi.fn(),
getVersionManagerBinPathsMock: vi.fn(),
readKeychainMock: vi.fn(),
resolveCliCommandMock: vi.fn(),
rmSyncMock: vi.fn(),
spawnMock: vi.fn(),
writeKeychainMock: vi.fn()
}))
// Why: keep real temp-dir cleanup by default so leak assertions stay honest,
// while allowing deterministic Windows EBUSY coverage.
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
rmSyncMock.mockImplementation(actual.rmSync)
return { ...actual, rmSync: rmSyncMock }
})
vi.mock('node:child_process', () => ({
execFile: vi.fn(),
execFileSync: vi.fn(),
spawn: spawnMock
}))
vi.mock('../../main/claude-accounts/keychain', () => ({
deleteActiveClaudeKeychainCredentialsStrict: deleteKeychainMock,
readActiveClaudeKeychainCredentialsStrict: readKeychainMock,
writeActiveClaudeKeychainCredentials: writeKeychainMock
}))
vi.mock('../../shared/node-cli-command-resolution', () => ({
getVersionManagerBinPaths: getVersionManagerBinPathsMock,
resolveCliCommand: resolveCliCommandMock
}))
import { ACCOUNT_HANDLERS } from './account'
import type { HandlerContext } from '../dispatch'
import type { RuntimeClient } from '../runtime-client'
import { getCmdExePath } from '../../shared/windows-batch-spawn'
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
function successfulChild(): EventEmitter {
const child = new EventEmitter()
queueMicrotask(() => child.emit('exit', 0))
return child
}
// Why: identify the handler under test by set difference, not by position —
// `.at(-1)` picks up any listener a later registration appends (vitest installs
// its own once-wrapped SIGINT teardown), which made assertions flake.
function newSignalListener(
signal: NodeJS.Signals,
before: readonly unknown[]
): (signal: NodeJS.Signals) => void {
const added = process.listeners(signal).filter((listener) => !before.includes(listener))
if (added.length !== 1) {
throw new Error(`Expected 1 new ${signal} listener, found ${added.length}`)
}
return added[0] as (signal: NodeJS.Signals) => void
}
function accountState(email: string) {
return {
accounts: [{ id: 'account-1', email }],
activeAccountId: 'account-1',
activeAccountIdsByRuntime: { host: 'account-1', wsl: {} }
}
}
describe('account CLI handlers', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
const originalElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE
const originalPathAlias = process.env.Path
const callMock = vi.fn()
const client = { call: callMock } as unknown as RuntimeClient
let logSpy: ReturnType<typeof vi.spyOn>
function context(agent: string, json = false): HandlerContext {
return {
client,
cwd: process.cwd(),
flags: new Map([['agent', agent]]),
json,
rawArgs: []
}
}
beforeEach(() => {
Object.defineProperty(process, 'platform', originalPlatform)
spawnMock.mockReset().mockImplementation(() => successfulChild())
resolveCliCommandMock.mockReset().mockImplementation((command: string) => command)
getVersionManagerBinPathsMock.mockReset().mockReturnValue([])
readKeychainMock.mockReset().mockResolvedValue(null)
deleteKeychainMock.mockReset().mockResolvedValue(undefined)
writeKeychainMock.mockReset().mockResolvedValue(undefined)
callMock.mockReset().mockImplementation((method: string) =>
Promise.resolve({
id: 'test',
ok: true,
result:
method === 'status.get'
? { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] }
: accountState(method.includes('Claude') ? 'claude@example.com' : 'codex@example.com'),
_meta: { runtimeId: 'test-runtime' }
})
)
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
process.env.ELECTRON_RUN_AS_NODE = '1'
})
afterEach(() => {
Object.defineProperty(process, 'platform', originalPlatform)
logSpy.mockRestore()
if (originalElectronRunAsNode === undefined) {
delete process.env.ELECTRON_RUN_AS_NODE
} else {
process.env.ELECTRON_RUN_AS_NODE = originalElectronRunAsNode
}
if (originalPathAlias === undefined) {
delete process.env.Path
} else {
process.env.Path = originalPathAlias
}
})
it('uses Codex device auth and keeps JSON stdout clean', async () => {
await ACCOUNT_HANDLERS['account add'](context('codex', true))
expect(spawnMock).toHaveBeenCalledWith(
'codex',
['login', '--device-auth'],
expect.objectContaining({
stdio: ['inherit', process.stderr, 'inherit'],
env: expect.objectContaining({ CODEX_HOME: expect.any(String) })
})
)
const spawnOptions = spawnMock.mock.calls[0]?.[2]
expect(spawnOptions.env.ELECTRON_RUN_AS_NODE).toBeUndefined()
expect(existsSync(spawnOptions.env.CODEX_HOME)).toBe(false)
expect(callMock).toHaveBeenCalledWith('accounts.addCodexFromHome', {
sourceHome: spawnOptions.env.CODEX_HOME
})
})
it('routes Windows package-manager shims through the safe cmd launcher', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
resolveCliCommandMock.mockReturnValue('C:\\tools\\codex.cmd')
await ACCOUNT_HANDLERS['account add'](context('codex'))
expect(spawnMock).toHaveBeenCalledWith(
getCmdExePath(),
['/d', '/c', 'C:\\tools\\codex.cmd', 'login', '--device-auth'],
expect.objectContaining({ stdio: ['inherit', 'inherit', 'inherit'] })
)
})
it('adds version-manager Node paths to the login child environment', async () => {
const nodeBin = '/home/test/.nvm/versions/node/v22.0.0/bin'
getVersionManagerBinPathsMock.mockReturnValue([nodeBin])
await ACCOUNT_HANDLERS['account add'](context('codex'))
const path = spawnMock.mock.calls[0]?.[2].env.PATH as string
expect(path.split(delimiter)[0]).toBe(nodeBin)
})
it('updates the effective Windows PATH regardless of native environment casing', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
process.env.Path = 'C:\\stale'
const nodeBin = 'C:\\Users\\test\\.volta\\bin'
const effectivePathBefore = process.env.PATH ?? process.env.Path ?? ''
getVersionManagerBinPathsMock.mockReturnValue([nodeBin])
await ACCOUNT_HANDLERS['account add'](context('codex'))
const env = spawnMock.mock.calls[0]?.[2].env as NodeJS.ProcessEnv
const pathValues = Object.entries(env)
.filter(([key]) => key.toLowerCase() === 'path')
.map(([, value]) => value)
expect(pathValues).toContain(`${nodeBin}${delimiter}${effectivePathBefore}`)
})
it('removes scoped Claude credentials and restores the legacy Keychain item', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readKeychainMock.mockResolvedValue('legacy-credentials')
await ACCOUNT_HANDLERS['account add'](context('claude'))
const configDir = spawnMock.mock.calls[0]?.[2].env.CLAUDE_CONFIG_DIR
expect(deleteKeychainMock).toHaveBeenCalledWith(configDir)
expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials')
expect(callMock).toHaveBeenCalledWith('accounts.addClaudeFromConfigDir', {
configDir,
previousLegacyCredentialsSha256: createHash('sha256')
.update('legacy-credentials')
.digest('hex')
})
expect(existsSync(configDir)).toBe(false)
})
it('waits for physical child close before removing interrupted login credentials', async () => {
// Why: deleting first lets the still-live login recreate credentials afterward.
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
const kill = vi.fn()
const child = Object.assign(new EventEmitter(), { kill })
let codexHome = ''
spawnMock.mockImplementation((_command, _args, options: { env: Record<string, string> }) => {
codexHome = options.env.CODEX_HOME
return child
})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
const listenersBefore = process.listeners('SIGINT')
const pending = ACCOUNT_HANDLERS['account add'](context('codex')).catch(() => {})
await vi.waitFor(() => expect(codexHome).not.toBe(''))
expect(existsSync(codexHome)).toBe(true)
newSignalListener('SIGINT', listenersBefore)('SIGINT')
await vi.waitFor(() => expect(kill).toHaveBeenCalledWith('SIGINT'))
expect(exitSpy).not.toHaveBeenCalled()
expect(existsSync(codexHome)).toBe(true)
child.emit('exit', 1)
child.emit('close', 1)
await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(130))
expect(existsSync(codexHome)).toBe(false)
expect(callMock).not.toHaveBeenCalledWith('accounts.addCodexFromHome', expect.anything())
await pending
exitSpy.mockRestore()
})
it('cleans up when an SSH hangup ends the login', async () => {
// Why: this flow targets headless/SSH hosts, where a dropped connection
// delivers SIGHUP — Node's default terminates without running cleanup.
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
const child = Object.assign(new EventEmitter(), { kill: vi.fn() })
let codexHome = ''
spawnMock.mockImplementation((_command, _args, options: { env: Record<string, string> }) => {
codexHome = options.env.CODEX_HOME
return child
})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
const listenersBefore = process.listeners('SIGHUP')
const pending = ACCOUNT_HANDLERS['account add'](context('codex')).catch(() => {})
await vi.waitFor(() => expect(codexHome).not.toBe(''))
newSignalListener('SIGHUP', listenersBefore)('SIGHUP')
await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGHUP'))
expect(exitSpy).not.toHaveBeenCalled()
expect(existsSync(codexHome)).toBe(true)
child.emit('exit', 1)
child.emit('close', 1)
await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(129))
expect(existsSync(codexHome)).toBe(false)
await pending
exitSpy.mockRestore()
})
it('waits for in-flight cleanup when a second signal arrives', async () => {
// Why: a boolean latch lets the second signal's process.exit fire while the
// first cleanup is still inside a Keychain call, stranding the credentials.
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readKeychainMock.mockResolvedValue('legacy-credentials')
let releaseKeychainDelete: (() => void) | undefined
deleteKeychainMock.mockImplementationOnce(
() =>
new Promise<void>((resolvePromise) => {
releaseKeychainDelete = () => resolvePromise()
})
)
const child = Object.assign(new EventEmitter(), { kill: vi.fn() })
let configDir = ''
spawnMock.mockImplementation((_command, _args, options: { env: Record<string, string> }) => {
configDir = options.env.CLAUDE_CONFIG_DIR
return child
})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
const sigintBefore = process.listeners('SIGINT')
const sigtermBefore = process.listeners('SIGTERM')
const pending = ACCOUNT_HANDLERS['account add'](context('claude')).catch(() => {})
await vi.waitFor(() => expect(configDir).not.toBe(''))
const onSigint = newSignalListener('SIGINT', sigintBefore)
const onSigterm = newSignalListener('SIGTERM', sigtermBefore)
// Why: `rawListeners` exposes the `once` wrapper, so this fails if the handler
// is registered with `once` — where a second Ctrl-C falls through to Node's
// default and kills the process mid-cleanup.
expect(process.rawListeners('SIGINT')).toContain(onSigint)
onSigint('SIGINT')
await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGINT'))
child.emit('exit', 1)
child.emit('close', 1)
await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalledWith(configDir))
onSigterm('SIGTERM')
await new Promise<void>((resolvePromise) => {
setImmediate(resolvePromise)
})
expect(exitSpy).not.toHaveBeenCalled()
expect(writeKeychainMock).not.toHaveBeenCalled()
expect(existsSync(configDir)).toBe(true)
releaseKeychainDelete?.()
await vi.waitFor(() => expect(exitSpy).toHaveBeenCalled())
expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials')
expect(existsSync(configDir)).toBe(false)
expect(child.kill).toHaveBeenCalledOnce()
await pending
exitSpy.mockRestore()
})
it('warns that the account may already be registered when interrupted mid-RPC', async () => {
// Why: the runtime finishes the add independently of this process, so an
// interrupt after sign-in cannot honestly be reported as "not added".
const child = Object.assign(new EventEmitter(), { kill: vi.fn() })
spawnMock.mockImplementation(() => {
queueMicrotask(() => child.emit('exit', 0))
return child
})
// Why: only the registration RPC hangs — the preflight must still resolve.
callMock.mockImplementation((method: string) =>
method === 'status.get'
? Promise.resolve({
id: 'test',
ok: true,
result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] },
_meta: { runtimeId: 'test-runtime' }
})
: method === 'accounts.list'
? Promise.resolve({
id: 'test',
ok: true,
result: { claude: accountState('c@e.com'), codex: accountState('x@e.com') },
_meta: { runtimeId: 'test-runtime' }
})
: new Promise(() => {})
)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never)
const listenersBefore = process.listeners('SIGINT')
void ACCOUNT_HANDLERS['account add'](context('codex')).catch(() => {})
await vi.waitFor(() =>
expect(callMock).toHaveBeenCalledWith('accounts.addCodexFromHome', expect.anything())
)
newSignalListener('SIGINT', listenersBefore)('SIGINT')
await vi.waitFor(() => expect(exitSpy).toHaveBeenCalledWith(130))
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('may still have been registered'))
warnSpy.mockRestore()
exitSpy.mockRestore()
})
it('stays armed for signals until post-success cleanup finishes', async () => {
// Why: detaching the handlers before cleanup leaves the multi-second Keychain
// calls covered only by Node's default handling, which kills mid-cleanup.
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readKeychainMock.mockResolvedValue('legacy-credentials')
let releaseKeychainDelete: (() => void) | undefined
deleteKeychainMock.mockImplementationOnce(
() =>
new Promise<void>((resolvePromise) => {
releaseKeychainDelete = () => resolvePromise()
})
)
const listenersBefore = process.listeners('SIGINT')
const pending = ACCOUNT_HANDLERS['account add'](context('claude'))
await vi.waitFor(() => expect(deleteKeychainMock).toHaveBeenCalled())
// Why: cleanup is still in flight here, so this add's guard must still be installed.
const handler = newSignalListener('SIGINT', listenersBefore)
releaseKeychainDelete?.()
await pending
expect(process.listeners('SIGINT')).not.toContain(handler)
})
it('fails before the login when the runtime is unreachable', async () => {
// Why: discovering a dead runtime after sign-in wastes a full OAuth round trip.
callMock.mockRejectedValue(new Error('runtime not running'))
await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow(
'runtime not running'
)
expect(callMock).toHaveBeenCalledWith('status.get')
expect(spawnMock).not.toHaveBeenCalled()
})
it('fails before login when the running runtime predates account imports', async () => {
callMock.mockResolvedValue({
id: 'test',
ok: true,
result: { capabilities: [] },
_meta: { runtimeId: 'test-runtime' }
})
await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow(
'runtime is too old'
)
expect(callMock).toHaveBeenCalledOnce()
expect(callMock).toHaveBeenCalledWith('status.get')
expect(spawnMock).not.toHaveBeenCalled()
})
it.each(['environment', 'pairing-code'])(
'rejects --%s instead of silently ignoring it',
async (flag) => {
// Why: account commands are pinned to the local runtime, so honoring these
// silently would register the account on the wrong host.
await expect(
ACCOUNT_HANDLERS['account add']({
...context('codex'),
flags: new Map<string, string | boolean>([
['agent', 'codex'],
[flag, 'homelab']
])
})
).rejects.toThrow(`\`--${flag}\` does not retarget`)
expect(spawnMock).not.toHaveBeenCalled()
}
)
it.each(['environment', 'pairing-code'])(
'rejects --%s on `account list` instead of listing the local host',
async (flag) => {
// Why: listing is read-only, but answering with the LOCAL machine's accounts
// when the user named a remote host is the specific wrong answer they'd act on.
await expect(
ACCOUNT_HANDLERS['account list']({
...context('claude'),
flags: new Map<string, string | boolean>([[flag, 'homelab']])
})
).rejects.toThrow(`\`--${flag}\` does not retarget`)
expect(callMock).not.toHaveBeenCalled()
}
)
it('keeps the original add error when cleanup also fails', async () => {
// Why: cleanup runs in a `finally`, so an unguarded rejection there replaces
// the error that actually explains why the add failed.
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
rmSyncMock.mockImplementationOnce(() => {
throw new Error('EBUSY: resource busy or locked')
})
callMock.mockImplementation((method: string) =>
method === 'status.get'
? Promise.resolve({
id: 'test',
ok: true,
result: { capabilities: [ACCOUNT_IMPORT_RUNTIME_CAPABILITY] },
_meta: { runtimeId: 'test-runtime' }
})
: method === 'accounts.list'
? Promise.resolve({
id: 'test',
ok: true,
result: { claude: accountState('c@e.com'), codex: accountState('x@e.com') },
_meta: { runtimeId: 'test-runtime' }
})
: Promise.reject(new Error('registration rejected by runtime'))
)
await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow(
'registration rejected by runtime'
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to clean up the temporary login directory'),
expect.any(Error)
)
warnSpy.mockRestore()
})
it('fails a successful add when the temporary credentials cannot be removed', async () => {
rmSyncMock.mockImplementationOnce(() => {
throw new Error('EBUSY: resource busy or locked')
})
await expect(ACCOUNT_HANDLERS['account add'](context('codex', true))).rejects.toThrow('EBUSY')
expect(logSpy).not.toHaveBeenCalled()
})
it('fails a successful Claude add when Keychain cleanup fails', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
readKeychainMock.mockResolvedValue('legacy-credentials')
deleteKeychainMock.mockRejectedValueOnce(new Error('Keychain denied cleanup'))
await expect(ACCOUNT_HANDLERS['account add'](context('claude'))).rejects.toThrow(
'Failed to clean up Claude login artifacts'
)
expect(writeKeychainMock).toHaveBeenCalledWith('legacy-credentials')
})
it('rejects `--agent` with no value instead of defaulting to Claude', async () => {
// Why: the parser turns a valueless flag into boolean true, so a silent
// default would run a full OAuth login for the wrong provider.
await expect(
ACCOUNT_HANDLERS['account add']({ ...context('claude'), flags: new Map([['agent', true]]) })
).rejects.toThrow('Missing a value for --agent')
expect(spawnMock).not.toHaveBeenCalled()
})
it('marks an account selected for WSL as active in human output', async () => {
callMock.mockResolvedValue({
id: 'test',
ok: true,
result: {
claude: {
accounts: [{ id: 'claude-wsl', email: 'claude@example.com' }],
activeAccountId: null,
activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'claude-wsl' } }
},
codex: { accounts: [], activeAccountId: null }
},
_meta: { runtimeId: 'test-runtime' }
})
await ACCOUNT_HANDLERS['account list']({ ...context('claude'), flags: new Map() })
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('claude@example.com (active)'))
})
it('lists accounts without forcing a provider usage refresh', async () => {
// Why: the forced lane bypasses the poll throttle and costs one serial
// round-trip per managed account, and this output shows no usage numbers.
callMock.mockResolvedValue({
id: 'test',
ok: true,
result: {
claude: { accounts: [], activeAccountId: null },
codex: { accounts: [], activeAccountId: null }
},
_meta: { runtimeId: 'test-runtime' }
})
await ACCOUNT_HANDLERS['account list']({ ...context('claude'), flags: new Map() })
expect(callMock).toHaveBeenCalledWith('accounts.list', { refreshUsage: false })
})
})
+301
View File
@@ -0,0 +1,301 @@
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import type { CommandHandler, HandlerContext } from '../dispatch'
import { printResult } from '../format'
import { RuntimeClientError } from '../runtime-client'
import { stripElectronRunAsNode } from '../runtime/launch'
import {
deleteActiveClaudeKeychainCredentialsStrict,
readActiveClaudeKeychainCredentialsStrict,
writeActiveClaudeKeychainCredentials
} from '../../main/claude-accounts/keychain'
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'
import {
type InteractiveLoginSession,
withInteractiveLoginCleanup
} from './interactive-login-interruption'
// Why: add returns just that provider's state; list returns the full snapshot.
type AccountsListSnapshot = {
claude: ClaudeRateLimitAccountsState
codex: CodexRateLimitAccountsState
}
// Why: Claude and Codex managed-account summaries both carry id+email+active id,
// so one formatter renders either provider's block.
type AccountsBlock = {
accounts: readonly { id: string; email: string }[]
activeAccountId: string | null
activeAccountIdsByRuntime?: {
host: string | null
wsl: Record<string, string | null>
}
}
/** Renders a provider's managed-account list as a human-readable block, marking the active account. */
function formatAccountsBlock(label: string, block: AccountsBlock): string {
if (block.accounts.length === 0) {
return `No managed ${label} accounts.`
}
const activeAccountIds = new Set([
block.activeAccountId,
block.activeAccountIdsByRuntime?.host,
...Object.values(block.activeAccountIdsByRuntime?.wsl ?? {})
])
const lines = block.accounts.map(
(account) => ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}`
)
return `Managed ${label} accounts (${block.accounts.length}):\n${lines.join('\n')}`
}
function addAgentNodePaths(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const pathKey =
process.platform === 'win32' && env.Path !== undefined && env.PATH === undefined
? 'Path'
: 'PATH'
const currentEntries = (env[pathKey] ?? '').split(delimiter).filter(Boolean)
const existing = new Set(currentEntries)
const missing = getVersionManagerBinPaths().filter((entry) => !existing.has(entry))
if (missing.length > 0) {
env[pathKey] = [...missing, ...currentEntries].join(delimiter)
}
return env
}
/**
* Runs the real agent login attached to the user's terminal so the OAuth
* URL/device-code prompt is visible and the code can be pasted back — the desktop
* GUI flow drives this via a browser Orca can't reach on a headless host.
*/
async function runAgentLoginInTerminal(
command: string,
args: string[],
extraEnv: Record<string, string>,
json: boolean,
session: InteractiveLoginSession
): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const resolvedCommand = resolveCliCommand(command)
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedCommand, args)
const env = addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv })
const child = spawn(spawnCmd, spawnArgs, {
// Why: JSON mode reserves stdout for the response envelope while keeping
// the interactive login attached to the user's terminal via stderr.
stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'],
env
})
session.child = child
child.once('error', (error) =>
rejectPromise(
new RuntimeClientError(
'internal',
`Could not launch \`${command}\`. Is it installed and on PATH? (${
error instanceof Error ? error.message : String(error)
})`
)
)
)
child.once('exit', (code) => {
session.child = null
if (code === 0) {
resolvePromise()
return
}
rejectPromise(
new RuntimeClientError(
'internal',
`\`${command} ${args.join(' ')}\` exited with code ${code ?? 'null'}.`
)
)
})
})
}
async function cleanupClaudeLoginArtifacts(
configDir: string,
legacyCredentials: string | null,
restoreLegacyCredentials: boolean
): Promise<void> {
const errors: unknown[] = []
if (process.platform === 'darwin') {
try {
await deleteActiveClaudeKeychainCredentialsStrict(configDir)
} catch (error) {
errors.push(error)
}
if (restoreLegacyCredentials) {
try {
await (legacyCredentials
? writeActiveClaudeKeychainCredentials(legacyCredentials)
: deleteActiveClaudeKeychainCredentialsStrict())
} catch (error) {
errors.push(error)
}
}
}
try {
rmSync(configDir, { recursive: true, force: true })
} catch (error) {
errors.push(error)
}
if (errors.length > 0) {
throw new AggregateError(errors, 'Failed to clean up Claude login artifacts.')
}
}
/** Logs into a Claude account in a temp config dir, then registers it with the local runtime. */
async function addClaudeAccount({ client, json }: HandlerContext): Promise<void> {
const configDir = mkdtempSync(join(tmpdir(), 'orca-account-add-claude-'))
const session: InteractiveLoginSession = {
child: null,
registering: false,
terminationPromise: null
}
let legacyCredentials: string | null = null
let restoreLegacyCredentials = false
const result = await withInteractiveLoginCleanup(
session,
async () => {
await cleanupClaudeLoginArtifacts(configDir, legacyCredentials, restoreLegacyCredentials)
},
async () => {
if (process.platform === 'darwin') {
legacyCredentials = await readActiveClaudeKeychainCredentialsStrict()
restoreLegacyCredentials = true
}
await runAgentLoginInTerminal(
'claude',
['auth', 'login', '--claudeai'],
{
CLAUDE_CONFIG_DIR: configDir
},
json,
session
)
session.registering = true
return client.call<ClaudeRateLimitAccountsState>('accounts.addClaudeFromConfigDir', {
configDir,
...(process.platform === 'darwin'
? {
previousLegacyCredentialsSha256: legacyCredentials
? createHash('sha256').update(legacyCredentials).digest('hex')
: null
}
: {})
})
}
)
printResult(result, json, (state) => formatAccountsBlock('Claude', state))
}
/** Logs into a Codex account in a temp CODEX_HOME, then registers it with the local runtime. */
async function addCodexAccount({ client, json }: HandlerContext): Promise<void> {
const codexHome = mkdtempSync(join(tmpdir(), 'orca-account-add-codex-'))
const session: InteractiveLoginSession = {
child: null,
registering: false,
terminationPromise: null
}
const result = await withInteractiveLoginCleanup(
session,
async () => {
rmSync(codexHome, { recursive: true, force: true })
},
async () => {
// Why: plain OAuth binds a loopback callback the user's browser cannot reach
// on a headless/SSH host; device auth is explicitly designed for this flow.
await runAgentLoginInTerminal(
'codex',
['login', '--device-auth'],
{ CODEX_HOME: codexHome },
json,
session
)
session.registering = true
return client.call<CodexRateLimitAccountsState>('accounts.addCodexFromHome', {
sourceHome: codexHome
})
}
)
printResult(result, json, (state) => formatAccountsBlock('Codex', state))
}
/**
* Rejects the runtime-selector flags instead of ignoring them. shouldIgnoreRemoteSelection
* pins account commands to the local runtime, so honoring `--environment homelab`
* silently would target the laptop rather than the host the user named — the exact
* mistake this feature exists to avoid. A `--help` note does not reach someone who
* already typed the flag.
*/
function rejectRemoteSelectionFlags(ctx: HandlerContext, command: string): void {
for (const flag of ['environment', 'pairing-code']) {
if (ctx.flags.has(flag)) {
throw new RuntimeClientError(
'invalid_argument',
`\`--${flag}\` does not retarget \`${command}\`. Run it on the host whose accounts you want to manage.`
)
}
}
}
async function assertAccountImportSupported({ client }: HandlerContext): Promise<void> {
const status = await client.call<RuntimeStatus>('status.get')
if (!status.result.capabilities?.includes(ACCOUNT_IMPORT_RUNTIME_CAPABILITY)) {
throw new RuntimeClientError(
'incompatible_runtime',
'The running Orca runtime is too old to add accounts from the CLI. Update or restart Orca and try again.'
)
}
}
/** CLI handlers for `orca account add [--agent claude|codex]` and `orca account list`. */
export const ACCOUNT_HANDLERS: Record<string, CommandHandler> = {
'account add': async (ctx) => {
const agentFlag = ctx.flags.get('agent')
// Why: a valueless `--agent` parses as boolean true; defaulting it to claude
// would silently run a full OAuth login for the provider the user did not ask for.
if (agentFlag !== undefined && typeof agentFlag !== 'string') {
throw new RuntimeClientError(
'invalid_argument',
'Missing a value for --agent. Use `--agent claude` or `--agent codex`.'
)
}
const agent = agentFlag ?? 'claude'
if (agent !== 'claude' && agent !== 'codex') {
throw new RuntimeClientError(
'invalid_argument',
`Unsupported --agent "${agent}". Use "claude" or "codex".`
)
}
rejectRemoteSelectionFlags(ctx, 'orca account add')
// Why: fail on runtime version skew before burning a full OAuth round trip.
await assertAccountImportSupported(ctx)
await ctx.client.call('accounts.list', { refreshUsage: false })
await (agent === 'claude' ? addClaudeAccount(ctx) : addCodexAccount(ctx))
},
'account list': async (ctx) => {
rejectRemoteSelectionFlags(ctx, 'orca account list')
const { client, json } = ctx
// Why: this command renders no usage numbers, so skip the forced provider
// refresh — it is one serial network round-trip per managed account.
const result = await client.call<AccountsListSnapshot>('accounts.list', {
refreshUsage: false
})
printResult(
result,
json,
(snapshot) =>
`${formatAccountsBlock('Claude', snapshot.claude)}\n\n${formatAccountsBlock('Codex', snapshot.codex)}`
)
}
}

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