mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/mobile-i18n-audit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -64,11 +64,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: building once avoids five parallel electron-vite builds inside
|
||||
# Playwright globalSetup, which otherwise contends for CPU/RAM on OSS
|
||||
# runners before the sharded tests even start.
|
||||
# Why: building here avoids parallel builds inside Playwright globalSetup;
|
||||
# paired-browser specs also need the standalone web bundle.
|
||||
- name: Build Electron app for E2E
|
||||
run: npx electron-vite build --mode e2e
|
||||
env:
|
||||
VITE_EXPOSE_STORE: 'true'
|
||||
run: |
|
||||
npx electron-vite build --mode e2e
|
||||
pnpm run build:web-from-renderer
|
||||
|
||||
- name: Upload E2E build output
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -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"
|
||||
@@ -276,6 +276,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.
|
||||
@@ -334,6 +386,7 @@ jobs:
|
||||
- shell_contracts
|
||||
- test
|
||||
- package
|
||||
- package_windows
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -353,6 +406,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" \
|
||||
@@ -360,7 +414,8 @@ jobs:
|
||||
"$GIT_COMPATIBILITY" \
|
||||
"$SHELL_CONTRACTS" \
|
||||
"$TEST" \
|
||||
"$PACKAGE"; do
|
||||
"$PACKAGE" \
|
||||
"$PACKAGE_WINDOWS"; do
|
||||
if [ "$result" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
@@ -143,21 +153,16 @@ module.exports = {
|
||||
'out/main/claude/**',
|
||||
'out/main/claude-accounts/keychain.js',
|
||||
'out/main/codex/**',
|
||||
'out/main/codex-cli/command.js',
|
||||
'out/main/copilot/**',
|
||||
'out/main/cursor/**',
|
||||
'out/main/droid/**',
|
||||
'out/main/gemini/**',
|
||||
'out/main/grok/**',
|
||||
'out/main/hermes/**',
|
||||
'out/main/ipc/local-agent-install-dir-detection.js',
|
||||
'out/main/ipc/tui-agent-detection-commands.js',
|
||||
'out/main/win32-utils.js',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
'out/main/main-thread-hang-watchdog-entry.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/ws/**',
|
||||
@@ -183,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' }
|
||||
@@ -213,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
|
||||
@@ -316,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'),
|
||||
@@ -456,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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 +3102,10 @@
|
||||
"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",
|
||||
"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-headless --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
@@ -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,17 @@
|
||||
"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",
|
||||
"cold-activates only visible paired terminals against an isolated headless host",
|
||||
"the headless host shares the zero-hidden-renderer-work and exact-restoration contract"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-parked-memory.spec.ts",
|
||||
@@ -3231,6 +3243,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 +3260,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",
|
||||
@@ -3291,13 +3315,13 @@
|
||||
"summary": "An isolated `orca serve` host and paired web renderer exhausted one terminal stream, recovered it, and preserved the original PTY and tab without exposing readiness or pairing material."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"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",
|
||||
"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-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.2,
|
||||
"summary": "The isolated headless `orca serve` host passed the byte-identical six-PTY ordinary-parking, memory/lag, deep-history, same-PTY, parked-output, and post-reveal I/O oracle."
|
||||
"durationSeconds": 23,
|
||||
"summary": "The isolated headless `orca serve` host passed ordinary parking plus cold activation: deferred tabs stayed unmounted until activation, preserved PTY identity and parked output, and resumed live input."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
@@ -3316,6 +3340,15 @@
|
||||
"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."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -3328,25 +3361,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 +6463,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 +6486,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 +6518,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 +6553,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 +6610,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 +6631,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')
|
||||
|
||||
@@ -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(
|
||||
@@ -158,11 +191,7 @@ describe('electron-builder config', () => {
|
||||
'out/package.json',
|
||||
'out/cli/**',
|
||||
'out/shared/**',
|
||||
'out/main/claude-accounts/keychain.js',
|
||||
'out/main/codex-cli/command.js',
|
||||
'out/main/ipc/local-agent-install-dir-detection.js',
|
||||
'out/main/ipc/tui-agent-detection-commands.js',
|
||||
'out/main/win32-utils.js'
|
||||
'out/main/claude-accounts/keychain.js'
|
||||
])
|
||||
)
|
||||
})
|
||||
@@ -175,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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -273,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)
|
||||
@@ -486,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 () => {
|
||||
@@ -509,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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -34,4 +34,14 @@ describe('release E2E dispatch contract', () => {
|
||||
expect(refInput.type).toBe('string')
|
||||
expect(refInput.required).toBe(false)
|
||||
})
|
||||
|
||||
it('includes the paired-runtime web client in the shared E2E build artifact', () => {
|
||||
const buildStep = e2eWorkflow.jobs.build.steps.find(
|
||||
(step) => step.name === 'Build Electron app for E2E'
|
||||
)
|
||||
|
||||
expect(buildStep.run).toContain('electron-vite build --mode e2e')
|
||||
expect(buildStep.env.VITE_EXPOSE_STORE).toBe('true')
|
||||
expect(buildStep.run).toContain('pnpm run build:web-from-renderer')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { getDefaultUserDataPath } from '../../src/cli/runtime/metadata'
|
||||
import type { PairingOffer } from '../../src/shared/pairing'
|
||||
import { RemoteRuntimeSharedControlConnection } from '../../src/shared/remote-runtime-shared-control-connection'
|
||||
import {
|
||||
resolveEnvironment,
|
||||
resolveEnvironmentPairingOffer
|
||||
} from '../../src/shared/runtime-environment-store'
|
||||
import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME
|
||||
if (!environmentName) {
|
||||
throw new Error('ORCA_PROBE_ENVIRONMENT_NAME is required')
|
||||
}
|
||||
const userDataPath = getDefaultUserDataPath()
|
||||
const environment = resolveEnvironment(userDataPath, environmentName)
|
||||
const pairing = resolveEnvironmentPairingOffer(userDataPath, environment.id)
|
||||
const cycles = readProbeInteger('ORCA_PROBE_CYCLES', 10, 100)
|
||||
const concurrency = readProbeInteger('ORCA_PROBE_CONCURRENCY', 25, 200)
|
||||
const settleMs = readProbeInteger('ORCA_PROBE_SETTLE_MS', 250, 5_000)
|
||||
const cleanupTimeoutMs = readProbeInteger('ORCA_PROBE_CLEANUP_TIMEOUT_MS', 10_000, 30_000)
|
||||
const unknownResponses = new Map<string, number>()
|
||||
const originalWarn = console.warn
|
||||
console.warn = (message?: unknown, details?: unknown): void => {
|
||||
if (
|
||||
message === '[remote-runtime.shared-control] unknown response id' &&
|
||||
typeof details === 'object' &&
|
||||
details !== null
|
||||
) {
|
||||
const responseId = String((details as { responseId?: unknown }).responseId ?? 'unknown')
|
||||
unknownResponses.set(responseId, (unknownResponses.get(responseId) ?? 0) + 1)
|
||||
return
|
||||
}
|
||||
originalWarn(message, details)
|
||||
}
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const before = await requestMemorySnapshot(pairing, environment.id)
|
||||
let ok = 0
|
||||
let subscriptionResponses = 0
|
||||
let runtimeStatus: RuntimeStatus | null = null
|
||||
const cleanupDurationsMs: number[] = []
|
||||
for (let cycle = 0; cycle < cycles; cycle += 1) {
|
||||
const result = await runCycle({
|
||||
pairing,
|
||||
environmentId: environment.id,
|
||||
concurrency,
|
||||
settleMs,
|
||||
cleanupTimeoutMs
|
||||
})
|
||||
ok += result.ok
|
||||
subscriptionResponses += result.subscriptionResponses
|
||||
runtimeStatus ??= result.runtimeStatus
|
||||
cleanupDurationsMs.push(result.cleanupDurationMs)
|
||||
}
|
||||
await wait(settleMs)
|
||||
const after = await requestMemorySnapshot(pairing, environment.id)
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
environment: { id: environment.id, name: environment.name },
|
||||
runtime: runtimeStatus
|
||||
? {
|
||||
appVersion: runtimeStatus.appVersion ?? null,
|
||||
capabilities: runtimeStatus.capabilities ?? [],
|
||||
hostPlatform: runtimeStatus.hostPlatform ?? null
|
||||
}
|
||||
: null,
|
||||
cycles,
|
||||
concurrency,
|
||||
requests: cycles * concurrency,
|
||||
ok,
|
||||
subscriptionResponses,
|
||||
cleanupDurationMs: {
|
||||
average: Math.round(
|
||||
cleanupDurationsMs.reduce((total, duration) => total + duration, 0) /
|
||||
cleanupDurationsMs.length
|
||||
),
|
||||
maximum: Math.max(...cleanupDurationsMs)
|
||||
},
|
||||
unknownResponseFrames: Array.from(unknownResponses.values()).reduce(
|
||||
(total, count) => total + count,
|
||||
0
|
||||
),
|
||||
unknownResponseIds: unknownResponses.size,
|
||||
memory: {
|
||||
before: summarizeMemory(before),
|
||||
after: summarizeMemory(after),
|
||||
appDelta: after.app.memory - before.app.memory
|
||||
},
|
||||
elapsedMs: Date.now() - startedAt
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
console.warn = originalWarn
|
||||
}
|
||||
}
|
||||
|
||||
async function runCycle(args: {
|
||||
pairing: PairingOffer
|
||||
environmentId: string
|
||||
concurrency: number
|
||||
settleMs: number
|
||||
cleanupTimeoutMs: number
|
||||
}): Promise<{
|
||||
ok: number
|
||||
subscriptionResponses: number
|
||||
cleanupDurationMs: number
|
||||
runtimeStatus: RuntimeStatus | null
|
||||
}> {
|
||||
const connection = new RemoteRuntimeSharedControlConnection(args.pairing, {
|
||||
environmentId: args.environmentId
|
||||
})
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: args.concurrency }, () =>
|
||||
connection.request<RuntimeStatus>('status.get', undefined, 10_000)
|
||||
)
|
||||
)
|
||||
const runtimeStatus = responses.find((response) => response.ok)
|
||||
let subscriptionResponses = 0
|
||||
const subscriptions = await Promise.all([
|
||||
connection.subscribe('runtime.clientEvents.subscribe', undefined, 10_000, {
|
||||
onResponse: () => {
|
||||
subscriptionResponses += 1
|
||||
},
|
||||
onError: () => {}
|
||||
}),
|
||||
connection.subscribe('session.tabs.subscribeAll', undefined, 10_000, {
|
||||
onResponse: () => {
|
||||
subscriptionResponses += 1
|
||||
},
|
||||
onError: () => {}
|
||||
})
|
||||
])
|
||||
await wait(args.settleMs)
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.close()
|
||||
}
|
||||
const cleanupDurationMs = await waitForConnectionIdle(connection, args.cleanupTimeoutMs)
|
||||
// Let cleanup replies reach the retirement cache before closing the socket.
|
||||
await wait(args.settleMs)
|
||||
return {
|
||||
ok: responses.filter((response) => response.ok).length,
|
||||
subscriptionResponses,
|
||||
cleanupDurationMs,
|
||||
runtimeStatus: runtimeStatus?.ok === true ? runtimeStatus.result : null
|
||||
}
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnectionIdle(
|
||||
connection: RemoteRuntimeSharedControlConnection,
|
||||
timeoutMs: number
|
||||
): Promise<number> {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const diagnostics = connection.getDiagnostics()
|
||||
if (diagnostics.pendingRequestCount === 0 && diagnostics.subscriptionCount === 0) {
|
||||
return Date.now() - startedAt
|
||||
}
|
||||
await wait(25)
|
||||
}
|
||||
throw new Error(`Cycle did not settle: ${JSON.stringify(connection.getDiagnostics())}`)
|
||||
}
|
||||
|
||||
async function requestMemorySnapshot(
|
||||
pairing: PairingOffer,
|
||||
environmentId: string
|
||||
): Promise<MemorySnapshot> {
|
||||
const connection = new RemoteRuntimeSharedControlConnection(pairing, { environmentId })
|
||||
try {
|
||||
const response = await connection.request<MemorySnapshot>(
|
||||
'diagnostics.memory',
|
||||
undefined,
|
||||
20_000
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Memory snapshot failed: ${response.error.message}`)
|
||||
}
|
||||
return response.result
|
||||
} finally {
|
||||
connection.close()
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeMemory(snapshot: MemorySnapshot): {
|
||||
app: MemorySnapshot['app']
|
||||
host: MemorySnapshot['host']
|
||||
processMemoryMetric: MemorySnapshot['processMemoryMetric']
|
||||
totalCpu: number
|
||||
totalMemory: number
|
||||
worktreeCount: number
|
||||
sessionCount: number
|
||||
worktreeMemory: number
|
||||
topWorktrees: {
|
||||
worktreeName: string
|
||||
repoName: string
|
||||
cpu: number
|
||||
memory: number
|
||||
sessionCount: number
|
||||
topSessions: { pid: number; cpu: number; memory: number }[]
|
||||
}[]
|
||||
} {
|
||||
return {
|
||||
app: snapshot.app,
|
||||
host: snapshot.host,
|
||||
processMemoryMetric: snapshot.processMemoryMetric,
|
||||
totalCpu: snapshot.totalCpu,
|
||||
totalMemory: snapshot.totalMemory,
|
||||
worktreeCount: snapshot.worktrees.length,
|
||||
sessionCount: snapshot.worktrees.reduce(
|
||||
(total, worktree) => total + worktree.sessions.length,
|
||||
0
|
||||
),
|
||||
worktreeMemory: snapshot.worktrees.reduce((total, worktree) => total + worktree.memory, 0),
|
||||
topWorktrees: [...snapshot.worktrees]
|
||||
.sort((left, right) => right.memory - left.memory)
|
||||
.slice(0, 10)
|
||||
.map((worktree) => ({
|
||||
worktreeName: worktree.worktreeName,
|
||||
repoName: worktree.repoName,
|
||||
cpu: worktree.cpu,
|
||||
memory: worktree.memory,
|
||||
sessionCount: worktree.sessions.length,
|
||||
topSessions: [...worktree.sessions]
|
||||
.sort((left, right) => right.memory - left.memory)
|
||||
.slice(0, 5)
|
||||
.map((session) => ({
|
||||
pid: session.pid,
|
||||
cpu: session.cpu,
|
||||
memory: session.memory
|
||||
}))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function readProbeInteger(name: string, fallback: number, maximum: number): number {
|
||||
const value = process.env[name]
|
||||
if (value === undefined) {
|
||||
return fallback
|
||||
}
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) {
|
||||
throw new Error(`${name} must be an integer from 1 through ${maximum}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function wait(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
Executable
+95
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -12,8 +12,6 @@
|
||||
"../src/main/agent-hooks/local-agent-cli-presence.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/ipc/local-agent-install-dir-detection.ts",
|
||||
"../src/main/ipc/tui-agent-detection-commands.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+50
-24
@@ -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'
|
||||
),
|
||||
@@ -225,16 +259,8 @@ export const electronViteConfig: UserConfig = {
|
||||
'agent-hooks/managed-agent-hook-controls': resolve(
|
||||
'src/main/agent-hooks/managed-agent-hook-controls.ts'
|
||||
),
|
||||
'ipc/local-agent-install-dir-detection': resolve(
|
||||
'src/main/ipc/local-agent-install-dir-detection.ts'
|
||||
),
|
||||
'ipc/tui-agent-detection-commands': resolve(
|
||||
'src/main/ipc/tui-agent-detection-commands.ts'
|
||||
),
|
||||
// Why: same rule — `orca account add` / `account list` import these.
|
||||
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts'),
|
||||
'codex-cli/command': resolve('src/main/codex-cli/command.ts'),
|
||||
'win32-utils': resolve('src/main/win32-utils.ts')
|
||||
// Why: account import mutates the user's macOS Keychain from the CLI.
|
||||
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts')
|
||||
},
|
||||
// Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers
|
||||
// consume these stable CommonJS paths.
|
||||
@@ -243,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
|
||||
|
||||
@@ -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
|
||||
@@ -1074,18 +1079,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.
|
||||
@@ -3005,7 +3010,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
|
||||
@@ -3014,15 +3020,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 {
|
||||
@@ -3039,29 +3047,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(
|
||||
@@ -3085,15 +3079,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]
|
||||
@@ -3355,14 +3353,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 {
|
||||
@@ -3842,14 +3843,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((sendResponse as RpcFailure).error.message || t('m.v-nMJVc'))
|
||||
@@ -4845,9 +4847,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 ? t('m.LNjU0eA') : t('m.g0S-mpY')}
|
||||
>
|
||||
@@ -4856,7 +4859,7 @@ export default function SessionScreen() {
|
||||
color={
|
||||
liveInputEnabled
|
||||
? colors.bgBase
|
||||
: canSend
|
||||
: canCompose
|
||||
? colors.textSecondary
|
||||
: colors.textMuted
|
||||
}
|
||||
@@ -5053,7 +5056,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
|
||||
|
||||
@@ -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 } } : {})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,143 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 = {
|
||||
@@ -1013,6 +1016,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', {
|
||||
|
||||
@@ -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? {
|
||||
|
||||
+50
@@ -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
|
||||
}
|
||||
+182
@@ -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()
|
||||
}
|
||||
}
|
||||
+104
@@ -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)
|
||||
}
|
||||
+195
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.163-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,6 +58,7 @@
|
||||
"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",
|
||||
"verify:mobile-localization-catalog": "node config/scripts/verify-mobile-localization-catalog.mjs",
|
||||
"sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix",
|
||||
@@ -65,13 +66,14 @@
|
||||
"verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check && node config/scripts/audit-localization-coverage.mjs --check --source-root mobile/app --allowlist config/mobile-localization-coverage-allowlist.json && node config/scripts/audit-localization-coverage.mjs --check --source-root mobile/src --allowlist config/mobile-localization-coverage-allowlist.json",
|
||||
"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",
|
||||
@@ -104,8 +106,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",
|
||||
|
||||
@@ -41,7 +41,7 @@ vi.mock('../../main/claude-accounts/keychain', () => ({
|
||||
readActiveClaudeKeychainCredentialsStrict: readKeychainMock,
|
||||
writeActiveClaudeKeychainCredentials: writeKeychainMock
|
||||
}))
|
||||
vi.mock('../../main/codex-cli/command', () => ({
|
||||
vi.mock('../../shared/node-cli-command-resolution', () => ({
|
||||
getVersionManagerBinPaths: getVersionManagerBinPathsMock,
|
||||
resolveCliCommand: resolveCliCommandMock
|
||||
}))
|
||||
@@ -49,7 +49,7 @@ vi.mock('../../main/codex-cli/command', () => ({
|
||||
import { ACCOUNT_HANDLERS } from './account'
|
||||
import type { HandlerContext } from '../dispatch'
|
||||
import type { RuntimeClient } from '../runtime-client'
|
||||
import { getCmdExePath } from '../../main/win32-utils'
|
||||
import { getCmdExePath } from '../../shared/windows-batch-spawn'
|
||||
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
|
||||
function successfulChild(): EventEmitter {
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
readActiveClaudeKeychainCredentialsStrict,
|
||||
writeActiveClaudeKeychainCredentials
|
||||
} from '../../main/claude-accounts/keychain'
|
||||
import { getVersionManagerBinPaths, resolveCliCommand } from '../../main/codex-cli/command'
|
||||
import { getSpawnArgsForWindows } from '../../main/win32-utils'
|
||||
import {
|
||||
getVersionManagerBinPaths,
|
||||
resolveCliCommand
|
||||
} from '../../shared/node-cli-command-resolution'
|
||||
import { getSpawnArgsForWindows } from '../../shared/windows-batch-spawn'
|
||||
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types'
|
||||
|
||||
@@ -3,14 +3,17 @@ import type { CommandHandler } from '../dispatch'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { delimiter, dirname } from 'node:path'
|
||||
import { getRepeatedStringFlag } from '../flags'
|
||||
import { resolveCliCommand } from '../../main/codex-cli/command'
|
||||
import { detectCommandsInInstallDirs } from '../../main/ipc/local-agent-install-dir-detection'
|
||||
import { resolveCliCommand } from '../../shared/node-cli-command-resolution'
|
||||
import { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection'
|
||||
import {
|
||||
getTuiAgentDetectionProbeCommands,
|
||||
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
|
||||
resolveDetectedTuiAgentIds
|
||||
} from '../../main/ipc/tui-agent-detection-commands'
|
||||
import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError } from '../../main/win32-utils'
|
||||
} from '../../shared/tui-agent-detection-commands'
|
||||
import {
|
||||
getSpawnArgsForWindows,
|
||||
UnsafeWindowsBatchArgumentsError
|
||||
} from '../../shared/windows-batch-spawn'
|
||||
import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys'
|
||||
import {
|
||||
buildAgentFeatureSkillInstallArgs,
|
||||
|
||||
@@ -41,8 +41,8 @@ function findElectronViteMainEntries(): Set<string> {
|
||||
describe('CLI imports of main-process modules', () => {
|
||||
// Why: electron-vite cleans out/main and emits only its declared entries, so a
|
||||
// `src/main/*` module the CLI imports but the config omits is deleted by the
|
||||
// build that runs after `build:cli` — the command then dies at require time
|
||||
// with "Cannot find module". Nothing else catches this before packaging.
|
||||
// build that runs after `build:cli` — keep source-level feedback ahead of the
|
||||
// final-artifact runtime verifier.
|
||||
it('has an electron-vite entry for every main module the CLI imports', () => {
|
||||
const entries = findElectronViteMainEntries()
|
||||
const missing = findMainImports().filter(({ module }) => !entries.has(module))
|
||||
@@ -52,7 +52,7 @@ describe('CLI imports of main-process modules', () => {
|
||||
|
||||
it('finds the imports it is meant to guard', () => {
|
||||
// Why: a broken matcher would make the guard above vacuously pass.
|
||||
expect(findMainImports().length).toBeGreaterThanOrEqual(4)
|
||||
expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(4)
|
||||
expect(findMainImports().length).toBeGreaterThanOrEqual(2)
|
||||
expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { delimiter } from 'node:path'
|
||||
import type * as CodexCliCommandModule from '../main/codex-cli/command'
|
||||
import type * as CodexCliCommandModule from '../shared/node-cli-command-resolution'
|
||||
|
||||
const {
|
||||
detectCommandsMock,
|
||||
@@ -19,12 +19,12 @@ const {
|
||||
|
||||
// Why: agent detection probes the real machine, so pin it or every install
|
||||
// assertion depends on what the test runner happens to have installed.
|
||||
vi.mock('../main/ipc/local-agent-install-dir-detection', () => ({
|
||||
vi.mock('../shared/local-agent-install-dir-detection', () => ({
|
||||
detectCommandsInInstallDirs: detectCommandsMock
|
||||
}))
|
||||
|
||||
// Why: override only the npx lookup so the real Windows .cmd rail still runs.
|
||||
vi.mock('../main/codex-cli/command', async (importOriginal) => ({
|
||||
vi.mock('../shared/node-cli-command-resolution', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof CodexCliCommandModule>()),
|
||||
resolveCliCommand: resolveCliCommandMock
|
||||
}))
|
||||
|
||||
@@ -15,14 +15,14 @@ function hasCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code
|
||||
}
|
||||
|
||||
function parseLinuxStartTicks(statLine: string): string | null {
|
||||
const commandEnd = statLine.lastIndexOf(') ')
|
||||
export function parseLinuxStartTicks(statLine: string): string | null {
|
||||
const commandEnd = statLine.lastIndexOf(')')
|
||||
if (commandEnd < 0) {
|
||||
return null
|
||||
}
|
||||
// Field 22 is index 19 after removing pid and the parenthesized command.
|
||||
const startTicks = statLine
|
||||
.slice(commandEnd + 2)
|
||||
.slice(commandEnd + 1)
|
||||
.trim()
|
||||
.split(/\s+/)[19]
|
||||
return startTicks ?? null
|
||||
@@ -115,7 +115,7 @@ async function readHostIdentity(): Promise<string> {
|
||||
return runtimeHostIdentity
|
||||
}
|
||||
|
||||
async function readBootIdentity(): Promise<string | undefined> {
|
||||
export async function readBootIdentity(): Promise<string | undefined> {
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
const bootId = (await readFile('/proc/sys/kernel/random/boot_id', 'utf8')).trim()
|
||||
|
||||
@@ -195,6 +195,14 @@ describe('browserManager grab operations', () => {
|
||||
expect(selection.opId).toBe('op-1')
|
||||
})
|
||||
|
||||
it('tears down an armed overlay when disabling before selection starts', async () => {
|
||||
const result = await browserManager.setGrabMode('tab-1', false, guest)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(guestExecuteJavaScriptMock).toHaveBeenCalledTimes(1)
|
||||
expect(guestExecuteJavaScriptMock.mock.calls[0][0]).toContain('grab.cleanup()')
|
||||
})
|
||||
|
||||
it('returns false if injection fails', async () => {
|
||||
guestExecuteJavaScriptMock.mockRejectedValue(new Error('Injection failed'))
|
||||
const result = await browserManager.setGrabMode('tab-1', true, guest)
|
||||
|
||||
@@ -1610,8 +1610,17 @@ export class BrowserManager {
|
||||
guest: Electron.WebContents
|
||||
): Promise<boolean> {
|
||||
if (!enabled) {
|
||||
const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId)
|
||||
this.cancelGrabOp(browserTabId, 'user')
|
||||
return true
|
||||
if (hadActiveGrabOp) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
await guest.executeJavaScript(buildGuestOverlayScript('teardown'))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe.
|
||||
try {
|
||||
|
||||
@@ -1,226 +1 @@
|
||||
import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { delimiter, dirname, join } from 'node:path'
|
||||
|
||||
type ResolveCommandOptions = {
|
||||
pathEnv?: string | null
|
||||
platform?: NodeJS.Platform
|
||||
homePath?: string
|
||||
}
|
||||
|
||||
function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] {
|
||||
if (platform === 'win32') {
|
||||
return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName]
|
||||
}
|
||||
|
||||
return [commandName]
|
||||
}
|
||||
|
||||
function splitPath(pathEnv: string | null | undefined): string[] {
|
||||
if (!pathEnv) {
|
||||
return []
|
||||
}
|
||||
|
||||
return pathEnv
|
||||
.split(delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function parseVersionSegment(raw: string): number[] {
|
||||
return raw
|
||||
.replace(/^v/i, '')
|
||||
.split('.')
|
||||
.map((segment) => Number.parseInt(segment, 10))
|
||||
.map((segment) => (Number.isFinite(segment) ? segment : 0))
|
||||
}
|
||||
|
||||
function compareVersionDesc(left: string, right: string): number {
|
||||
const leftParts = parseVersionSegment(left)
|
||||
const rightParts = parseVersionSegment(right)
|
||||
const length = Math.max(leftParts.length, rightParts.length)
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0)
|
||||
if (delta !== 0) {
|
||||
return delta
|
||||
}
|
||||
}
|
||||
|
||||
return right.localeCompare(left)
|
||||
}
|
||||
|
||||
function findFirstExecutable(
|
||||
platform: NodeJS.Platform,
|
||||
directories: string[],
|
||||
executableNames: string[]
|
||||
): string | null {
|
||||
for (const directory of directories) {
|
||||
for (const executableName of executableNames) {
|
||||
const candidate = join(directory, executableName)
|
||||
if (isRunnableCommand(platform, candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean {
|
||||
try {
|
||||
const stats = statSync(candidate)
|
||||
if (!stats.isFile()) {
|
||||
return false
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return true
|
||||
}
|
||||
// Why: GUI fallback probing should skip placeholders/directories so spawn
|
||||
// can continue to a runnable CLI instead of failing later with EACCES/EISDIR.
|
||||
accessSync(candidate, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] {
|
||||
const directories = [
|
||||
join(homePath, '.volta', 'bin'),
|
||||
join(homePath, '.asdf', 'shims'),
|
||||
join(homePath, '.fnm', 'aliases', 'default', 'bin'),
|
||||
// Why: mise (formerly rtx) exposes managed tool binaries via a shims
|
||||
// directory, similar to asdf. Without this, users who installed node
|
||||
// or CLI tools through mise can't be found by the fallback probe.
|
||||
join(homePath, '.local', 'share', 'mise', 'shims')
|
||||
]
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Why: Anthropic's native Windows installer places claude.exe here, and
|
||||
// GUI-launched Orca may not inherit the user's PATH entry for it.
|
||||
directories.push(join(homePath, '.local', 'bin'))
|
||||
directories.push(join(homePath, 'AppData', 'Roaming', 'npm'))
|
||||
directories.push(join(homePath, 'AppData', 'Local', 'pnpm'))
|
||||
directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin'))
|
||||
} else {
|
||||
directories.push(join(homePath, '.local', 'bin'))
|
||||
// Why: pnpm uses platform-specific global bin directories that differ from
|
||||
// npm's ~/.local/bin. macOS follows the ~/Library convention while Linux
|
||||
// uses the XDG-compatible ~/.local/share path. Without these, users who
|
||||
// installed via `pnpm add -g` can't be found by the fallback probe.
|
||||
if (platform === 'darwin') {
|
||||
directories.push(join(homePath, 'Library', 'pnpm'))
|
||||
} else {
|
||||
directories.push(join(homePath, '.local', 'share', 'pnpm'))
|
||||
}
|
||||
directories.push(join(homePath, '.yarn', 'bin'))
|
||||
}
|
||||
|
||||
// Why: bun uses ~/.bun/bin on all platforms for globally installed packages.
|
||||
directories.push(join(homePath, '.bun', 'bin'))
|
||||
|
||||
return directories
|
||||
}
|
||||
|
||||
function getNvmVersionDirectories(homePath: string): string[] {
|
||||
const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node')
|
||||
if (!existsSync(nvmVersionsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return readdirSync(nvmVersionsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort(compareVersionDesc)
|
||||
.map((entry) => join(nvmVersionsDir, entry, 'bin'))
|
||||
}
|
||||
|
||||
function getVersionManagerDirectories(
|
||||
platform: NodeJS.Platform,
|
||||
homePath: string,
|
||||
executableNames: string[]
|
||||
): string[] {
|
||||
const directories = getBaseVersionManagerDirectories(platform, homePath)
|
||||
|
||||
// Why: GUI-launched Electron apps do not inherit shell init from nvm, so
|
||||
// command resolution probes the newest installed Node versions explicitly.
|
||||
const firstNvmMatch = findFirstExecutable(
|
||||
platform,
|
||||
getNvmVersionDirectories(homePath),
|
||||
executableNames
|
||||
)
|
||||
if (firstNvmMatch) {
|
||||
directories.unshift(dirname(firstNvmMatch))
|
||||
}
|
||||
|
||||
return directories
|
||||
}
|
||||
|
||||
export function resolveCliCommand(
|
||||
commandName: string,
|
||||
options: ResolveCommandOptions = {}
|
||||
): string {
|
||||
const platform = options.platform ?? process.platform
|
||||
const executableNames = getExecutableNames(platform, commandName)
|
||||
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
|
||||
const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames)
|
||||
if (pathCandidate) {
|
||||
return pathCandidate
|
||||
}
|
||||
|
||||
const homePath = options.homePath ?? homedir()
|
||||
const versionManagerCandidate = findFirstExecutable(
|
||||
platform,
|
||||
getVersionManagerDirectories(platform, homePath, executableNames),
|
||||
executableNames
|
||||
)
|
||||
return versionManagerCandidate ?? commandName
|
||||
}
|
||||
|
||||
export function resolveCliCommands(
|
||||
commandNames: readonly string[],
|
||||
options: ResolveCommandOptions = {}
|
||||
): Map<string, string> {
|
||||
const platform = options.platform ?? process.platform
|
||||
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
|
||||
const pathDirectories = splitPath(pathEnv)
|
||||
const homePath = options.homePath ?? homedir()
|
||||
// Why: agent detection probes many CLIs at once; compute expensive install
|
||||
// directories, especially nvm versions, once per detection pass.
|
||||
const installDirectories = [
|
||||
...getNvmVersionDirectories(homePath),
|
||||
...getBaseVersionManagerDirectories(platform, homePath)
|
||||
]
|
||||
const resolved = new Map<string, string>()
|
||||
|
||||
for (const commandName of new Set(commandNames)) {
|
||||
const executableNames = getExecutableNames(platform, commandName)
|
||||
const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames)
|
||||
const installCandidate =
|
||||
pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames)
|
||||
resolved.set(commandName, installCandidate ?? commandName)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function resolveCodexCommand(options: ResolveCommandOptions = {}): string {
|
||||
return resolveCliCommand('codex', options)
|
||||
}
|
||||
|
||||
export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string {
|
||||
return resolveCliCommand('claude', options)
|
||||
}
|
||||
|
||||
// Why: GUI-launched Electron apps inherit a minimal PATH that excludes Node
|
||||
// version manager directories. CLI tools like codex/claude are Node scripts
|
||||
// with #!/usr/bin/env node shebangs — they need `node` in PATH to execute,
|
||||
// not just to be *found*. This function returns the version manager bin paths
|
||||
// so the caller can augment process.env.PATH at startup.
|
||||
export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] {
|
||||
const platform = options.platform ?? process.platform
|
||||
const homePath = options.homePath ?? homedir()
|
||||
const nodeNames = getExecutableNames(platform, 'node')
|
||||
return getVersionManagerDirectories(platform, homePath, nodeNames)
|
||||
}
|
||||
export * from '../../shared/node-cli-command-resolution'
|
||||
|
||||
@@ -22,13 +22,13 @@ describe('promptForGpuFallbackRestart', () => {
|
||||
await expect(promptForGpuFallbackRestart(parentWindow as never)).resolves.toBe('restart')
|
||||
expect(showMessageBoxMock).toHaveBeenCalledWith(parentWindow, {
|
||||
type: 'warning',
|
||||
buttons: ['Restart with Software Rendering', 'Keep Running'],
|
||||
buttons: ['Restart in Safe Graphics Mode', 'Keep Running'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: 'Restart Orca?',
|
||||
title: 'Restart Orca in Safe Graphics Mode?',
|
||||
message: "Orca's graphics process has crashed repeatedly.",
|
||||
detail:
|
||||
'Restart to switch to software rendering and reduce the chance of the app window crashing. If you keep running, Orca may become unstable.'
|
||||
'Safe graphics mode disables hardware acceleration and WebGL for this Orca version. Terminals and 3D content may render more slowly. Keep Running leaves graphics settings unchanged.'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ export type GpuFallbackRestartDecision = 'restart' | 'continue'
|
||||
|
||||
const GPU_FALLBACK_RESTART_OPTIONS: MessageBoxOptions = {
|
||||
type: 'warning',
|
||||
buttons: ['Restart with Software Rendering', 'Keep Running'],
|
||||
buttons: ['Restart in Safe Graphics Mode', 'Keep Running'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: 'Restart Orca?',
|
||||
title: 'Restart Orca in Safe Graphics Mode?',
|
||||
message: "Orca's graphics process has crashed repeatedly.",
|
||||
detail:
|
||||
'Restart to switch to software rendering and reduce the chance of the app window crashing. If you keep running, Orca may become unstable.'
|
||||
'Safe graphics mode disables hardware acceleration and WebGL for this Orca version. Terminals and 3D content may render more slowly. Keep Running leaves graphics settings unchanged.'
|
||||
}
|
||||
|
||||
export async function promptForGpuFallbackRestart(
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import {
|
||||
probeDaemonProcessIdentity,
|
||||
type DaemonEvidenceSource,
|
||||
type DaemonEvidenceSources,
|
||||
type DaemonProcessEvidence,
|
||||
type ExactDaemonIncarnation
|
||||
} from './daemon-incarnation-evidence'
|
||||
import type {
|
||||
DaemonAuditFailureTrigger,
|
||||
DaemonAuditGoneReason
|
||||
} from '../../shared/daemon-audit-eligibility'
|
||||
|
||||
export type { DaemonAuditTrigger } from '../../shared/daemon-audit-eligibility'
|
||||
|
||||
export type DaemonAuditContext = {
|
||||
protocolGeneration: number
|
||||
provider: 'local-daemon'
|
||||
endpoint: string
|
||||
tokenPath: string
|
||||
endpointKind: 'unix-socket' | 'windows-named-pipe'
|
||||
profileScope: string
|
||||
}
|
||||
|
||||
export type DaemonAuditObservation =
|
||||
| {
|
||||
state: 'present'
|
||||
reason: 'authenticated_inventory'
|
||||
trigger: 'inventory_answered'
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
context: DaemonAuditContext
|
||||
exactIncarnation: ExactDaemonIncarnation | null
|
||||
reachability: 'authenticated'
|
||||
inventoryAuthority: 'authoritative'
|
||||
processLiveness: 'unknown'
|
||||
processReason: null
|
||||
endpointState: DaemonEndpointState
|
||||
observedAtMs: number
|
||||
}
|
||||
| {
|
||||
state: 'gone'
|
||||
reason: DaemonAuditGoneReason
|
||||
trigger: DaemonAuditFailureTrigger
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
context: DaemonAuditContext
|
||||
exactIncarnation: ExactDaemonIncarnation
|
||||
reachability: 'authenticated' | 'disconnected' | 'unknown'
|
||||
inventoryAuthority: 'unavailable'
|
||||
processLiveness: 'gone'
|
||||
processReason: DaemonAuditGoneReason
|
||||
endpointState: DaemonEndpointState
|
||||
observedAtMs: number
|
||||
}
|
||||
| {
|
||||
state: 'unknown'
|
||||
reason: DaemonAuditFailureTrigger
|
||||
trigger: DaemonAuditFailureTrigger
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
context: DaemonAuditContext
|
||||
exactIncarnation: ExactDaemonIncarnation | null
|
||||
reachability: 'authenticated' | 'disconnected' | 'unknown'
|
||||
inventoryAuthority: 'unavailable'
|
||||
processLiveness: 'present' | 'unknown'
|
||||
processReason:
|
||||
| Extract<DaemonProcessEvidence, { state: 'present' | 'unknown' }>['reason']
|
||||
| null
|
||||
endpointState: DaemonEndpointState
|
||||
observedAtMs: number
|
||||
}
|
||||
|
||||
export type DaemonEndpointState = 'missing' | 'named-pipe' | 'non-socket' | 'socket' | 'unknown'
|
||||
|
||||
export type DaemonAuditClassifierDependencies = {
|
||||
probeProcessIdentity?: typeof probeDaemonProcessIdentity
|
||||
inspectEndpointState?: (context: DaemonAuditContext) => Promise<DaemonEndpointState>
|
||||
}
|
||||
|
||||
export type DaemonAuditClassificationOptions = {
|
||||
additionalEvidenceSources?: readonly DaemonEvidenceSource[]
|
||||
endpointGoneProof?: 'windows_named_pipe_missing'
|
||||
dependencies?: DaemonAuditClassifierDependencies
|
||||
}
|
||||
|
||||
export function recordAuthenticatedInventory(
|
||||
context: DaemonAuditContext,
|
||||
exactIncarnation: ExactDaemonIncarnation | null
|
||||
): DaemonAuditObservation {
|
||||
return {
|
||||
state: 'present',
|
||||
reason: 'authenticated_inventory',
|
||||
trigger: 'inventory_answered',
|
||||
evidenceSources: ['authenticated_inventory'],
|
||||
context,
|
||||
exactIncarnation,
|
||||
reachability: 'authenticated',
|
||||
inventoryAuthority: 'authoritative',
|
||||
processLiveness: 'unknown',
|
||||
processReason: null,
|
||||
endpointState: context.endpointKind === 'windows-named-pipe' ? 'named-pipe' : 'socket',
|
||||
observedAtMs: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
export async function classifyDaemonAuditFailure(
|
||||
context: DaemonAuditContext,
|
||||
trigger: DaemonAuditFailureTrigger,
|
||||
exactIncarnation: ExactDaemonIncarnation | null,
|
||||
options: DaemonAuditClassificationOptions = {}
|
||||
): Promise<DaemonAuditObservation> {
|
||||
const probeProcessIdentity =
|
||||
options.dependencies?.probeProcessIdentity ?? probeDaemonProcessIdentity
|
||||
const inspectEndpoint = options.dependencies?.inspectEndpointState ?? inspectDaemonEndpointState
|
||||
const [processEvidence, endpointState] = await Promise.all([
|
||||
probeProcessIdentity(exactIncarnation, {
|
||||
socketPath: context.endpoint,
|
||||
tokenPath: context.tokenPath
|
||||
}),
|
||||
inspectEndpoint(context)
|
||||
])
|
||||
const reachability = reachabilityForTrigger(trigger)
|
||||
const evidenceSources = combineEvidenceSources(
|
||||
processEvidence.evidenceSources,
|
||||
context.endpointKind === 'unix-socket' ? ['endpoint_stat'] : [],
|
||||
options.additionalEvidenceSources ?? []
|
||||
)
|
||||
const windowsNamedPipeMissing =
|
||||
options.endpointGoneProof === 'windows_named_pipe_missing' &&
|
||||
context.endpointKind === 'windows-named-pipe'
|
||||
const observedEndpointState = windowsNamedPipeMissing ? 'missing' : endpointState
|
||||
if (windowsNamedPipeMissing && exactIncarnation && processEvidence.state !== 'present') {
|
||||
return {
|
||||
state: 'gone',
|
||||
reason: 'windows_named_pipe_missing',
|
||||
trigger,
|
||||
evidenceSources,
|
||||
context,
|
||||
exactIncarnation,
|
||||
reachability,
|
||||
inventoryAuthority: 'unavailable',
|
||||
processLiveness: 'gone',
|
||||
processReason: 'windows_named_pipe_missing',
|
||||
endpointState: observedEndpointState,
|
||||
observedAtMs: Date.now()
|
||||
}
|
||||
}
|
||||
if (processEvidence.state === 'gone') {
|
||||
return {
|
||||
state: 'gone',
|
||||
reason: processEvidence.reason,
|
||||
trigger,
|
||||
evidenceSources,
|
||||
context,
|
||||
exactIncarnation: processEvidence.exactIncarnation,
|
||||
reachability,
|
||||
inventoryAuthority: 'unavailable',
|
||||
processLiveness: 'gone',
|
||||
processReason: processEvidence.reason,
|
||||
endpointState: observedEndpointState,
|
||||
observedAtMs: Date.now()
|
||||
}
|
||||
}
|
||||
return {
|
||||
state: 'unknown',
|
||||
reason: trigger,
|
||||
trigger,
|
||||
evidenceSources,
|
||||
context,
|
||||
exactIncarnation,
|
||||
reachability,
|
||||
inventoryAuthority: 'unavailable',
|
||||
processLiveness: processEvidence.state,
|
||||
processReason: processEvidence.reason,
|
||||
endpointState: observedEndpointState,
|
||||
observedAtMs: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectDaemonEndpointState(
|
||||
context: DaemonAuditContext
|
||||
): Promise<DaemonEndpointState> {
|
||||
if (context.endpointKind === 'windows-named-pipe') {
|
||||
return 'named-pipe'
|
||||
}
|
||||
try {
|
||||
const stats = await lstat(context.endpoint)
|
||||
return stats.isSocket() ? 'socket' : 'non-socket'
|
||||
} catch (error) {
|
||||
return hasErrorCode(error, 'ENOENT') ? 'missing' : 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function reachabilityForTrigger(
|
||||
trigger: DaemonAuditFailureTrigger
|
||||
): 'authenticated' | 'disconnected' | 'unknown' {
|
||||
if (
|
||||
trigger === 'transport_closed' ||
|
||||
trigger === 'token_missing_after_authenticated_disconnect'
|
||||
) {
|
||||
return 'disconnected'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function combineEvidenceSources(
|
||||
first: DaemonEvidenceSources,
|
||||
...rest: readonly (readonly DaemonEvidenceSource[])[]
|
||||
): DaemonEvidenceSources {
|
||||
return [...new Set([first[0], ...first.slice(1), ...rest.flat()])] as [
|
||||
DaemonEvidenceSource,
|
||||
...DaemonEvidenceSource[]
|
||||
]
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { validate } from '../telemetry/validator'
|
||||
import { recordAuthenticatedInventory, type DaemonAuditContext } from './daemon-audit-classifier'
|
||||
|
||||
const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() }))
|
||||
vi.mock('../telemetry/client', () => ({ track: trackMock }))
|
||||
|
||||
import { trackDaemonAuditEligibility } from './daemon-audit-eligibility-event'
|
||||
|
||||
const context: DaemonAuditContext = {
|
||||
protocolGeneration: 23,
|
||||
provider: 'local-daemon',
|
||||
endpoint: '/profile/daemon.sock',
|
||||
tokenPath: '/profile/daemon.token',
|
||||
endpointKind: 'unix-socket',
|
||||
profileScope: '/profile'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
trackMock.mockReset()
|
||||
})
|
||||
|
||||
describe('daemon audit eligibility telemetry', () => {
|
||||
it('uses a dedicated validator-accepted event family', () => {
|
||||
trackDaemonAuditEligibility(recordAuthenticatedInventory(context, null))
|
||||
|
||||
expect(trackMock).toHaveBeenCalledOnce()
|
||||
const [name, props] = trackMock.mock.calls[0]
|
||||
expect(name).toBe('daemon_audit_eligibility')
|
||||
expect(name).not.toBe('daemon_lifecycle')
|
||||
expect(props).toMatchObject({
|
||||
state: 'present',
|
||||
reason: 'authenticated_inventory',
|
||||
evidence_sources: ['authenticated_inventory'],
|
||||
protocol_generation: 23,
|
||||
exact_incarnation: 'unavailable',
|
||||
process_reason: null
|
||||
})
|
||||
expect(validate('daemon_audit_eligibility', props).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('cannot affect callers when telemetry throws', () => {
|
||||
trackMock.mockImplementation(() => {
|
||||
throw new Error('transport failed')
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
trackDaemonAuditEligibility(recordAuthenticatedInventory(context, null))
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { track } from '../telemetry/client'
|
||||
import type { DaemonAuditObservation } from './daemon-audit-classifier'
|
||||
|
||||
export function trackDaemonAuditEligibility(observation: DaemonAuditObservation): void {
|
||||
try {
|
||||
track('daemon_audit_eligibility', {
|
||||
state: observation.state,
|
||||
reason: observation.reason,
|
||||
trigger: observation.trigger,
|
||||
evidence_sources: [...observation.evidenceSources],
|
||||
protocol_generation: observation.context.protocolGeneration,
|
||||
provider: observation.context.provider,
|
||||
endpoint_kind: observation.context.endpointKind,
|
||||
profile_scope: observation.context.profileScope ? 'configured' : 'unspecified',
|
||||
exact_incarnation: exactIncarnationKind(observation),
|
||||
reachability: observation.reachability,
|
||||
inventory_authority: observation.inventoryAuthority,
|
||||
process_liveness: observation.processLiveness,
|
||||
process_reason: observation.processReason,
|
||||
endpoint_state: observation.endpointState
|
||||
})
|
||||
} catch {
|
||||
// Audit telemetry cannot affect daemon availability.
|
||||
}
|
||||
}
|
||||
|
||||
function exactIncarnationKind(
|
||||
observation: DaemonAuditObservation
|
||||
): 'endpoint-identity' | 'endpoint-identity-linux-ticks' | 'unavailable' {
|
||||
if (!observation.exactIncarnation) {
|
||||
return 'unavailable'
|
||||
}
|
||||
return observation.exactIncarnation.linuxStartTicks && observation.exactIncarnation.bootId
|
||||
? 'endpoint-identity-linux-ticks'
|
||||
: 'endpoint-identity'
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../providers/macos-tcc-login-shell'
|
||||
import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch'
|
||||
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
||||
import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity'
|
||||
|
||||
export type ParsedDaemonArgs = {
|
||||
socketPath: string
|
||||
@@ -255,9 +256,8 @@ async function main(): Promise<void> {
|
||||
|
||||
// Signal readiness to parent via IPC (if available)
|
||||
if (process.send) {
|
||||
// Why: Windows has no cheap OS query for a child's start time, so the
|
||||
// daemon self-reports it here for the pid file's pid-recycling guard.
|
||||
process.send({ type: 'ready', startedAtMs })
|
||||
const readyIdentity = await readCurrentDaemonReadyIdentity(startedAtMs)
|
||||
process.send({ type: 'ready', ...readyIdentity })
|
||||
}
|
||||
daemonLog.log('ready')
|
||||
|
||||
|
||||
@@ -198,7 +198,10 @@ describe('parseDaemonPidFile', () => {
|
||||
pid: 12345,
|
||||
startedAtMs: 1_700_000_000_000,
|
||||
entryPath: null,
|
||||
appVersion: null
|
||||
appVersion: null,
|
||||
launchNonce: null,
|
||||
linuxStartTicks: null,
|
||||
bootId: null
|
||||
})
|
||||
})
|
||||
|
||||
@@ -213,7 +216,25 @@ describe('parseDaemonPidFile', () => {
|
||||
pid: 12345,
|
||||
startedAtMs: 1_700_000_000_000,
|
||||
entryPath: '/repo/out/main/daemon-entry.js',
|
||||
appVersion: '1.2.3'
|
||||
appVersion: '1.2.3',
|
||||
launchNonce: null,
|
||||
linuxStartTicks: null,
|
||||
bootId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves exact-incarnation launch and Linux process identity', () => {
|
||||
const serialized = serializeDaemonPidFile({
|
||||
pid: 12345,
|
||||
startedAtMs: 1_700_000_000_000,
|
||||
launchNonce: 'launch-a',
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a'
|
||||
})
|
||||
expect(parseDaemonPidFile(serialized)).toMatchObject({
|
||||
launchNonce: 'launch-a',
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -224,7 +245,10 @@ describe('parseDaemonPidFile', () => {
|
||||
pid: 9999,
|
||||
startedAtMs: null,
|
||||
entryPath: null,
|
||||
appVersion: null
|
||||
appVersion: null,
|
||||
launchNonce: null,
|
||||
linuxStartTicks: null,
|
||||
bootId: null
|
||||
})
|
||||
})
|
||||
|
||||
@@ -236,13 +260,19 @@ describe('parseDaemonPidFile', () => {
|
||||
pid: 12345,
|
||||
startedAtMs: null,
|
||||
entryPath: null,
|
||||
appVersion: null
|
||||
appVersion: null,
|
||||
launchNonce: null,
|
||||
linuxStartTicks: null,
|
||||
bootId: null
|
||||
})
|
||||
expect(parseDaemonPidFile(' 12345\n')).toEqual({
|
||||
pid: 12345,
|
||||
startedAtMs: null,
|
||||
entryPath: null,
|
||||
appVersion: null
|
||||
appVersion: null,
|
||||
launchNonce: null,
|
||||
linuxStartTicks: null,
|
||||
bootId: null
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -40,11 +40,14 @@ const WIN32_START_TIME_TOLERANCE_MS = 10_000
|
||||
// also covers a live-but-wedged daemon that simply missed the RPC budget.
|
||||
export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy'
|
||||
|
||||
type ParsedDaemonPid = {
|
||||
export type ParsedDaemonPid = {
|
||||
pid: number
|
||||
startedAtMs: number | null
|
||||
entryPath: string | null
|
||||
appVersion: string | null
|
||||
launchNonce: string | null
|
||||
linuxStartTicks: string | null
|
||||
bootId: string | null
|
||||
}
|
||||
|
||||
function canConnectSocket(socketPath: string): Promise<boolean> {
|
||||
@@ -301,7 +304,7 @@ export function getMacDaemonSystemResolverHealth(
|
||||
})
|
||||
}
|
||||
|
||||
function commandLineMatchesDaemon(
|
||||
export function commandLineMatchesDaemon(
|
||||
commandLine: string,
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
@@ -321,6 +324,9 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
|
||||
startedAtMs?: unknown
|
||||
entryPath?: unknown
|
||||
appVersion?: unknown
|
||||
launchNonce?: unknown
|
||||
linuxStartTicks?: unknown
|
||||
bootId?: unknown
|
||||
}
|
||||
if (typeof parsed.pid === 'number' && Number.isFinite(parsed.pid)) {
|
||||
return {
|
||||
@@ -330,7 +336,10 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
|
||||
? parsed.startedAtMs
|
||||
: null,
|
||||
entryPath: typeof parsed.entryPath === 'string' ? parsed.entryPath : null,
|
||||
appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : null
|
||||
appVersion: typeof parsed.appVersion === 'string' ? parsed.appVersion : null,
|
||||
launchNonce: typeof parsed.launchNonce === 'string' ? parsed.launchNonce : null,
|
||||
linuxStartTicks: typeof parsed.linuxStartTicks === 'string' ? parsed.linuxStartTicks : null,
|
||||
bootId: typeof parsed.bootId === 'string' ? parsed.bootId : null
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -338,7 +347,17 @@ export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null {
|
||||
}
|
||||
|
||||
const pid = Number(trimmed)
|
||||
return Number.isFinite(pid) ? { pid, startedAtMs: null, entryPath: null, appVersion: null } : null
|
||||
return Number.isFinite(pid)
|
||||
? {
|
||||
pid,
|
||||
startedAtMs: null,
|
||||
entryPath: null,
|
||||
appVersion: null,
|
||||
launchNonce: null,
|
||||
linuxStartTicks: null,
|
||||
bootId: null
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
function getLinuxProcessStartedAtMs(pid: number): number | null {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
|
||||
import type {
|
||||
DaemonAuditGoneReason,
|
||||
DaemonEvidenceSource,
|
||||
DaemonProcessGoneReason,
|
||||
DaemonProcessPresentReason,
|
||||
DaemonProcessUnknownReason
|
||||
} from '../../shared/daemon-audit-eligibility'
|
||||
|
||||
export const WINDOWS_CREATION_TIME_TOLERANCE_MS = 10_000
|
||||
|
||||
// Oracle contract validated 2026-07-29; omitted proofs remain unknown.
|
||||
export const DAEMON_GONE_PROOFS = {
|
||||
linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'],
|
||||
darwin: ['pid_missing'],
|
||||
win32: ['windows_process_missing', 'windows_creation_time_mismatch', 'windows_named_pipe_missing']
|
||||
} as const satisfies Readonly<
|
||||
Record<'linux' | 'darwin' | 'win32', readonly DaemonAuditGoneReason[]>
|
||||
>
|
||||
|
||||
export type {
|
||||
DaemonAuditGoneReason,
|
||||
DaemonEvidenceSource,
|
||||
DaemonProcessGoneReason,
|
||||
DaemonProcessPresentReason,
|
||||
DaemonProcessUnknownReason
|
||||
} from '../../shared/daemon-audit-eligibility'
|
||||
|
||||
export type DaemonEvidenceSources = readonly [DaemonEvidenceSource, ...DaemonEvidenceSource[]]
|
||||
|
||||
export type ExactDaemonIncarnation = {
|
||||
identity: DaemonEndpointIdentity
|
||||
linuxStartTicks?: string
|
||||
bootId?: string
|
||||
}
|
||||
|
||||
export type DaemonProcessEvidence =
|
||||
| {
|
||||
state: 'present'
|
||||
reason: DaemonProcessPresentReason
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
}
|
||||
| {
|
||||
state: 'gone'
|
||||
reason: DaemonProcessGoneReason
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
exactIncarnation: ExactDaemonIncarnation
|
||||
}
|
||||
| {
|
||||
state: 'unknown'
|
||||
reason: DaemonProcessUnknownReason
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
}
|
||||
|
||||
export type ProcessSignalEvidence = 'occupied' | 'permission_denied' | 'missing' | 'unavailable'
|
||||
|
||||
export type LinuxStatEvidence =
|
||||
| { status: 'present'; value: string }
|
||||
| { status: 'missing' }
|
||||
| { status: 'unavailable' }
|
||||
|
||||
export type WindowsProcessEvidence =
|
||||
| { status: 'present'; commandLine: string | null; startedAtMs: number | null }
|
||||
| { status: 'missing' }
|
||||
| { status: 'unavailable' }
|
||||
|
||||
export type DaemonProcessProbeDependencies = {
|
||||
platform?: NodeJS.Platform
|
||||
signalProcess?: (pid: number) => ProcessSignalEvidence
|
||||
readLinuxStat?: (pid: number) => Promise<LinuxStatEvidence>
|
||||
readBootIdentity?: () => Promise<string | undefined>
|
||||
readCommandLine?: (pid: number, platform: NodeJS.Platform) => Promise<string | undefined>
|
||||
readProcessStartedAtMs?: (pid: number) => number | null
|
||||
queryWindowsProcess?: (pid: number) => Promise<WindowsProcessEvidence>
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DAEMON_GONE_PROOFS,
|
||||
probeDaemonProcessIdentity,
|
||||
WINDOWS_CREATION_TIME_TOLERANCE_MS,
|
||||
type DaemonProcessProbeDependencies,
|
||||
type ExactDaemonIncarnation
|
||||
} from './daemon-incarnation-evidence'
|
||||
import {
|
||||
classifyDaemonAuditFailure,
|
||||
recordAuthenticatedInventory,
|
||||
type DaemonAuditClassifierDependencies,
|
||||
type DaemonAuditContext
|
||||
} from './daemon-audit-classifier'
|
||||
|
||||
const endpoint = { socketPath: '/runtime/daemon.sock', tokenPath: '/runtime/daemon.token' }
|
||||
const exactIncarnation: ExactDaemonIncarnation = {
|
||||
identity: { pid: 42, startedAtMs: 1_700_000_000_000, launchNonce: 'launch-a' },
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a'
|
||||
}
|
||||
const auditClassifierDependencies = {
|
||||
probeProcessIdentity: async () => ({
|
||||
state: 'unknown',
|
||||
reason: 'inspection_failed',
|
||||
evidenceSources: ['process_signal']
|
||||
}),
|
||||
inspectEndpointState: async (context) =>
|
||||
context.endpointKind === 'windows-named-pipe' ? 'named-pipe' : 'missing'
|
||||
} satisfies DaemonAuditClassifierDependencies
|
||||
|
||||
function linuxStat(state: string, startTicks: string): string {
|
||||
return `42 (orca daemon with spaces) ${[state, ...Array(18).fill('0'), startTicks].join(' ')}`
|
||||
}
|
||||
|
||||
function linuxDependencies(
|
||||
overrides: DaemonProcessProbeDependencies = {}
|
||||
): DaemonProcessProbeDependencies {
|
||||
return {
|
||||
platform: 'linux',
|
||||
signalProcess: () => 'occupied',
|
||||
readLinuxStat: async () => ({ status: 'present', value: linuxStat('S', '4242') }),
|
||||
readBootIdentity: async () => 'boot-a',
|
||||
readCommandLine: async () =>
|
||||
`node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}`,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('daemon process identity evidence', () => {
|
||||
it('pins the dated conclusive-gone oracle contract', () => {
|
||||
expect(DAEMON_GONE_PROOFS).toEqual({
|
||||
linux: ['pid_missing', 'linux_boot_changed', 'linux_start_ticks_mismatch', 'linux_zombie'],
|
||||
darwin: ['pid_missing'],
|
||||
win32: [
|
||||
'windows_process_missing',
|
||||
'windows_creation_time_mismatch',
|
||||
'windows_named_pipe_missing'
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('proves Linux pid reuse from native start ticks without derived milliseconds', async () => {
|
||||
const readProcessStartedAtMs = vi.fn(() => exactIncarnation.identity.startedAtMs)
|
||||
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(
|
||||
exactIncarnation,
|
||||
endpoint,
|
||||
linuxDependencies({
|
||||
readLinuxStat: async () => ({ status: 'present', value: linuxStat('S', '4243') }),
|
||||
readProcessStartedAtMs
|
||||
})
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
state: 'gone',
|
||||
reason: 'linux_start_ticks_mismatch'
|
||||
})
|
||||
expect(readProcessStartedAtMs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proves Linux reboot invalidated the recorded incarnation', async () => {
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(
|
||||
exactIncarnation,
|
||||
endpoint,
|
||||
linuxDependencies({ readBootIdentity: async () => 'boot-b' })
|
||||
)
|
||||
).resolves.toMatchObject({ state: 'gone', reason: 'linux_boot_changed' })
|
||||
})
|
||||
|
||||
it('treats a matching unreaped Linux zombie as gone', async () => {
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(
|
||||
exactIncarnation,
|
||||
endpoint,
|
||||
linuxDependencies({
|
||||
readLinuxStat: async () => ({ status: 'present', value: linuxStat('Z', '4242') })
|
||||
})
|
||||
)
|
||||
).resolves.toMatchObject({ state: 'gone', reason: 'linux_zombie' })
|
||||
})
|
||||
|
||||
it('keeps EPERM unknown without an independent identity match', async () => {
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(
|
||||
exactIncarnation,
|
||||
endpoint,
|
||||
linuxDependencies({
|
||||
signalProcess: () => 'permission_denied',
|
||||
readLinuxStat: async () => ({ status: 'unavailable' })
|
||||
})
|
||||
)
|
||||
).resolves.toMatchObject({ state: 'unknown', reason: 'permission_denied' })
|
||||
})
|
||||
|
||||
it('keeps a missing proc stat unknown when signal zero still sees the pid', async () => {
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(
|
||||
exactIncarnation,
|
||||
endpoint,
|
||||
linuxDependencies({
|
||||
readLinuxStat: async () => ({ status: 'missing' })
|
||||
})
|
||||
)
|
||||
).resolves.toMatchObject({ state: 'unknown', reason: 'inspection_failed' })
|
||||
})
|
||||
|
||||
it('keeps an unreadable command line unknown even when Linux start identity matches', async () => {
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(
|
||||
exactIncarnation,
|
||||
endpoint,
|
||||
linuxDependencies({ readCommandLine: async () => undefined })
|
||||
)
|
||||
).resolves.toMatchObject({ state: 'unknown', reason: 'command_line_unavailable' })
|
||||
})
|
||||
|
||||
it('never treats signal-zero success alone as present', async () => {
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(
|
||||
{ identity: exactIncarnation.identity },
|
||||
endpoint,
|
||||
linuxDependencies({
|
||||
readCommandLine: async () => undefined
|
||||
})
|
||||
)
|
||||
).resolves.toMatchObject({ state: 'unknown' })
|
||||
})
|
||||
|
||||
it('never turns a legacy identity gap into gone', async () => {
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(null, endpoint, linuxDependencies())
|
||||
).resolves.toMatchObject({ state: 'unknown', reason: 'exact_identity_unavailable' })
|
||||
})
|
||||
|
||||
it('uses Windows CreationDate as the primary identity regardless of command line', async () => {
|
||||
const base = {
|
||||
platform: 'win32' as const,
|
||||
signalProcess: () => 'occupied' as const
|
||||
}
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
|
||||
...base,
|
||||
queryWindowsProcess: async () => ({
|
||||
status: 'present',
|
||||
commandLine: 'unreadable',
|
||||
startedAtMs:
|
||||
exactIncarnation.identity.startedAtMs + WINDOWS_CREATION_TIME_TOLERANCE_MS + 1
|
||||
})
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'gone',
|
||||
reason: 'windows_creation_time_mismatch'
|
||||
})
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
|
||||
...base,
|
||||
queryWindowsProcess: async () => ({
|
||||
status: 'present',
|
||||
commandLine: null,
|
||||
startedAtMs: exactIncarnation.identity.startedAtMs
|
||||
})
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'present',
|
||||
reason: 'windows_identity_match'
|
||||
})
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
|
||||
...base,
|
||||
queryWindowsProcess: async () => ({
|
||||
status: 'present',
|
||||
commandLine: 'unrelated process',
|
||||
startedAtMs: exactIncarnation.identity.startedAtMs
|
||||
})
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'present',
|
||||
reason: 'windows_identity_match'
|
||||
})
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
|
||||
...base,
|
||||
queryWindowsProcess: async () => ({
|
||||
status: 'present',
|
||||
commandLine: 'node daemon-entry',
|
||||
startedAtMs: null
|
||||
})
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'unknown',
|
||||
reason: 'windows_process_start_time_unavailable'
|
||||
})
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
|
||||
...base,
|
||||
queryWindowsProcess: async () => ({ status: 'missing' })
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'gone',
|
||||
reason: 'windows_process_missing'
|
||||
})
|
||||
})
|
||||
|
||||
it('requires Windows CIM evidence even when signal-zero reports a missing pid', async () => {
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
|
||||
platform: 'win32',
|
||||
signalProcess: () => 'missing',
|
||||
queryWindowsProcess: async () => ({
|
||||
status: 'present',
|
||||
commandLine: null,
|
||||
startedAtMs: exactIncarnation.identity.startedAtMs
|
||||
})
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'present',
|
||||
reason: 'windows_identity_match'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a macOS lstart mismatch unknown', async () => {
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
|
||||
platform: 'darwin',
|
||||
signalProcess: () => 'occupied',
|
||||
readCommandLine: async () =>
|
||||
`node daemon-entry --socket ${endpoint.socketPath} --token ${endpoint.tokenPath}`,
|
||||
readProcessStartedAtMs: () => exactIncarnation.identity.startedAtMs + 2_500
|
||||
})
|
||||
).resolves.toMatchObject({ state: 'unknown', reason: 'macos_start_time_mismatch' })
|
||||
})
|
||||
|
||||
it('accepts ESRCH as conclusive POSIX process disappearance', async () => {
|
||||
await expect(
|
||||
probeDaemonProcessIdentity(exactIncarnation, endpoint, {
|
||||
platform: 'darwin',
|
||||
signalProcess: () => 'missing'
|
||||
})
|
||||
).resolves.toMatchObject({ state: 'gone', reason: 'pid_missing' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('daemon audit availability evidence', () => {
|
||||
const context: DaemonAuditContext = {
|
||||
protocolGeneration: 23,
|
||||
provider: 'local-daemon',
|
||||
endpoint: endpoint.socketPath,
|
||||
tokenPath: endpoint.tokenPath,
|
||||
endpointKind: 'unix-socket',
|
||||
profileScope: '/profile'
|
||||
}
|
||||
|
||||
it('records authoritative inventory as present without endpoint identity', () => {
|
||||
expect(recordAuthenticatedInventory(context, null)).toMatchObject({
|
||||
state: 'present',
|
||||
reason: 'authenticated_inventory',
|
||||
exactIncarnation: null,
|
||||
inventoryAuthority: 'authoritative'
|
||||
})
|
||||
})
|
||||
|
||||
it('represents failed legacy inventory as unknown, never an empty process list', async () => {
|
||||
const observation = await classifyDaemonAuditFailure(context, 'inventory_failed', null, {
|
||||
dependencies: auditClassifierDependencies
|
||||
})
|
||||
|
||||
expect(observation).toMatchObject({
|
||||
state: 'unknown',
|
||||
reason: 'inventory_failed',
|
||||
inventoryAuthority: 'unavailable',
|
||||
processLiveness: 'unknown'
|
||||
})
|
||||
expect(observation.evidenceSources.length).toBeGreaterThan(0)
|
||||
expect(observation).not.toHaveProperty('processes')
|
||||
})
|
||||
|
||||
it('keeps token removal after disconnect as contributing evidence only', async () => {
|
||||
const observation = await classifyDaemonAuditFailure(
|
||||
context,
|
||||
'token_missing_after_authenticated_disconnect',
|
||||
null,
|
||||
{
|
||||
additionalEvidenceSources: ['token_file'],
|
||||
dependencies: auditClassifierDependencies
|
||||
}
|
||||
)
|
||||
|
||||
expect(observation.state).toBe('unknown')
|
||||
expect(observation.evidenceSources).toContain('token_file')
|
||||
})
|
||||
|
||||
it('accepts Windows named-pipe absence only with exact incarnation evidence', async () => {
|
||||
const windowsContext: DaemonAuditContext = {
|
||||
...context,
|
||||
endpoint: '\\\\?\\pipe\\orca-daemon',
|
||||
endpointKind: 'windows-named-pipe'
|
||||
}
|
||||
const observation = await classifyDaemonAuditFailure(
|
||||
windowsContext,
|
||||
'inventory_failed',
|
||||
exactIncarnation,
|
||||
{
|
||||
additionalEvidenceSources: ['windows_named_pipe'],
|
||||
endpointGoneProof: 'windows_named_pipe_missing',
|
||||
dependencies: auditClassifierDependencies
|
||||
}
|
||||
)
|
||||
|
||||
expect(observation).toMatchObject({
|
||||
state: 'gone',
|
||||
reason: 'windows_named_pipe_missing',
|
||||
endpointState: 'missing',
|
||||
exactIncarnation
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps contradictory Windows pipe and process evidence unknown', async () => {
|
||||
const windowsContext: DaemonAuditContext = {
|
||||
...context,
|
||||
endpoint: '\\\\?\\pipe\\orca-daemon',
|
||||
endpointKind: 'windows-named-pipe'
|
||||
}
|
||||
const dependencies = {
|
||||
...auditClassifierDependencies,
|
||||
probeProcessIdentity: async () => ({
|
||||
state: 'present',
|
||||
reason: 'windows_identity_match',
|
||||
evidenceSources: ['windows_cim', 'endpoint_identity']
|
||||
})
|
||||
} satisfies DaemonAuditClassifierDependencies
|
||||
|
||||
await expect(
|
||||
classifyDaemonAuditFailure(windowsContext, 'inventory_failed', exactIncarnation, {
|
||||
additionalEvidenceSources: ['windows_named_pipe'],
|
||||
endpointGoneProof: 'windows_named_pipe_missing',
|
||||
dependencies
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
state: 'unknown',
|
||||
reason: 'inventory_failed',
|
||||
processLiveness: 'present',
|
||||
processReason: 'windows_identity_match',
|
||||
endpointState: 'missing'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects named-pipe disappearance as a gone proof for Unix endpoints', async () => {
|
||||
const observation = await classifyDaemonAuditFailure(
|
||||
context,
|
||||
'inventory_failed',
|
||||
exactIncarnation,
|
||||
{
|
||||
additionalEvidenceSources: ['windows_named_pipe'],
|
||||
endpointGoneProof: 'windows_named_pipe_missing',
|
||||
dependencies: auditClassifierDependencies
|
||||
}
|
||||
)
|
||||
|
||||
expect(observation).toMatchObject({
|
||||
state: 'unknown',
|
||||
reason: 'inventory_failed',
|
||||
processLiveness: 'unknown'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,270 @@
|
||||
import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity'
|
||||
import {
|
||||
commandLineMatchesDaemon,
|
||||
getProcessStartedAtMs,
|
||||
startTimesWithinTolerance
|
||||
} from './daemon-health'
|
||||
import {
|
||||
WINDOWS_CREATION_TIME_TOLERANCE_MS,
|
||||
type DaemonEvidenceSources,
|
||||
type DaemonProcessEvidence,
|
||||
type DaemonProcessProbeDependencies,
|
||||
type ExactDaemonIncarnation,
|
||||
type ProcessSignalEvidence
|
||||
} from './daemon-incarnation-evidence-types'
|
||||
import {
|
||||
inspectProcessSignal,
|
||||
queryWindowsProcess,
|
||||
readLinuxStat,
|
||||
readProcessCommandLine
|
||||
} from './daemon-process-inspection'
|
||||
|
||||
export {
|
||||
DAEMON_GONE_PROOFS,
|
||||
WINDOWS_CREATION_TIME_TOLERANCE_MS,
|
||||
type DaemonEvidenceSource,
|
||||
type DaemonEvidenceSources,
|
||||
type DaemonProcessEvidence,
|
||||
type DaemonProcessProbeDependencies,
|
||||
type ExactDaemonIncarnation,
|
||||
type LinuxStatEvidence,
|
||||
type ProcessSignalEvidence,
|
||||
type WindowsProcessEvidence
|
||||
} from './daemon-incarnation-evidence-types'
|
||||
|
||||
const POSIX_START_TIME_TOLERANCE_MS = 1_500
|
||||
|
||||
export async function probeDaemonProcessIdentity(
|
||||
exactIncarnation: ExactDaemonIncarnation | null,
|
||||
endpoint: { socketPath: string; tokenPath: string },
|
||||
dependencies: DaemonProcessProbeDependencies = {}
|
||||
): Promise<DaemonProcessEvidence> {
|
||||
if (!exactIncarnation) {
|
||||
return unknown('exact_identity_unavailable', ['pid_record'])
|
||||
}
|
||||
const platform = dependencies.platform ?? process.platform
|
||||
const signalProcess = dependencies.signalProcess ?? inspectProcessSignal
|
||||
const signal = signalProcess(exactIncarnation.identity.pid)
|
||||
if (platform !== 'win32' && signal === 'missing') {
|
||||
return gone('pid_missing', ['process_signal', 'endpoint_identity'], exactIncarnation)
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
return await probeLinuxProcess(exactIncarnation, endpoint, signal, dependencies)
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return await probeWindowsProcess(exactIncarnation, endpoint, signal, dependencies)
|
||||
}
|
||||
return await probeMacosProcess(exactIncarnation, endpoint, signal, dependencies)
|
||||
}
|
||||
|
||||
async function probeLinuxProcess(
|
||||
exactIncarnation: ExactDaemonIncarnation,
|
||||
endpoint: { socketPath: string; tokenPath: string },
|
||||
signal: ProcessSignalEvidence,
|
||||
dependencies: DaemonProcessProbeDependencies
|
||||
): Promise<DaemonProcessEvidence> {
|
||||
const stat = await (dependencies.readLinuxStat ?? readLinuxStat)(exactIncarnation.identity.pid)
|
||||
if (stat.status === 'missing') {
|
||||
return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [
|
||||
'linux_proc_stat',
|
||||
'process_signal'
|
||||
])
|
||||
}
|
||||
if (stat.status === 'unavailable') {
|
||||
return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [
|
||||
'linux_proc_stat',
|
||||
'process_signal'
|
||||
])
|
||||
}
|
||||
|
||||
const expectedTicks = exactIncarnation.linuxStartTicks
|
||||
const expectedBootId = exactIncarnation.bootId
|
||||
if (expectedTicks || expectedBootId) {
|
||||
if (!expectedTicks || !expectedBootId) {
|
||||
return unknown('linux_identity_incomplete', ['pid_record'])
|
||||
}
|
||||
const bootId = await (dependencies.readBootIdentity ?? readBootIdentity)()
|
||||
if (!bootId) {
|
||||
return unknown('inspection_failed', ['boot_identity'])
|
||||
}
|
||||
if (bootId !== expectedBootId) {
|
||||
return gone(
|
||||
'linux_boot_changed',
|
||||
['boot_identity', 'pid_record', 'endpoint_identity'],
|
||||
exactIncarnation
|
||||
)
|
||||
}
|
||||
const currentTicks = parseLinuxStartTicks(stat.value)
|
||||
if (!currentTicks) {
|
||||
return unknown('inspection_failed', ['linux_proc_stat'])
|
||||
}
|
||||
if (currentTicks !== expectedTicks) {
|
||||
return gone(
|
||||
'linux_start_ticks_mismatch',
|
||||
['linux_proc_stat', 'boot_identity', 'pid_record'],
|
||||
exactIncarnation
|
||||
)
|
||||
}
|
||||
if (parseLinuxProcessState(stat.value) === 'Z') {
|
||||
return gone(
|
||||
'linux_zombie',
|
||||
['linux_proc_stat', 'boot_identity', 'pid_record'],
|
||||
exactIncarnation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const commandLine = await (dependencies.readCommandLine ?? readProcessCommandLine)(
|
||||
exactIncarnation.identity.pid,
|
||||
'linux'
|
||||
)
|
||||
if (commandLine === undefined) {
|
||||
return unknown('command_line_unavailable', ['process_command_line'])
|
||||
}
|
||||
if (!commandLineMatchesDaemon(commandLine, endpoint.socketPath, endpoint.tokenPath)) {
|
||||
return unknown('command_line_mismatch', ['process_command_line'])
|
||||
}
|
||||
if (expectedTicks && expectedBootId) {
|
||||
return present('linux_identity_match', [
|
||||
'linux_proc_stat',
|
||||
'boot_identity',
|
||||
'process_command_line',
|
||||
'endpoint_identity'
|
||||
])
|
||||
}
|
||||
|
||||
const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)(
|
||||
exactIncarnation.identity.pid
|
||||
)
|
||||
if (startedAtMs === null) {
|
||||
return unknown('process_start_time_unavailable', ['process_start_time'])
|
||||
}
|
||||
return startTimesWithinTolerance(
|
||||
startedAtMs,
|
||||
exactIncarnation.identity.startedAtMs,
|
||||
POSIX_START_TIME_TOLERANCE_MS
|
||||
)
|
||||
? present('linux_identity_match', [
|
||||
'process_command_line',
|
||||
'process_start_time',
|
||||
'endpoint_identity'
|
||||
])
|
||||
: unknown('linux_identity_incomplete', ['process_start_time', 'endpoint_identity'])
|
||||
}
|
||||
|
||||
async function probeMacosProcess(
|
||||
exactIncarnation: ExactDaemonIncarnation,
|
||||
endpoint: { socketPath: string; tokenPath: string },
|
||||
signal: ProcessSignalEvidence,
|
||||
dependencies: DaemonProcessProbeDependencies
|
||||
): Promise<DaemonProcessEvidence> {
|
||||
const commandLine = await (dependencies.readCommandLine ?? readProcessCommandLine)(
|
||||
exactIncarnation.identity.pid,
|
||||
'darwin'
|
||||
)
|
||||
if (commandLine === undefined) {
|
||||
return unknown(
|
||||
signal === 'permission_denied' ? 'permission_denied' : 'command_line_unavailable',
|
||||
['process_command_line', 'process_signal']
|
||||
)
|
||||
}
|
||||
if (!commandLineMatchesDaemon(commandLine, endpoint.socketPath, endpoint.tokenPath)) {
|
||||
return unknown('command_line_mismatch', ['process_command_line'])
|
||||
}
|
||||
const startedAtMs = (dependencies.readProcessStartedAtMs ?? getProcessStartedAtMs)(
|
||||
exactIncarnation.identity.pid
|
||||
)
|
||||
if (startedAtMs === null) {
|
||||
return unknown('process_start_time_unavailable', ['process_start_time'])
|
||||
}
|
||||
return startTimesWithinTolerance(
|
||||
startedAtMs,
|
||||
exactIncarnation.identity.startedAtMs,
|
||||
POSIX_START_TIME_TOLERANCE_MS
|
||||
)
|
||||
? present('macos_identity_match', [
|
||||
'process_command_line',
|
||||
'process_start_time',
|
||||
'endpoint_identity'
|
||||
])
|
||||
: unknown('macos_start_time_mismatch', ['process_start_time', 'endpoint_identity'])
|
||||
}
|
||||
|
||||
async function probeWindowsProcess(
|
||||
exactIncarnation: ExactDaemonIncarnation,
|
||||
endpoint: { socketPath: string; tokenPath: string },
|
||||
signal: ProcessSignalEvidence,
|
||||
dependencies: DaemonProcessProbeDependencies
|
||||
): Promise<DaemonProcessEvidence> {
|
||||
const identity = await (dependencies.queryWindowsProcess ?? queryWindowsProcess)(
|
||||
exactIncarnation.identity.pid
|
||||
)
|
||||
if (identity.status === 'missing') {
|
||||
return gone('windows_process_missing', ['windows_cim', 'endpoint_identity'], exactIncarnation)
|
||||
}
|
||||
if (identity.status === 'unavailable') {
|
||||
return unknown(signal === 'permission_denied' ? 'permission_denied' : 'inspection_failed', [
|
||||
'windows_cim',
|
||||
'process_signal'
|
||||
])
|
||||
}
|
||||
if (identity.startedAtMs === null) {
|
||||
return unknown('windows_process_start_time_unavailable', ['windows_cim'])
|
||||
}
|
||||
if (
|
||||
!startTimesWithinTolerance(
|
||||
identity.startedAtMs,
|
||||
exactIncarnation.identity.startedAtMs,
|
||||
WINDOWS_CREATION_TIME_TOLERANCE_MS
|
||||
)
|
||||
) {
|
||||
return gone(
|
||||
'windows_creation_time_mismatch',
|
||||
['windows_cim', 'endpoint_identity'],
|
||||
exactIncarnation
|
||||
)
|
||||
}
|
||||
return present(
|
||||
'windows_identity_match',
|
||||
identity.commandLine &&
|
||||
commandLineMatchesDaemon(identity.commandLine, endpoint.socketPath, endpoint.tokenPath)
|
||||
? ['windows_cim', 'process_command_line', 'endpoint_identity']
|
||||
: ['windows_cim', 'endpoint_identity']
|
||||
)
|
||||
}
|
||||
|
||||
export function parseLinuxProcessState(statLine: string): string | null {
|
||||
const commandEnd = statLine.lastIndexOf(')')
|
||||
if (commandEnd < 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
statLine
|
||||
.slice(commandEnd + 1)
|
||||
.trim()
|
||||
.split(/\s+/, 1)[0] ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function present(
|
||||
reason: Extract<DaemonProcessEvidence, { state: 'present' }>['reason'],
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
): DaemonProcessEvidence {
|
||||
return { state: 'present', reason, evidenceSources }
|
||||
}
|
||||
|
||||
function gone(
|
||||
reason: Extract<DaemonProcessEvidence, { state: 'gone' }>['reason'],
|
||||
evidenceSources: DaemonEvidenceSources,
|
||||
exactIncarnation: ExactDaemonIncarnation
|
||||
): DaemonProcessEvidence {
|
||||
return { state: 'gone', reason, evidenceSources, exactIncarnation }
|
||||
}
|
||||
|
||||
function unknown(
|
||||
reason: Extract<DaemonProcessEvidence, { state: 'unknown' }>['reason'],
|
||||
evidenceSources: DaemonEvidenceSources
|
||||
): DaemonProcessEvidence {
|
||||
return { state: 'unknown', reason, evidenceSources }
|
||||
}
|
||||
@@ -241,7 +241,10 @@ vi.mock('fs', () => ({
|
||||
writeFileSync: writeFileSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({ fork: forkMock }))
|
||||
vi.mock('child_process', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
fork: forkMock
|
||||
}))
|
||||
|
||||
vi.mock('net', () => ({ connect: netConnectMock }))
|
||||
|
||||
@@ -1754,7 +1757,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
on(event: string, cb: (arg?: unknown) => void) {
|
||||
handlers[event]?.push(cb)
|
||||
if (event === 'message') {
|
||||
queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_000_000 }))
|
||||
queueMicrotask(() =>
|
||||
cb({
|
||||
type: 'ready',
|
||||
startedAtMs: 1_000_000,
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a'
|
||||
})
|
||||
)
|
||||
}
|
||||
return this
|
||||
},
|
||||
@@ -1782,6 +1792,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(JSON.parse(pidContents as string)).toEqual({
|
||||
pid: 12345,
|
||||
startedAtMs: 1_000_000,
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a',
|
||||
entryPath: FAKE_DAEMON_ENTRY_PATH,
|
||||
appVersion: '1.2.3',
|
||||
launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/)
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
confirmSeededClaudeLivePtys,
|
||||
hasSeededUnconfirmedClaudePtys
|
||||
} from '../claude-accounts/live-pty-gate'
|
||||
import { parseDaemonReadyIdentity } from './daemon-ready-identity'
|
||||
|
||||
// Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start.
|
||||
function logDaemonMilestone(event: string, details: Record<string, unknown> = {}): void {
|
||||
@@ -600,14 +601,8 @@ function createOutOfProcessLauncher(
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs
|
||||
if (
|
||||
!Number.isSafeInteger(child.pid) ||
|
||||
(child.pid as number) <= 0 ||
|
||||
typeof selfReported !== 'number' ||
|
||||
!Number.isFinite(selfReported) ||
|
||||
selfReported <= 0
|
||||
) {
|
||||
const readyIdentity = parseDaemonReadyIdentity(msg)
|
||||
if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0 || !readyIdentity) {
|
||||
void fail(new Error('Daemon readiness identity is incomplete'))
|
||||
return
|
||||
}
|
||||
@@ -617,7 +612,7 @@ function createOutOfProcessLauncher(
|
||||
pidPath,
|
||||
serializeDaemonPidFile({
|
||||
pid: child.pid as number,
|
||||
startedAtMs: selfReported,
|
||||
...readyIdentity,
|
||||
entryPath,
|
||||
appVersion: app.getVersion(),
|
||||
launchNonce
|
||||
@@ -718,7 +713,9 @@ export async function initDaemonPtyProvider(
|
||||
// Why: fail-open may already have spawned fallback PTYs; don't install late, but retire an empty daemon (live sessions reject it and survive).
|
||||
const abortedStartupAdapter = new DaemonPtyAdapter({
|
||||
socketPath: info.socketPath,
|
||||
tokenPath: info.tokenPath
|
||||
tokenPath: info.tokenPath,
|
||||
pidPath: getDaemonPidPath(runtimeDir),
|
||||
profileScope: runtimeDir
|
||||
})
|
||||
releaseDaemonAdoptionLease(newSpawner.getHandle())
|
||||
await abortedStartupAdapter.disconnectOnly()
|
||||
@@ -728,6 +725,8 @@ export async function initDaemonPtyProvider(
|
||||
const newAdapter = new DaemonPtyAdapter({
|
||||
socketPath: info.socketPath,
|
||||
tokenPath: info.tokenPath,
|
||||
pidPath: getDaemonPidPath(runtimeDir),
|
||||
profileScope: runtimeDir,
|
||||
historyPath: getHistoryDir(),
|
||||
// Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying.
|
||||
respawn: async (reason: DaemonRespawnReason) => {
|
||||
@@ -925,6 +924,8 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
|
||||
const newCurrent = new DaemonPtyAdapter({
|
||||
socketPath: info.socketPath,
|
||||
tokenPath: info.tokenPath,
|
||||
pidPath: getDaemonPidPath(runtimeDir),
|
||||
profileScope: runtimeDir,
|
||||
historyPath: getHistoryDir(),
|
||||
respawn: async (reason: DaemonRespawnReason) => {
|
||||
// Why: attribute rather than emit — the launcher below is the one that completes the
|
||||
@@ -1155,6 +1156,8 @@ export async function createLegacyDaemonAdapters(
|
||||
new DaemonPtyAdapter({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
pidPath: getDaemonPidPath(runtimeDir, protocolVersion),
|
||||
profileScope: runtimeDir,
|
||||
protocolVersion,
|
||||
historyPath
|
||||
})
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { queryWindowsProcess, readProcessCommandLine } from './daemon-process-inspection'
|
||||
|
||||
describe('daemon process inspection', () => {
|
||||
it('falls back to ps when Linux procfs returns an empty command line', async () => {
|
||||
const readTextFile = vi.fn(async () => '')
|
||||
const runCommand = vi.fn(async () => 'node daemon-entry --socket daemon.sock')
|
||||
|
||||
await expect(readProcessCommandLine(42, 'linux', { readTextFile, runCommand })).resolves.toBe(
|
||||
'node daemon-entry --socket daemon.sock'
|
||||
)
|
||||
expect(readTextFile).toHaveBeenCalledWith('/proc/42/cmdline')
|
||||
expect(runCommand).toHaveBeenCalledWith('ps', ['-p', '42', '-o', 'command='], 2_000)
|
||||
})
|
||||
|
||||
it('uses a non-empty Linux procfs command line without spawning ps', async () => {
|
||||
const readTextFile = vi.fn(async () => 'node\0daemon-entry')
|
||||
const runCommand = vi.fn()
|
||||
|
||||
await expect(readProcessCommandLine(42, 'linux', { readTextFile, runCommand })).resolves.toBe(
|
||||
'node\0daemon-entry'
|
||||
)
|
||||
expect(runCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])(
|
||||
'rejects unsafe Windows pid %s before command interpolation',
|
||||
async (pid) => {
|
||||
const runCommand = vi.fn()
|
||||
|
||||
await expect(queryWindowsProcess(pid, { runCommand })).resolves.toEqual({
|
||||
status: 'unavailable'
|
||||
})
|
||||
expect(runCommand).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { promisify } from 'node:util'
|
||||
import type {
|
||||
LinuxStatEvidence,
|
||||
ProcessSignalEvidence,
|
||||
WindowsProcessEvidence
|
||||
} from './daemon-incarnation-evidence-types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export type DaemonProcessInspectionDependencies = {
|
||||
readTextFile?: (path: string) => Promise<string>
|
||||
runCommand?: (file: string, args: string[], timeoutMs: number) => Promise<string>
|
||||
}
|
||||
|
||||
export function inspectProcessSignal(pid: number): ProcessSignalEvidence {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return 'occupied'
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, 'ESRCH')) {
|
||||
return 'missing'
|
||||
}
|
||||
if (hasErrorCode(error, 'EPERM')) {
|
||||
return 'permission_denied'
|
||||
}
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
export async function readLinuxStat(pid: number): Promise<LinuxStatEvidence> {
|
||||
try {
|
||||
return { status: 'present', value: await readFile(`/proc/${pid}/stat`, 'utf8') }
|
||||
} catch (error) {
|
||||
return { status: hasErrorCode(error, 'ENOENT') ? 'missing' : 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function readProcessCommandLine(
|
||||
pid: number,
|
||||
platform: NodeJS.Platform,
|
||||
dependencies: DaemonProcessInspectionDependencies = {}
|
||||
): Promise<string | undefined> {
|
||||
const readTextFile =
|
||||
dependencies.readTextFile ?? (async (path: string) => await readFile(path, 'utf8'))
|
||||
const runCommand = dependencies.runCommand ?? runInspectionCommand
|
||||
if (platform === 'linux') {
|
||||
try {
|
||||
const procCommandLine = await readTextFile(`/proc/${pid}/cmdline`)
|
||||
if (procCommandLine.length > 0) {
|
||||
return procCommandLine
|
||||
}
|
||||
} catch {
|
||||
// Fall through to ps for procfs privilege or mount restrictions.
|
||||
}
|
||||
}
|
||||
try {
|
||||
const stdout = await runCommand('ps', ['-p', String(pid), '-o', 'command='], 2_000)
|
||||
return stdout.trim() || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryWindowsProcess(
|
||||
pid: number,
|
||||
dependencies: DaemonProcessInspectionDependencies = {}
|
||||
): Promise<WindowsProcessEvidence> {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
const runCommand = dependencies.runCommand ?? runInspectionCommand
|
||||
try {
|
||||
const stdout = await runCommand(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` +
|
||||
`if (!$p) { @{ exists = $false } | ConvertTo-Json -Compress } else { ` +
|
||||
`$start = $null; if ($p.CreationDate) { ` +
|
||||
`$start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` +
|
||||
`@{ exists = $true; cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress }`
|
||||
],
|
||||
3_000
|
||||
)
|
||||
const parsed = JSON.parse(stdout.trim()) as {
|
||||
exists?: unknown
|
||||
cmd?: unknown
|
||||
start?: unknown
|
||||
}
|
||||
if (parsed.exists === false) {
|
||||
return { status: 'missing' }
|
||||
}
|
||||
if (parsed.exists !== true) {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
return {
|
||||
status: 'present',
|
||||
commandLine: typeof parsed.cmd === 'string' && parsed.cmd ? parsed.cmd : null,
|
||||
startedAtMs:
|
||||
typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null
|
||||
}
|
||||
} catch {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
async function runInspectionCommand(
|
||||
file: string,
|
||||
args: string[],
|
||||
timeoutMs: number
|
||||
): Promise<string> {
|
||||
const { stdout } = await execFileAsync(file, args, { encoding: 'utf8', timeout: timeoutMs })
|
||||
return stdout
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import type { SubprocessHandle } from './session'
|
||||
import type { PendingOutputRecord } from './types'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import type * as DaemonHealthModule from './daemon-health'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import { getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner'
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks'
|
||||
|
||||
@@ -1449,6 +1449,58 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(procs[0]).toHaveProperty('title')
|
||||
expect(procs[0].cwd).toBe('/repo/owned-before-osc7')
|
||||
expect(procs[0].worktreeId).toBe('repo::/repo/owned-before-osc7')
|
||||
expect(adapter.getLastAuditObservation()).toMatchObject({
|
||||
state: 'present',
|
||||
reason: 'authenticated_inventory',
|
||||
inventoryAuthority: 'authoritative'
|
||||
})
|
||||
})
|
||||
|
||||
it('loads persisted Linux birth identity for audit observations', async () => {
|
||||
adapter.dispose()
|
||||
await server.shutdown()
|
||||
const startedAtMs = 1_700_000_000_000
|
||||
const launchNonce = 'launch-with-linux-identity'
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
startedAtMs,
|
||||
launchNonce,
|
||||
log: daemonLog,
|
||||
spawnSubprocess: (opts) => {
|
||||
lastSpawnOpts = opts
|
||||
lastSubprocess = createMockSubprocess()
|
||||
return lastSubprocess
|
||||
}
|
||||
})
|
||||
await server.start()
|
||||
const pidPath = join(dir, 'daemon.pid')
|
||||
writeFileSync(
|
||||
pidPath,
|
||||
serializeDaemonPidFile({
|
||||
pid: process.pid,
|
||||
startedAtMs,
|
||||
launchNonce,
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a'
|
||||
})
|
||||
)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
||||
try {
|
||||
adapter = new DaemonPtyAdapter({ socketPath, tokenPath, pidPath })
|
||||
|
||||
await expect(adapter.listProcesses()).resolves.toEqual([])
|
||||
|
||||
expect(adapter.getLastAuditObservation()?.exactIncarnation).toMatchObject({
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a'
|
||||
})
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('reports the daemon session WSL owner', async () => {
|
||||
@@ -1470,6 +1522,104 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('retains authenticated identity and reports replacement across a same-endpoint reconnect', async () => {
|
||||
await adapter.listProcesses()
|
||||
const firstIdentity = adapter.getLastAuthenticatedDaemonIdentity()
|
||||
expect(firstIdentity).not.toBeNull()
|
||||
const identityChanges: {
|
||||
previous: NonNullable<typeof firstIdentity>
|
||||
current: NonNullable<typeof firstIdentity>
|
||||
}[] = []
|
||||
adapter.onDaemonIdentityChanged(() => {
|
||||
throw new Error('audit listener failed')
|
||||
})
|
||||
adapter.onDaemonIdentityChanged((event) => identityChanges.push(event))
|
||||
|
||||
await server.shutdown()
|
||||
await waitFor(
|
||||
() => !(adapter as unknown as { client: { isConnected(): boolean } }).client.isConnected()
|
||||
)
|
||||
expect(adapter.getLastAuthenticatedDaemonIdentity()).toEqual(firstIdentity)
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
launchNonce: 'replacement-launch',
|
||||
startedAtMs: (firstIdentity?.startedAtMs ?? 0) + 10_000,
|
||||
log: daemonLog,
|
||||
spawnSubprocess: (opts) => {
|
||||
lastSpawnOpts = opts
|
||||
lastSubprocess = createMockSubprocess()
|
||||
return lastSubprocess
|
||||
}
|
||||
})
|
||||
await server.start()
|
||||
|
||||
await expect(adapter.listProcesses()).resolves.toEqual([])
|
||||
|
||||
expect(identityChanges).toEqual([
|
||||
{
|
||||
previous: firstIdentity,
|
||||
current: {
|
||||
pid: process.pid,
|
||||
startedAtMs: (firstIdentity?.startedAtMs ?? 0) + 10_000,
|
||||
launchNonce: 'replacement-launch'
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(adapter.getLastAuthenticatedDaemonIdentity()).toEqual(identityChanges[0]?.current)
|
||||
})
|
||||
|
||||
it('isolates audit observation listeners from inventory and later listeners', async () => {
|
||||
const laterListener = vi.fn()
|
||||
adapter.onAuditEligibilityObservation(() => {
|
||||
throw new Error('audit listener failed')
|
||||
})
|
||||
adapter.onAuditEligibilityObservation(laterListener)
|
||||
|
||||
await expect(adapter.listProcesses()).resolves.toEqual([])
|
||||
|
||||
expect(laterListener).toHaveBeenCalledOnce()
|
||||
expect(laterListener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
state: 'present',
|
||||
reason: 'authenticated_inventory'
|
||||
})
|
||||
)
|
||||
expect(adapter.getLastAuditObservation()).toMatchObject({
|
||||
state: 'present',
|
||||
reason: 'authenticated_inventory'
|
||||
})
|
||||
})
|
||||
|
||||
it('audits token ENOENT only after an authenticated disconnect', async () => {
|
||||
const observations: {
|
||||
trigger: string
|
||||
state: string
|
||||
evidenceSources: readonly string[]
|
||||
}[] = []
|
||||
adapter.onAuditEligibilityObservation((observation) => observations.push(observation))
|
||||
await adapter.listProcesses()
|
||||
|
||||
await server.shutdown()
|
||||
await waitFor(
|
||||
() => !(adapter as unknown as { client: { isConnected(): boolean } }).client.isConnected()
|
||||
)
|
||||
await expect(adapter.listProcesses()).rejects.toThrow()
|
||||
await waitFor(() =>
|
||||
observations.some(
|
||||
(observation) => observation.trigger === 'token_missing_after_authenticated_disconnect'
|
||||
)
|
||||
)
|
||||
|
||||
expect(observations).toContainEqual(
|
||||
expect.objectContaining({
|
||||
trigger: 'token_missing_after_authenticated_disconnect',
|
||||
state: 'unknown',
|
||||
evidenceSources: expect.arrayContaining(['token_file'])
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasChildProcesses / getForegroundProcess', () => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */
|
||||
import { basename } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { DaemonClient } from './client'
|
||||
import { getMacDaemonSystemResolverHealth } from './daemon-health'
|
||||
import {
|
||||
getMacDaemonSystemResolverHealth,
|
||||
parseDaemonPidFile,
|
||||
type ParsedDaemonPid
|
||||
} from './daemon-health'
|
||||
import {
|
||||
HistoryManager,
|
||||
type HistoryCheckpointResult,
|
||||
@@ -63,6 +68,16 @@ import {
|
||||
TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS
|
||||
} from './terminal-history-seed-chunks'
|
||||
import { NdjsonLineTooLongError } from './ndjson'
|
||||
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
|
||||
import {
|
||||
classifyDaemonAuditFailure,
|
||||
recordAuthenticatedInventory,
|
||||
type DaemonAuditContext,
|
||||
type DaemonAuditObservation,
|
||||
type DaemonAuditTrigger
|
||||
} from './daemon-audit-classifier'
|
||||
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
|
||||
import { trackDaemonAuditEligibility } from './daemon-audit-eligibility-event'
|
||||
|
||||
type PendingDaemonSpawnOperation = {
|
||||
exitsBySessionId: Map<string, { incarnationId?: string }[]>
|
||||
@@ -100,6 +115,8 @@ function providerSequenceForSpawn(
|
||||
export type DaemonPtyAdapterOptions = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
pidPath?: string
|
||||
profileScope?: string
|
||||
protocolVersion?: number
|
||||
/** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */
|
||||
historyPath?: string
|
||||
@@ -109,6 +126,11 @@ export type DaemonPtyAdapterOptions = {
|
||||
|
||||
export type DaemonRespawnReason = 'daemon_died' | 'unhealthy_resolver'
|
||||
|
||||
export type DaemonIdentityChangeEvent = {
|
||||
previous: DaemonEndpointIdentity
|
||||
current: DaemonEndpointIdentity
|
||||
}
|
||||
|
||||
const MAX_TOMBSTONES = 1000
|
||||
const MAX_CONCURRENT_CHECKPOINTS = 4
|
||||
|
||||
@@ -130,7 +152,15 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
readonly protocolVersion: number
|
||||
private socketPath: string
|
||||
private tokenPath: string
|
||||
private pidPath: string | null
|
||||
private pidRecord: ParsedDaemonPid | null
|
||||
private client: DaemonClient
|
||||
private auditContext: DaemonAuditContext
|
||||
private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null
|
||||
private exactDaemonIncarnation: ExactDaemonIncarnation | null = null
|
||||
private lastAuditObservation: DaemonAuditObservation | null = null
|
||||
private auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = []
|
||||
private identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = []
|
||||
private historyManager: HistoryManager | null
|
||||
private historyReader: HistoryReader | null
|
||||
private respawnFn: DaemonPtyAdapterOptions['respawn'] | null
|
||||
@@ -219,6 +249,16 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION
|
||||
this.socketPath = opts.socketPath
|
||||
this.tokenPath = opts.tokenPath
|
||||
this.pidPath = opts.pidPath ?? null
|
||||
this.pidRecord = readDaemonPidRecord(this.pidPath)
|
||||
this.auditContext = {
|
||||
protocolGeneration: this.protocolVersion,
|
||||
provider: 'local-daemon',
|
||||
endpoint: opts.socketPath,
|
||||
tokenPath: opts.tokenPath,
|
||||
endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket',
|
||||
profileScope: opts.profileScope ?? ''
|
||||
}
|
||||
this.client = new DaemonClient({
|
||||
socketPath: opts.socketPath,
|
||||
tokenPath: opts.tokenPath,
|
||||
@@ -247,6 +287,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.producerResumesOwedOnReconnect.add(id)
|
||||
}
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.observeAuditFailure('transport_closed')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -254,6 +295,26 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
return this.historyManager
|
||||
}
|
||||
|
||||
getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null {
|
||||
return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null
|
||||
}
|
||||
|
||||
getLastAuditObservation(): DaemonAuditObservation | null {
|
||||
return this.lastAuditObservation
|
||||
}
|
||||
|
||||
onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void {
|
||||
this.identityChangeListeners.push(listener)
|
||||
return () => removeListener(this.identityChangeListeners, listener)
|
||||
}
|
||||
|
||||
onAuditEligibilityObservation(
|
||||
listener: (observation: DaemonAuditObservation) => void
|
||||
): () => void {
|
||||
this.auditObservationListeners.push(listener)
|
||||
return () => removeListener(this.auditObservationListeners, listener)
|
||||
}
|
||||
|
||||
supportsAgentSessionClaims(): boolean {
|
||||
return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
|
||||
}
|
||||
@@ -1262,36 +1323,57 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
async listProcesses(opts?: { deadlineMs?: number }): Promise<PtyProcessInfo[]> {
|
||||
// Why: connect + listSessions share the caller's one absolute deadline so a
|
||||
// wedged handshake cannot burn the whole teardown budget before the list issues.
|
||||
await this.ensureConnected(opts?.deadlineMs)
|
||||
const result = await this.client.request<ListSessionsResult>(
|
||||
'listSessions',
|
||||
undefined,
|
||||
remainingRequestTimeoutMs(opts?.deadlineMs)
|
||||
)
|
||||
const admission = new PtyProcessListAdmission()
|
||||
const processes: PtyProcessInfo[] = []
|
||||
for (const session of result.sessions) {
|
||||
if (!session.isAlive) {
|
||||
continue
|
||||
}
|
||||
const { worktreeId } = parsePtySessionId(session.sessionId)
|
||||
processes.push(
|
||||
admission.admit({
|
||||
id: session.sessionId,
|
||||
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
|
||||
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
|
||||
cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '',
|
||||
title: 'shell',
|
||||
...(worktreeId ? { worktreeId } : {}),
|
||||
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
|
||||
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}),
|
||||
...this.validatedAgentSessionOwners(session.agentSessionOwners)
|
||||
})
|
||||
try {
|
||||
// Why: connect + listSessions share the caller's one absolute deadline so a
|
||||
// wedged handshake cannot burn the whole teardown budget before the list issues.
|
||||
await this.ensureConnected(opts?.deadlineMs)
|
||||
const result = await this.client.request<ListSessionsResult>(
|
||||
'listSessions',
|
||||
undefined,
|
||||
remainingRequestTimeoutMs(opts?.deadlineMs)
|
||||
)
|
||||
const admission = new PtyProcessListAdmission()
|
||||
const processes: PtyProcessInfo[] = []
|
||||
for (const session of result.sessions) {
|
||||
if (!session.isAlive) {
|
||||
continue
|
||||
}
|
||||
const { worktreeId } = parsePtySessionId(session.sessionId)
|
||||
processes.push(
|
||||
admission.admit({
|
||||
id: session.sessionId,
|
||||
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
|
||||
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
|
||||
cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '',
|
||||
title: 'shell',
|
||||
...(worktreeId ? { worktreeId } : {}),
|
||||
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
|
||||
...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}),
|
||||
...this.validatedAgentSessionOwners(session.agentSessionOwners)
|
||||
})
|
||||
)
|
||||
}
|
||||
this.publishAuditObservation(
|
||||
recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation)
|
||||
)
|
||||
return processes
|
||||
} catch (error) {
|
||||
const missingAuthenticatedToken =
|
||||
isMissingTokenFileError(error) && this.client.hasObservedAuthenticatedDisconnect()
|
||||
const missingNamedPipe = isMissingWindowsNamedPipeError(error)
|
||||
this.observeAuditFailure(
|
||||
missingAuthenticatedToken
|
||||
? 'token_missing_after_authenticated_disconnect'
|
||||
: 'inventory_failed',
|
||||
this.exactDaemonIncarnation,
|
||||
[
|
||||
...(missingAuthenticatedToken ? (['token_file'] as const) : []),
|
||||
...(missingNamedPipe ? (['windows_named_pipe'] as const) : [])
|
||||
],
|
||||
missingNamedPipe ? 'windows_named_pipe_missing' : undefined
|
||||
)
|
||||
throw error
|
||||
}
|
||||
return processes
|
||||
}
|
||||
|
||||
private validatedAgentSessionOwners(
|
||||
@@ -1452,6 +1534,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.wslDistrosBySessionId.clear()
|
||||
this.pausedProducerSessionIds.clear()
|
||||
this.producerResumesOwedOnReconnect.clear()
|
||||
this.auditObservationListeners.length = 0
|
||||
this.identityChangeListeners.length = 0
|
||||
this.removeEventListener?.()
|
||||
this.removeEventListener = null
|
||||
// Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions
|
||||
@@ -1470,6 +1554,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
// Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit.
|
||||
await this.client.ensureConnected()
|
||||
this.recordAuthenticatedIdentity()
|
||||
}
|
||||
|
||||
// Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable,
|
||||
@@ -1532,6 +1617,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks.
|
||||
this.releasePendingRespawnAdoptionLease()
|
||||
}
|
||||
this.recordAuthenticatedIdentity()
|
||||
// Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the
|
||||
// daemon-side backgrounded set (process state lost with the old daemon) needs a resync.
|
||||
const isFreshConnection = this.removeEventListener === null
|
||||
@@ -1543,6 +1629,104 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private recordAuthenticatedIdentity(): void {
|
||||
const current = this.client.getDaemonIdentity()
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
const previous = this.lastAuthenticatedIdentity
|
||||
if (previous && sameEndpointIdentity(previous, current)) {
|
||||
return
|
||||
}
|
||||
this.lastAuthenticatedIdentity = { ...current }
|
||||
this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord)
|
||||
if (!previous) {
|
||||
return
|
||||
}
|
||||
const event = { previous: { ...previous }, current: { ...current } }
|
||||
notifyAuditListeners(this.identityChangeListeners, event)
|
||||
}
|
||||
|
||||
private async resolveExactDaemonIncarnation(
|
||||
exactIncarnation: ExactDaemonIncarnation | null
|
||||
): Promise<ExactDaemonIncarnation | null> {
|
||||
if (
|
||||
!exactIncarnation ||
|
||||
process.platform !== 'linux' ||
|
||||
(exactIncarnation.linuxStartTicks && exactIncarnation.bootId)
|
||||
) {
|
||||
return exactIncarnation
|
||||
}
|
||||
const cachedIncarnation = exactDaemonIncarnationForPidRecord(
|
||||
exactIncarnation.identity,
|
||||
this.pidRecord
|
||||
)
|
||||
if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) {
|
||||
return cachedIncarnation
|
||||
}
|
||||
const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity)
|
||||
this.pidRecord = pidRecord ?? this.pidRecord
|
||||
return pidRecord?.linuxStartTicks && pidRecord.bootId
|
||||
? {
|
||||
identity: { ...exactIncarnation.identity },
|
||||
linuxStartTicks: pidRecord.linuxStartTicks,
|
||||
bootId: pidRecord.bootId
|
||||
}
|
||||
: exactIncarnation
|
||||
}
|
||||
|
||||
private cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void {
|
||||
if (
|
||||
exactIncarnation &&
|
||||
this.lastAuthenticatedIdentity &&
|
||||
sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity)
|
||||
) {
|
||||
this.exactDaemonIncarnation = exactIncarnation
|
||||
}
|
||||
}
|
||||
|
||||
private async readMatchingPidRecord(
|
||||
identity: DaemonEndpointIdentity
|
||||
): Promise<ParsedDaemonPid | null> {
|
||||
if (!this.pidPath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8'))
|
||||
return parsed?.pid === identity.pid &&
|
||||
parsed.startedAtMs === identity.startedAtMs &&
|
||||
parsed.launchNonce === identity.launchNonce
|
||||
? parsed
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private observeAuditFailure(
|
||||
trigger: Exclude<DaemonAuditTrigger, 'inventory_answered'>,
|
||||
exactIncarnation = this.exactDaemonIncarnation,
|
||||
additionalEvidenceSources: readonly DaemonEvidenceSource[] = [],
|
||||
endpointGoneProof?: 'windows_named_pipe_missing'
|
||||
): void {
|
||||
void this.resolveExactDaemonIncarnation(exactIncarnation)
|
||||
.then((resolvedIncarnation) => {
|
||||
this.cacheExactDaemonIncarnation(resolvedIncarnation)
|
||||
return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, {
|
||||
additionalEvidenceSources,
|
||||
endpointGoneProof
|
||||
})
|
||||
})
|
||||
.then((observation) => this.publishAuditObservation(observation))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
private publishAuditObservation(observation: DaemonAuditObservation): void {
|
||||
this.lastAuditObservation = observation
|
||||
trackDaemonAuditEligibility(observation)
|
||||
notifyAuditListeners(this.auditObservationListeners, observation)
|
||||
}
|
||||
|
||||
private resyncBackgroundedSessions(): void {
|
||||
for (const id of this.backgroundedSessionIds) {
|
||||
// Harmless no-op for sessions the daemon doesn't know (yet).
|
||||
@@ -1829,6 +2013,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// Why: the token is removed only after an authenticated drop; an initial missing token may still hide a live daemon.
|
||||
const missingRetiredEndpointToken =
|
||||
isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect()
|
||||
if (missingRetiredEndpointToken) {
|
||||
this.observeAuditFailure(
|
||||
'token_missing_after_authenticated_disconnect',
|
||||
this.exactDaemonIncarnation,
|
||||
['token_file']
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.respawnAdoptionClosed ||
|
||||
!this.respawnFn ||
|
||||
@@ -2135,6 +2326,65 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
|
||||
function sameEndpointIdentity(
|
||||
left: DaemonEndpointIdentity,
|
||||
right: DaemonEndpointIdentity
|
||||
): boolean {
|
||||
return (
|
||||
left.pid === right.pid &&
|
||||
left.startedAtMs === right.startedAtMs &&
|
||||
left.launchNonce === right.launchNonce
|
||||
)
|
||||
}
|
||||
|
||||
function exactDaemonIncarnationForPidRecord(
|
||||
identity: DaemonEndpointIdentity,
|
||||
pidRecord: ParsedDaemonPid | null
|
||||
): ExactDaemonIncarnation {
|
||||
return {
|
||||
identity: { ...identity },
|
||||
...(process.platform === 'linux' &&
|
||||
pidRecord?.pid === identity.pid &&
|
||||
pidRecord.startedAtMs === identity.startedAtMs &&
|
||||
pidRecord.launchNonce === identity.launchNonce &&
|
||||
pidRecord.linuxStartTicks &&
|
||||
pidRecord.bootId
|
||||
? {
|
||||
linuxStartTicks: pidRecord.linuxStartTicks,
|
||||
bootId: pidRecord.bootId
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null {
|
||||
if (!pidPath) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return parseDaemonPidFile(readFileSync(pidPath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function removeListener<T>(listeners: T[], listener: T): void {
|
||||
const index = listeners.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function notifyAuditListeners<T>(listeners: readonly ((value: T) => void)[], value: T): void {
|
||||
for (const listener of listeners.slice()) {
|
||||
try {
|
||||
listener(value)
|
||||
} catch {
|
||||
// Audit observers cannot affect daemon operations.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall);
|
||||
// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn.
|
||||
function isDaemonGoneError(err: unknown): boolean {
|
||||
@@ -2161,3 +2411,11 @@ function isMissingTokenFileError(err: unknown): boolean {
|
||||
const errno = err as NodeJS.ErrnoException
|
||||
return errno.code === 'ENOENT' && errno.syscall === 'open'
|
||||
}
|
||||
|
||||
function isMissingWindowsNamedPipeError(err: unknown): boolean {
|
||||
if (process.platform !== 'win32' || !(err instanceof Error)) {
|
||||
return false
|
||||
}
|
||||
const errno = err as NodeJS.ErrnoException
|
||||
return errno.code === 'ENOENT' && errno.syscall === 'connect'
|
||||
}
|
||||
|
||||
@@ -546,6 +546,35 @@ describe('DaemonPtyRouter', () => {
|
||||
await expect(router.listProcesses()).rejects.toThrow('legacy unavailable')
|
||||
})
|
||||
|
||||
it('keeps a legacy adapter that exits after construction in fail-closed aggregates', async () => {
|
||||
const current = createAdapter('current', ['current-session'])
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
await router.discoverLegacySessions()
|
||||
vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy exited'))
|
||||
|
||||
await expect(router.listProcesses()).rejects.toThrow('legacy exited')
|
||||
await expect(router.listProcesses()).rejects.toThrow('legacy exited')
|
||||
expect(router.getLegacyAdapters()).toEqual([legacy])
|
||||
expect(current.listProcesses).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('pins colliding unmapped legacy ids falling through to the current daemon', async () => {
|
||||
const sessionId = 'cross-generation-collision'
|
||||
const current = createAdapter('current', [sessionId])
|
||||
const legacy = createAdapter('legacy', [sessionId])
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.mocked(legacy.listProcesses).mockRejectedValueOnce(new Error('legacy discovery failed'))
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
|
||||
await router.discoverLegacySessions()
|
||||
router.write(sessionId, 'misrouted\n')
|
||||
|
||||
expect(current.write).toHaveBeenCalledWith(sessionId, 'misrouted\n')
|
||||
expect(legacy.write).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('merges startup reconciliation and updates route mappings', async () => {
|
||||
const current = createAdapter('current', [], {
|
||||
alive: ['current-alive'],
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseDaemonReadyIdentity } from './daemon-ready-identity'
|
||||
|
||||
describe('parseDaemonReadyIdentity', () => {
|
||||
it('accepts additive Linux incarnation identity', () => {
|
||||
expect(
|
||||
parseDaemonReadyIdentity({
|
||||
type: 'ready',
|
||||
startedAtMs: 1_700_000_000_000,
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a'
|
||||
})
|
||||
).toEqual({
|
||||
startedAtMs: 1_700_000_000_000,
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps older ready payloads compatible', () => {
|
||||
expect(parseDaemonReadyIdentity({ type: 'ready', startedAtMs: 123 })).toEqual({
|
||||
startedAtMs: 123
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects partial Linux identity instead of persisting a false proof', () => {
|
||||
expect(
|
||||
parseDaemonReadyIdentity({
|
||||
type: 'ready',
|
||||
startedAtMs: 123,
|
||||
linuxStartTicks: '4242'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parseLinuxStartTicks, readBootIdentity } from '../agent-hooks/managed-hook-owner-identity'
|
||||
|
||||
export type DaemonReadyIdentity = {
|
||||
startedAtMs: number
|
||||
linuxStartTicks?: string
|
||||
bootId?: string
|
||||
}
|
||||
|
||||
export async function readCurrentDaemonReadyIdentity(
|
||||
startedAtMs: number
|
||||
): Promise<DaemonReadyIdentity> {
|
||||
if (process.platform !== 'linux') {
|
||||
return { startedAtMs }
|
||||
}
|
||||
try {
|
||||
const linuxStartTicks = parseLinuxStartTicks(readFileSync('/proc/self/stat', 'utf8'))
|
||||
const bootId = await readBootIdentity()
|
||||
return linuxStartTicks && bootId ? { startedAtMs, linuxStartTicks, bootId } : { startedAtMs }
|
||||
} catch {
|
||||
return { startedAtMs }
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDaemonReadyIdentity(message: unknown): DaemonReadyIdentity | null {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return null
|
||||
}
|
||||
const value = message as {
|
||||
startedAtMs?: unknown
|
||||
linuxStartTicks?: unknown
|
||||
bootId?: unknown
|
||||
}
|
||||
if (
|
||||
typeof value.startedAtMs !== 'number' ||
|
||||
!Number.isFinite(value.startedAtMs) ||
|
||||
value.startedAtMs <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const hasLinuxStartTicks = value.linuxStartTicks !== undefined
|
||||
const hasBootId = value.bootId !== undefined
|
||||
if (hasLinuxStartTicks !== hasBootId) {
|
||||
return null
|
||||
}
|
||||
if (!hasLinuxStartTicks) {
|
||||
return { startedAtMs: value.startedAtMs }
|
||||
}
|
||||
if (
|
||||
typeof value.linuxStartTicks !== 'string' ||
|
||||
value.linuxStartTicks.length === 0 ||
|
||||
typeof value.bootId !== 'string' ||
|
||||
value.bootId.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
startedAtMs: value.startedAtMs,
|
||||
linuxStartTicks: value.linuxStartTicks,
|
||||
bootId: value.bootId
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export type DaemonPidFile = {
|
||||
entryPath?: string
|
||||
appVersion?: string
|
||||
launchNonce?: string
|
||||
linuxStartTicks?: string
|
||||
bootId?: string
|
||||
}
|
||||
|
||||
export type DaemonProcessHandle = {
|
||||
|
||||
@@ -405,4 +405,19 @@ describe('DegradedDaemonPtyProvider', () => {
|
||||
expect(provider.getCurrentDaemonSessionIds()).toEqual([])
|
||||
expect(provider.hasPty('legacy-session')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an exited legacy daemon poisoning listProcesses after construction', async () => {
|
||||
const current = createDaemonAdapter('daemon', ['current-session'])
|
||||
const legacy = createDaemonAdapter('legacy', ['legacy-session'])
|
||||
const fallback = createProvider('fallback', ['fallback-session'])
|
||||
const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback })
|
||||
await provider.discoverDaemonSessions()
|
||||
vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('legacy exited'))
|
||||
|
||||
await expect(provider.listProcesses()).rejects.toThrow('legacy exited')
|
||||
await expect(provider.listProcesses()).rejects.toThrow('legacy exited')
|
||||
expect(provider.getLegacyAdapters()).toEqual([legacy])
|
||||
expect(current.listProcesses).toHaveBeenCalledTimes(3)
|
||||
expect(fallback.listProcesses).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,14 +58,16 @@ describe('createProductionLauncher', () => {
|
||||
|
||||
it('returns a launcher function', () => {
|
||||
const launcher = createProductionLauncher({
|
||||
getDaemonEntryPath: () => '/fake/path.js'
|
||||
getDaemonEntryPath: () => '/fake/path.js',
|
||||
getAppVersion: () => '1.2.3'
|
||||
})
|
||||
expect(typeof launcher).toBe('function')
|
||||
})
|
||||
|
||||
it('rejects either ownership argument without its pair before forking', async () => {
|
||||
const launcher = createProductionLauncher({
|
||||
getDaemonEntryPath: () => '/fake/path.js'
|
||||
getDaemonEntryPath: () => '/fake/path.js',
|
||||
getAppVersion: () => '1.2.3'
|
||||
})
|
||||
|
||||
await expect(
|
||||
@@ -126,12 +128,18 @@ describe('createProductionLauncher', () => {
|
||||
forkMock.mockReturnValueOnce(child)
|
||||
|
||||
const launcher = createProductionLauncher({
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
|
||||
getAppVersion: () => '1.2.3'
|
||||
})
|
||||
|
||||
const pidPath = join(dir, 'daemon.pid')
|
||||
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-a')
|
||||
handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
|
||||
handlers.message[0]?.({
|
||||
type: 'ready',
|
||||
startedAtMs: 123_456,
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a'
|
||||
})
|
||||
const handle = await launch
|
||||
|
||||
expect(handle.shutdown).toEqual(expect.any(Function))
|
||||
@@ -143,7 +151,10 @@ describe('createProductionLauncher', () => {
|
||||
expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual({
|
||||
pid: 12345,
|
||||
startedAtMs: 123_456,
|
||||
linuxStartTicks: '4242',
|
||||
bootId: 'boot-a',
|
||||
entryPath: join(dir, 'daemon-entry.js'),
|
||||
appVersion: '1.2.3',
|
||||
launchNonce: 'launch-a'
|
||||
})
|
||||
expect(forkMock).toHaveBeenCalledWith(
|
||||
@@ -194,7 +205,8 @@ describe('createProductionLauncher', () => {
|
||||
}
|
||||
forkMock.mockReturnValueOnce(child)
|
||||
const launcher = createProductionLauncher({
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
|
||||
getAppVersion: () => '1.2.3'
|
||||
})
|
||||
const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
|
||||
handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 })
|
||||
@@ -241,7 +253,8 @@ describe('createProductionLauncher', () => {
|
||||
forkMock.mockReturnValueOnce(child)
|
||||
|
||||
const launcher = createProductionLauncher({
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
|
||||
getAppVersion: () => '1.2.3'
|
||||
})
|
||||
|
||||
const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
|
||||
@@ -302,7 +315,8 @@ describe('createProductionLauncher', () => {
|
||||
forkMock.mockReturnValueOnce(child)
|
||||
|
||||
const launcher = createProductionLauncher({
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
|
||||
getAppVersion: () => '1.2.3'
|
||||
})
|
||||
const launch = launcher(socketPathFor(dir), tokenPathFor(dir))
|
||||
handlers.message[0]?.({ type: 'ready' })
|
||||
@@ -359,7 +373,8 @@ describe('createProductionLauncher', () => {
|
||||
const pidPath = join(dir, 'occupied.pid')
|
||||
writeFileSync(pidPath, 'occupied')
|
||||
const launcher = createProductionLauncher({
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js')
|
||||
getDaemonEntryPath: () => join(dir, 'daemon-entry.js'),
|
||||
getAppVersion: () => '1.2.3'
|
||||
})
|
||||
|
||||
const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b')
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
type DaemonLauncher,
|
||||
type DaemonProcessHandle
|
||||
} from './daemon-spawner'
|
||||
import { parseDaemonReadyIdentity, type DaemonReadyIdentity } from './daemon-ready-identity'
|
||||
|
||||
const READY_TIMEOUT_MS = 10_000
|
||||
|
||||
export type ProductionLauncherOptions = {
|
||||
getDaemonEntryPath: () => string
|
||||
getAppVersion: () => string
|
||||
}
|
||||
|
||||
export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher {
|
||||
@@ -45,9 +47,9 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
|
||||
}
|
||||
)
|
||||
|
||||
let startedAtMs: number
|
||||
let readyIdentity: DaemonReadyIdentity
|
||||
try {
|
||||
startedAtMs = await waitForReady(child)
|
||||
readyIdentity = await waitForReady(child)
|
||||
} catch (error) {
|
||||
return rejectAfterChildCleanup(child, error)
|
||||
}
|
||||
@@ -60,8 +62,9 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
|
||||
pidPath,
|
||||
serializeDaemonPidFile({
|
||||
pid: child.pid as number,
|
||||
startedAtMs,
|
||||
...readyIdentity,
|
||||
entryPath,
|
||||
appVersion: opts.getAppVersion(),
|
||||
launchNonce
|
||||
}),
|
||||
{ mode: 0o600, flag: 'wx' }
|
||||
@@ -81,7 +84,7 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo
|
||||
}
|
||||
}
|
||||
|
||||
function waitForReady(child: ChildProcess): Promise<number> {
|
||||
function waitForReady(child: ChildProcess): Promise<DaemonReadyIdentity> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
let settled = false
|
||||
@@ -106,8 +109,8 @@ function waitForReady(child: ChildProcess): Promise<number> {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
const startedAtMs = (msg as { startedAtMs?: unknown }).startedAtMs
|
||||
if (typeof startedAtMs !== 'number' || !Number.isFinite(startedAtMs) || startedAtMs <= 0) {
|
||||
const readyIdentity = parseDaemonReadyIdentity(msg)
|
||||
if (!readyIdentity) {
|
||||
fail(new Error('Daemon readiness identity is incomplete'))
|
||||
return
|
||||
}
|
||||
@@ -115,7 +118,7 @@ function waitForReady(child: ChildProcess): Promise<number> {
|
||||
// Why: the daemon is detached after readiness, so startup listeners
|
||||
// must not keep the child process closure alive for the daemon lifetime.
|
||||
cleanupStartupListeners()
|
||||
resolve(startedAtMs)
|
||||
resolve(readyIdentity)
|
||||
}
|
||||
}
|
||||
function onError(err: Error): void {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner'
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||||
resolve = nextResolve
|
||||
reject = nextReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('GitStatusReadLeaseOwner', () => {
|
||||
it('cancels first and later callers without aborting a remaining lease', async () => {
|
||||
const owner = new GitStatusReadLeaseOwner<string>()
|
||||
const pending = deferred<string>()
|
||||
const load = vi.fn((_signal: AbortSignal) => pending.promise)
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
const thirdController = new AbortController()
|
||||
const firstError = new Error('first cancelled')
|
||||
const thirdError = new Error('third cancelled')
|
||||
|
||||
const first = owner.lease('repo', firstController.signal, load)
|
||||
const second = owner.lease('repo', secondController.signal, load)
|
||||
const third = owner.lease('repo', thirdController.signal, load)
|
||||
firstController.abort(firstError)
|
||||
thirdController.abort(thirdError)
|
||||
|
||||
await expect(first).rejects.toBe(firstError)
|
||||
await expect(third).rejects.toBe(thirdError)
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
expect(load.mock.calls[0][0].aborted).toBe(false)
|
||||
|
||||
pending.resolve('fresh')
|
||||
await expect(second).resolves.toBe('fresh')
|
||||
})
|
||||
|
||||
it('aborts underlying work when every live lease cancels and admits a fresh read', async () => {
|
||||
const owner = new GitStatusReadLeaseOwner<string>()
|
||||
const firstPending = deferred<string>()
|
||||
const secondPending = deferred<string>()
|
||||
const signals: AbortSignal[] = []
|
||||
const load = vi.fn((signal: AbortSignal) => {
|
||||
signals.push(signal)
|
||||
return signals.length === 1 ? firstPending.promise : secondPending.promise
|
||||
})
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
|
||||
const first = owner.lease('repo', firstController.signal, load)
|
||||
const second = owner.lease('repo', secondController.signal, load)
|
||||
firstController.abort(new Error('first cancelled'))
|
||||
expect(signals[0]?.aborted).toBe(false)
|
||||
secondController.abort(new Error('second cancelled'))
|
||||
|
||||
await expect(first).rejects.toThrow('first cancelled')
|
||||
await expect(second).rejects.toThrow('second cancelled')
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
|
||||
const fresh = owner.lease('repo', undefined, load)
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
secondPending.resolve('fresh')
|
||||
await expect(fresh).resolves.toBe('fresh')
|
||||
firstPending.reject(new Error('underlying aborted'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('rejects a pre-aborted caller without starting or joining work', async () => {
|
||||
const owner = new GitStatusReadLeaseOwner<string>()
|
||||
const pending = deferred<string>()
|
||||
const load = vi.fn(() => pending.promise)
|
||||
const active = owner.lease('repo', undefined, load)
|
||||
const controller = new AbortController()
|
||||
const abortError = new Error('already cancelled')
|
||||
controller.abort(abortError)
|
||||
|
||||
await expect(owner.lease('repo', controller.signal, load)).rejects.toBe(abortError)
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
|
||||
pending.resolve('active')
|
||||
await expect(active).resolves.toBe('active')
|
||||
})
|
||||
|
||||
it('cleans listeners and entries after success, failure, and cancellation', async () => {
|
||||
const owner = new GitStatusReadLeaseOwner<string>()
|
||||
const successController = new AbortController()
|
||||
const successRemove = vi.spyOn(successController.signal, 'removeEventListener')
|
||||
await expect(
|
||||
owner.lease('success', successController.signal, async () => 'success')
|
||||
).resolves.toBe('success')
|
||||
expect(successRemove).toHaveBeenCalledOnce()
|
||||
|
||||
const failureController = new AbortController()
|
||||
const failureRemove = vi.spyOn(failureController.signal, 'removeEventListener')
|
||||
const failure = new Error('failed')
|
||||
await expect(
|
||||
owner.lease('failure', failureController.signal, async () => {
|
||||
throw failure
|
||||
})
|
||||
).rejects.toBe(failure)
|
||||
expect(failureRemove).toHaveBeenCalledOnce()
|
||||
|
||||
const cancelController = new AbortController()
|
||||
const cancelRemove = vi.spyOn(cancelController.signal, 'removeEventListener')
|
||||
const cancelPending = deferred<string>()
|
||||
const cancelled = owner.lease('cancel', cancelController.signal, () => cancelPending.promise)
|
||||
cancelController.abort()
|
||||
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(cancelRemove).toHaveBeenCalledOnce()
|
||||
|
||||
const freshLoad = vi.fn(async () => 'fresh')
|
||||
await expect(owner.lease('success', undefined, freshLoad)).resolves.toBe('fresh')
|
||||
await expect(owner.lease('failure', undefined, freshLoad)).resolves.toBe('fresh')
|
||||
await expect(owner.lease('cancel', undefined, freshLoad)).resolves.toBe('fresh')
|
||||
expect(freshLoad).toHaveBeenCalledTimes(3)
|
||||
cancelPending.reject(new Error('underlying aborted'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('invalidates joinability without breaking already-issued leases', async () => {
|
||||
const owner = new GitStatusReadLeaseOwner<string>()
|
||||
const firstPending = deferred<string>()
|
||||
const secondPending = deferred<string>()
|
||||
const load = vi
|
||||
.fn<(_signal: AbortSignal) => Promise<string>>()
|
||||
.mockReturnValueOnce(firstPending.promise)
|
||||
.mockReturnValueOnce(secondPending.promise)
|
||||
|
||||
const first = owner.lease('repo', undefined, load)
|
||||
owner.invalidate()
|
||||
const second = owner.lease('repo', undefined, load)
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
|
||||
firstPending.resolve('pre-mutation')
|
||||
secondPending.resolve('post-mutation')
|
||||
await expect(first).resolves.toBe('pre-mutation')
|
||||
await expect(second).resolves.toBe('post-mutation')
|
||||
})
|
||||
|
||||
it('never caches a settled read', async () => {
|
||||
const owner = new GitStatusReadLeaseOwner<string>()
|
||||
const load = vi.fn(async () => `read-${load.mock.calls.length}`)
|
||||
|
||||
await expect(owner.lease('repo', undefined, load)).resolves.toBe('read-1')
|
||||
await expect(owner.lease('repo', undefined, load)).resolves.toBe('read-2')
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user