Merge remote-tracking branch 'origin/main' into brennanb2025/mobile-i18n-audit

This commit is contained in:
Brennan Benson
2026-07-31 00:24:54 -07:00
395 changed files with 28226 additions and 2902 deletions
@@ -0,0 +1,197 @@
name: Install SignPath PowerShell module
description: >-
Installs the SignPath PowerShell module (Get-SignedArtifact) from PSGallery,
falling back to a pinned, hash-verified nupkg from the gallery CDN when the
gallery's package API is unavailable.
inputs:
fallback-version:
description: Module version fetched directly from the CDN when the gallery API is unreachable.
required: false
default: 4.4.6
fallback-sha256:
description: >-
SHA-256 of the pinned fallback nupkg. The CDN path bypasses the gallery's own
package validation, so this hash is the only integrity check on that route.
required: false
default: 2487357a9a02c7d985baaf9ebd9158b4ce877316a2d9de3a6e9af1b263c0a32d
runs:
using: composite
steps:
- name: Install SignPath PowerShell module
shell: pwsh
env:
SIGNPATH_FALLBACK_VERSION: ${{ inputs.fallback-version }}
SIGNPATH_FALLBACK_SHA256: ${{ inputs.fallback-sha256 }}
run: |
$ErrorActionPreference = 'Stop'
# Why: force TLS 1.2 so gallery downloads work on older hosted images.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
# Why: on some hosted Windows images `Register-PSRepository -Default`
# fails inside the legacy nuget.exe provider with "Missing option value
# for: '-source'", so PSGallery is never registered and the install
# below dies with "No repository with the name 'PSGallery'". PSResourceGet
# (bundled with PowerShell 7.4+) has PSGallery registered by default and
# avoids that code path, so prefer it and fall back to PowerShellGet only
# when it is absent.
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
try {
if ($useResourceGet) {
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSResourceRepository -PSGallery -Trusted
} else {
Set-PSResourceRepository -Name PSGallery -Trusted
}
} else {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSRepository -Default -InstallationPolicy Trusted
}
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
}
} catch {
# Why: repository registration also talks to the gallery, so a gallery
# outage can fail here before a single install is attempted. The CDN
# fallback below does not need a registered repository, so keep going.
Write-Warning "PSGallery repository registration failed: $_"
}
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
$currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator |
Where-Object {
if ([string]::IsNullOrWhiteSpace($_)) {
$false
} else {
$candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars)
$candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase)
}
} |
Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) {
throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.'
}
$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'
function Test-SignPathModule {
Import-Module SignPath -ErrorAction Stop
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
}
function Remove-SignPathModuleDirectory {
if (Test-Path -LiteralPath $signPathModulePath) {
Write-Warning "Removing current-user SignPath module directory: $signPathModulePath"
Remove-Item -LiteralPath $signPathModulePath -Recurse -Force
}
}
$installed = $false
for ($attempt = 1; $attempt -le 3; $attempt++) {
if ($attempt -eq 2) {
Start-Sleep -Seconds 15
} elseif ($attempt -eq 3) {
Start-Sleep -Seconds 30
}
try {
if ($useResourceGet) {
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
} else {
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
}
Test-SignPathModule
$installed = $true
break
} catch {
Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_"
Remove-SignPathModuleDirectory
}
}
# Why: the gallery's package API (OData search + repository metadata) sits
# behind Azure Front Door and has returned 403/502/504 for every install
# attempt during gallery incidents, which hard-failed the whole Windows
# release job. The CDN that serves the nupkg itself is a separate origin
# and stays up through those incidents, so fall back to a pinned version
# fetched straight from it. The hash pin is mandatory: this route skips the
# gallery's package validation, so an unexpected payload must fail loudly.
if (-not $installed) {
$version = $env:SIGNPATH_FALLBACK_VERSION
$expectedHash = $env:SIGNPATH_FALLBACK_SHA256
Write-Warning "PSGallery install failed; falling back to pinned SignPath $version from the gallery CDN."
$nupkg = Join-Path -Path $env:RUNNER_TEMP -ChildPath "signpath-$version.zip"
if (Test-Path -LiteralPath $nupkg) {
Remove-Item -LiteralPath $nupkg -Force
}
# Why two URLs: the /api/v2/package route 302s to the CDN and can serve
# while the OData search endpoint is failing; the CDN URL is the same
# redirect target reached directly when the api host is down entirely.
$sources = @(
"https://www.powershellgallery.com/api/v2/package/SignPath/$version",
"https://cdn.powershellgallery.com/packages/signpath.$version.nupkg"
)
$downloaded = $false
foreach ($source in $sources) {
for ($attempt = 1; $attempt -le 3; $attempt++) {
if ($attempt -gt 1) {
Start-Sleep -Seconds (10 * $attempt)
}
try {
Invoke-WebRequest -Uri $source -OutFile $nupkg -MaximumRedirection 5 -UseBasicParsing -ErrorAction Stop
$actualHash = (Get-FileHash -LiteralPath $nupkg -Algorithm SHA256).Hash
if ($actualHash -ne $expectedHash.ToUpperInvariant()) {
throw "SHA-256 mismatch for $source (expected $expectedHash, got $actualHash)."
}
$downloaded = $true
Write-Host "Downloaded and verified SignPath $version from $source"
break
} catch {
Write-Warning "SignPath CDN download attempt $attempt from $source failed: $_"
if (Test-Path -LiteralPath $nupkg) {
Remove-Item -LiteralPath $nupkg -Force
}
}
}
if ($downloaded) {
break
}
}
if (-not $downloaded) {
throw "Unable to install the SignPath PowerShell module: PSGallery installs failed and the pinned $version nupkg could not be downloaded from any source."
}
Remove-SignPathModuleDirectory
# Why a version-named subdirectory: PowerShell only treats a nested folder
# as a side-by-side module version when the name matches the manifest's
# ModuleVersion, which is what makes `Import-Module SignPath` resolve it.
$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version
New-Item -ItemType Directory -Path $versionRoot -Force | Out-Null
Expand-Archive -LiteralPath $nupkg -DestinationPath $versionRoot -Force
# Why: strip nupkg packaging entries so only the module files remain.
foreach ($entry in @('_rels', 'package', '[Content_Types].xml', 'SignPath.nuspec')) {
$path = Join-Path -Path $versionRoot -ChildPath $entry
if (Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Recurse -Force
}
}
$manifest = Join-Path -Path $versionRoot -ChildPath 'SignPath.psd1'
if (-not (Test-Path -LiteralPath $manifest)) {
throw "Pinned SignPath nupkg did not contain SignPath.psd1 at $versionRoot."
}
Test-SignPathModule
}
+36
View File
@@ -9,6 +9,12 @@ on:
- 'config/scripts/build-windows-cli-launcher.mjs'
- 'config/scripts/build-windows-cli-launcher.test.mjs'
- 'config/scripts/computer-e2e-workflow.test.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-benchmark.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-metrics.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-processes.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs'
- 'config/scripts/macos-computer-helper-owner-loss-trial-cleanup.mjs'
- 'config/scripts/computer-use-modifier-safety.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
@@ -59,6 +65,8 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
@@ -85,6 +93,8 @@ jobs:
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
config/scripts/computer-use-modifier-safety.test.mjs
config/scripts/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
@@ -140,6 +150,32 @@ jobs:
ORCA_COMPUTER_E2E: '1'
run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts
mac-native-owner-smoke:
if: github.event_name == 'pull_request'
runs-on: macos-15
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
- name: Owner-loss benchmark process cleanup
run: >-
pnpm vitest run
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
config/scripts/macos-computer-helper-owner-loss-processes.test.mjs
- name: Authenticated helper owner-loss smoke
run: pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1
- name: Swift tests and signed universal helper verification
run: pnpm verify:computer-native
mac:
# macOS Accessibility and Screen Recording require user-granted TCC entries.
# Keep this on manual/scheduled permission-bearing runners instead of PR CI.
+7 -4
View File
@@ -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
+330
View File
@@ -0,0 +1,330 @@
name: Hourly macOS Dev Build
# Why: gives developers a signed macOS build of main every hour that the in-app
# updater can install directly, without waiting for an RC cut.
#
# Deliberately narrow scope:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
# and release-cut remain the gates that matter.
# - Signed but NOT notarized. Squirrel.Mac validates the replacement bundle's
# signature, not its notarization, so in-place updates still work while the
# ~10min notary round trip is skipped 24x a day.
#
# Artifacts publish to stablyai/orca-hourly, never to stablyai/orca: the main
# repo's releases atom feed exposes only its 10 newest entries, so 24 hourly
# tags a day would evict every stable/RC entry and break updates for real users.
#
# GITHUB_TOKEN is scoped to this repo and cannot publish there, so writes use a
# GitHub App installed on orca-hourly with Contents: Read and write. Its private
# key does not expire, unlike a PAT — nothing here needs yearly rotation, and the
# credential belongs to the org rather than to whoever created it.
#
# Provision the two secrets with `bash config/scripts/setup-hourly-release-token.sh`:
# HOURLY_RELEASE_APP_ID the App's numeric id
# HOURLY_RELEASE_APP_PRIVATE_KEY the App's .pem private key
#
# Installation tokens live one hour, which is ample here: this job skips tests,
# notarization, and Windows signing entirely, so a run is pack + upload and lands
# well inside that. If one ever did overrun, the release is still an unpublished
# draft at that point, so nothing user-visible breaks.
on:
schedule:
# Top of every hour. Skipped automatically when main has not moved.
- cron: '0 * * * *'
workflow_dispatch:
inputs:
force:
description: Build even if main has not moved since the last hourly
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: hourly-mac-build
cancel-in-progress: false
env:
HOURLY_REPO: stablyai/orca-hourly
# Keep ~3 days of history so a regression can be bisected across a weekend.
HOURLY_RETAIN_COUNT: 72
jobs:
build-hourly-mac:
if: github.repository == 'stablyai/orca'
runs-on: blacksmith-6vcpu-macos-15
# Why 120: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x25 = 80, plus ~30 for checkout/build/verify), or
# the job is killed mid-retry and no cleanup step runs at all. A typical run
# is far shorter — there is no notarization, Windows signing, or test phase.
timeout-minutes: 120
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
# Why: this job only reads stablyai/orca and never pushes; every write
# goes to the hourly repo through a minted App token passed by env.
# Not persisting the checkout credential shrinks the blast radius if a
# build step is compromised (zizmor: artipacked).
persist-credentials: false
- name: Mint hourly repo token
id: app_token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.HOURLY_RELEASE_APP_ID }}
private-key: ${{ secrets.HOURLY_RELEASE_APP_PRIVATE_KEY }}
owner: stablyai
repositories: orca-hourly
# Why: main is often idle overnight. Rebuilding an unchanged commit burns a
# runner hour and adds a redundant tag to the retention window.
- name: Check whether main moved since the last hourly
id: freshness
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
FORCED: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
run: |
set -euo pipefail
head_sha="$(git rev-parse HEAD)"
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
if [[ "$FORCED" == "true" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "Forced dispatch; building $head_sha."
exit 0
fi
# The previous hourly records its source commit in the release body.
# Drafts are excluded: an unpublished leftover never shipped, so treating
# it as "the last build" would skip a build that never actually happened.
last_body="$(gh release list --repo "$HOURLY_REPO" --limit 20 --json tagName,isDraft \
--jq 'map(select(.isDraft | not)) | .[0].tagName // empty' 2>/dev/null || true)"
if [[ -z "$last_body" ]]; then
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "No prior hourly release found; building $head_sha."
exit 0
fi
last_sha="$(gh release view "$last_body" --repo "$HOURLY_REPO" --json body \
--jq '.body | capture("commit `(?<sha>[0-9a-f]{7,40})`") | .sha' 2>/dev/null || true)"
if [[ -n "$last_sha" && "$head_sha" == "$last_sha"* ]]; then
echo "should_build=false" >>"$GITHUB_OUTPUT"
echo "main is unchanged since $last_body ($last_sha); skipping."
else
echo "should_build=true" >>"$GITHUB_OUTPUT"
echo "main moved to $head_sha (last hourly built $last_sha); building."
fi
- name: Setup pnpm
if: steps.freshness.outputs.should_build == 'true'
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
if: steps.freshness.outputs.should_build == 'true'
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
if: steps.freshness.outputs.should_build == 'true'
uses: actions/cache@v5
with:
path: |
~/Library/Caches/electron
~/Library/Caches/electron-builder
key: electron-builder-mac-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-mac-
- name: Install dependencies
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: signing is what makes an hourly installable over an existing Orca, so
# a missing cert must fail here rather than after a 20-minute build.
- name: Verify macOS signing environment
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/verify-macos-release-env.mjs
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Compute hourly version
id: hourly
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/hourly-build-version.mjs >>"$GITHUB_OUTPUT"
- name: Build app
if: steps.freshness.outputs.should_build == 'true'
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
# Why: hourly builds are not an official channel — telemetry's transport
# gate accepts only 'stable' or 'rc', so leaving this unset keeps them
# silent, which is correct for unvetted dev artifacts.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
- name: Create hourly release
id: release
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: v${{ steps.hourly.outputs.version }}
SHA: ${{ steps.freshness.outputs.head_sha }}
run: |
set -euo pipefail
short_sha="${SHA:0:12}"
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
#
# Why --draft: everything between here and the manifest check is a window
# where the release exists but has no installable assets. A draft is
# absent from the releases list and from listReleaseBuilds, so a job that
# dies in that window — including a hard kill by the job timeout, which
# runs no cleanup step at all — leaves something invisible rather than a
# tag the picker offers and the download 404s on. It is flipped live only
# after the manifest is verified.
gh release create "$TAG" \
--repo "$HOURLY_REPO" \
--title "$TAG" \
--draft \
--notes "Automated hourly macOS dev build from commit \`$short_sha\`.
Built from [\`stablyai/orca@$short_sha\`](https://github.com/stablyai/orca/commit/$SHA).
**Unvetted.** No tests ran. Signed but not notarized — installable
through Orca's in-app updater, but a manual download will be
Gatekeeper-quarantined."
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Publish hourly macOS artifacts
if: steps.freshness.outputs.should_build == 'true'
uses: nick-fields/retry@v4
with:
# Why lower than the release pipeline's 3x45: that budget is sized for
# notarization and Windows signing, neither of which runs here. An attempt
# is pack + upload only.
timeout_minutes: 25
max_attempts: 2
retry_wait_seconds: 30
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_HOURLY=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
env:
# Why: electron-builder's github publisher targets the repo named in the
# config; the token must therefore carry write access to orca-hourly.
GH_TOKEN: ${{ steps.app_token.outputs.token }}
ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }}
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Why: the updater resolves a tag, then fetches latest-mac.yml from it. A
# release missing that manifest is a tag the picker offers and the download
# 404s on, so fail loudly instead of leaving a broken entry.
- name: Verify update manifest published
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$HOURLY_REPO" --json assets --jq '.assets[].name')"
echo "Published assets:"
echo "$assets"
# Why exit 1 without deleting here: the release is still a draft, so it is
# already invisible to users, and the failure handler below owns cleanup.
# Deleting inline under `set -e` would also let the delete's exit code
# preempt this explicit failure.
for required in latest-mac.yml; do
if ! grep -qx "$required" <<<"$assets"; then
echo "::error::Hourly draft $TAG is missing $required; the updater could not install it."
exit 1
fi
done
if ! grep -q '\.zip$' <<<"$assets"; then
echo "::error::Hourly draft $TAG has no ZIP artifact for the updater to download."
exit 1
fi
# Why this is the last mutating step: publishing the draft is what makes the
# build visible to listReleaseBuilds. Doing it only after the manifest check
# means the picker can never offer a release whose assets are incomplete.
- name: Publish the verified release
id: publish_live
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease
echo "Published $TAG"
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.
#
# Why it is gated on publish_live not having succeeded: a later failure (the
# prune step) must not delete a release that already went live and that users
# may already be installing. A job killed by the outer timeout runs no steps
# at all — which is exactly why the release stays a draft until verified.
# Why cancelled() too: a run stopped from the Actions UI is not a failure(),
# so without it a manual cancel mid-publish would strand the draft.
- name: Discard the draft release on failure
if: >-
(failure() || cancelled()) && steps.release.outputs.tag != '' &&
steps.publish_live.outcome != 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
run: |
set -uo pipefail
# No --cleanup-tag: an unpublished draft never created a git tag.
echo "Run failed before publish; discarding draft $TAG"
gh release delete "$TAG" --repo "$HOURLY_REPO" --yes ||
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune old hourly releases
if: steps.freshness.outputs.should_build == 'true'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
# Why: --cleanup-tag so pruning does not leave orphan tags behind that
# keep showing up in tag lists with no release or assets attached.
# Drafts are excluded so retention counts shipped builds only; a stale
# draft is handled by the failure path, not by the retention window.
stale="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json tagName,createdAt,isDraft \
--jq "map(select(.isDraft | not)) | sort_by(.createdAt) | reverse | .[${HOURLY_RETAIN_COUNT}:] | .[].tagName")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune; at or under $HOURLY_RETAIN_COUNT retained builds."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
echo "Pruning $tag"
gh release delete "$tag" --repo "$HOURLY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
+56 -1
View File
@@ -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
+61 -79
View File
@@ -72,6 +72,11 @@ jobs:
tag: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }}
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }}
# Why: downstream SignPath Slack pings need the cut source (not only the
# new tag) so approvers know what they're signing and who cut it.
source_ref: ${{ steps.resolve.outputs.ref }}
source_sha: ${{ steps.resolve.outputs.sha }}
source_short_sha: ${{ steps.resolve.outputs.short_sha }}
steps:
# Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs):
# this job runs with contents:write and secret scope, so avoid executing
@@ -122,9 +127,12 @@ jobs:
SERVER_URL: ${{ github.server_url }}
run: |
set -euo pipefail
input_ref="${INPUT_REF:-main}"
sha="$(git rev-parse HEAD)"
short_sha="$(git rev-parse --short=12 HEAD)"
echo "ref=$input_ref" >>"$GITHUB_OUTPUT"
echo "sha=$sha" >>"$GITHUB_OUTPUT"
echo "short_sha=$short_sha" >>"$GITHUB_OUTPUT"
# Why: only push the version-bump commit back to main when the
# caller is releasing the exact tip of main. For any older or
@@ -142,7 +150,6 @@ jobs:
# `ref` input alone is ambiguous (branch vs tag vs SHA); for SHA
# inputs it also hides the human-readable names operators need
# when auditing RC cuts.
input_ref="${INPUT_REF:-main}"
repo_url="${SERVER_URL}/${REPO}"
branches="$(
@@ -1176,82 +1183,7 @@ jobs:
- name: Install SignPath PowerShell module
if: matrix.platform == 'win'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# Why: force TLS 1.2 so gallery downloads work on older hosted images.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
# Why: on some hosted Windows images `Register-PSRepository -Default`
# fails inside the legacy nuget.exe provider with "Missing option value
# for: '-source'", so PSGallery is never registered and the install
# below dies with "No repository with the name 'PSGallery'". PSResourceGet
# (bundled with PowerShell 7.4+) has PSGallery registered by default and
# avoids that code path, so prefer it and fall back to PowerShellGet only
# when it is absent.
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
if ($useResourceGet) {
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSResourceRepository -PSGallery -Trusted
} else {
Set-PSResourceRepository -Name PSGallery -Trusted
}
} else {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSRepository -Default -InstallationPolicy Trusted
}
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
}
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
$currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator |
Where-Object {
if ([string]::IsNullOrWhiteSpace($_)) {
$false
} else {
$candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars)
$candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase)
}
} |
Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) {
throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.'
}
$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'
for ($attempt = 1; $attempt -le 3; $attempt++) {
if ($attempt -eq 2) {
Start-Sleep -Seconds 15
} elseif ($attempt -eq 3) {
Start-Sleep -Seconds 30
}
try {
if ($useResourceGet) {
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
} else {
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
}
Import-Module SignPath -ErrorAction Stop
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
break
} catch {
if ($attempt -eq 3) {
throw
}
Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_"
if (Test-Path -LiteralPath $signPathModulePath) {
Write-Warning "Removing current-user SignPath module directory before retry: $signPathModulePath"
Remove-Item -LiteralPath $signPathModulePath -Recurse -Force
}
}
}
uses: ./.github/actions/install-signpath-module
# ── Windows inner-binary signing (issue #7785) ─────────────────────
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE
@@ -1339,6 +1271,12 @@ jobs:
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }}
TAG: ${{ needs.cut.outputs.tag }}
SOURCE_REF: ${{ needs.cut.outputs.source_ref }}
SOURCE_SHA: ${{ needs.cut.outputs.source_sha }}
SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }}
# Prefer triggering_actor so re-runs name who re-ran; fall back to actor.
CUT_BY: ${{ github.triggering_actor || github.actor }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
@@ -1350,7 +1288,26 @@ jobs:
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
}
$message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
# Why: approvers need tag + source ref/commit + who cut, not only the tag.
$sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' }
$shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) {
$env:SOURCE_SHORT_SHA
} elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
$env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length))
} else {
'unknown'
}
$commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
"<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>"
} else {
"``$shortSha``"
}
$cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) {
"<https://github.com/$($env:CUT_BY)|@$($env:CUT_BY)>"
} else {
'unknown'
}
$message = "Orca Windows release ``$($env:TAG)`` inner-binaries signing request (1 of 2) is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>"
$payload = @{
text = $message
blocks = @(
@@ -1523,6 +1480,12 @@ jobs:
SIGNPATH_REQUEST_ID: ${{ steps.submit-signing-request.outputs.signing-request-id }}
SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }}
TAG: ${{ needs.cut.outputs.tag }}
SOURCE_REF: ${{ needs.cut.outputs.source_ref }}
SOURCE_SHA: ${{ needs.cut.outputs.source_sha }}
SOURCE_SHORT_SHA: ${{ needs.cut.outputs.source_short_sha }}
# Prefer triggering_actor so re-runs name who re-ran; fall back to actor.
CUT_BY: ${{ github.triggering_actor || github.actor }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }}
run: |
@@ -1537,7 +1500,26 @@ jobs:
# Why: releases where inner signing fell through have only this one request.
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
$message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
# Why: approvers need tag + source ref/commit + who cut, not only the tag.
$sourceRef = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_REF)) { $env:SOURCE_REF } else { 'unknown' }
$shortSha = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHORT_SHA)) {
$env:SOURCE_SHORT_SHA
} elseif (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
$env:SOURCE_SHA.Substring(0, [Math]::Min(12, $env:SOURCE_SHA.Length))
} else {
'unknown'
}
$commitLink = if (-not [string]::IsNullOrWhiteSpace($env:SOURCE_SHA)) {
"<$($env:REPO_URL)/commit/$($env:SOURCE_SHA)|``$shortSha``>"
} else {
"``$shortSha``"
}
$cutBy = if (-not [string]::IsNullOrWhiteSpace($env:CUT_BY)) {
"<https://github.com/$($env:CUT_BY)|@$($env:CUT_BY)>"
} else {
'unknown'
}
$message = "Orca Windows release ``$($env:TAG)`` $stage is ready for SignPath approval.`nSource: ``$sourceRef`` @ $commitLink · cut by $cutBy`n<$requestUrl|Open SignPath signing request>`n<$($env:GITHUB_RUN_URL)|Open GitHub Actions run>"
$payload = @{
text = $message
blocks = @(
@@ -141,23 +141,7 @@ jobs:
if-no-files-found: error
- name: Install SignPath PowerShell module
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
if ($useResourceGet) {
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSResourceRepository -PSGallery -Trusted
} else {
Set-PSResourceRepository -Name PSGallery -Trusted
}
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
} else {
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
}
Import-Module SignPath -ErrorAction Stop
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
uses: ./.github/actions/install-signpath-module
- name: Submit inner binaries signing request
id: submit-inner-signing
-1
View File
@@ -21,7 +21,6 @@ type OutputChunk = Rollup.OutputChunk
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'main-thread-hang-watchdog-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
+33 -12
View File
@@ -14,10 +14,16 @@ const {
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
// Why: hourly dev builds must carry the *release* identity — same bundle id and
// Developer ID signature — or Squirrel.Mac refuses to swap them over an installed
// Orca. Only notarization is skipped, which in-place updates never check.
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const hourlyBuildVersion = isMacHourly ? process.env.ORCA_HOURLY_BUILD_VERSION : undefined
const appId = 'com.stablyai.orca'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
@@ -64,7 +70,11 @@ const winSpeechNativeResource = {
module.exports = {
appId,
productName: 'Orca',
...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}),
...(hourlyBuildVersion
? { extraMetadata: { version: hourlyBuildVersion } }
: localBuildVersion
? { extraMetadata: { version: localBuildVersion } }
: {}),
directories: {
buildResources: 'resources/build'
},
@@ -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'
}
}
+88 -21
View File
@@ -3079,6 +3079,7 @@
"paired remote terminal ordinary parking",
"paired remote terminal bounded scrollback restore",
"stalled paired terminal stream recovery",
"snapshot-probe sequence gap recovery",
"hidden remote worktree retention",
"remote terminal reveal and input",
"manual server disconnect"
@@ -3087,13 +3088,13 @@
"providers": ["paired-runtime", "ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["paired-runtime"],
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.",
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8652",
"https://github.com/stablyai/orca/pull/10625"
],
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
@@ -3101,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')
+110 -10
View File
@@ -19,6 +19,39 @@ const {
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
const MUTABLE_BUILD_ENV = [
'ORCA_MAC_HOURLY',
'ORCA_MAC_RELEASE',
'ORCA_HOURLY_BUILD_VERSION',
'ORCA_LOCAL_BUILD_VERSION'
]
/** Re-requires the config under a temporary env, then restores env and module cache. */
function withEnv(env, assert) {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = Object.fromEntries(MUTABLE_BUILD_ENV.map((key) => [key, process.env[key]]))
try {
for (const key of MUTABLE_BUILD_ENV) {
delete process.env[key]
}
Object.assign(process.env, env)
delete require.cache[configPath]
assert(require('../electron-builder.config.cjs'))
} finally {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
}
const withHourlyEnv = (assert) => withEnv({ ORCA_MAC_HOURLY: '1' }, assert)
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
expect(electronBuilderConfig.appId).toBe(
@@ -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()
})
})
+43
View File
@@ -0,0 +1,43 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
export function createHourlyBuildVersion(baseVersion, date) {
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion)
if (!match) {
throw new Error(`Package version is not valid semver: ${baseVersion}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Hourly build timestamp is invalid.')
}
const pad = (value, width = 2) => String(value).padStart(width, '0')
const stamp = [
pad(date.getUTCFullYear(), 4),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
pad(date.getUTCHours()),
pad(date.getUTCMinutes())
].join('')
// Why: drop any -rc.N tail. Keeping it makes every hourly semver-NEWER than the
// RC it was cut from (1.4.160-rc.3-hourly.X > 1.4.160-rc.3), which would let an
// ordinary RC-channel check offer untested hourly builds to RC users. Stripping
// to the base parks hourlies below both rc.N and stable ('hourly' < 'rc'
// alphabetically), reachable only by an explicit pinned jump.
return `${match[1]}-hourly.${stamp}`
}
export function getHourlyBuildIdentity(now = new Date()) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
return { commit, version: createHourlyBuildVersion(packageJson.version, now) }
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getHourlyBuildIdentity()
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(`version=${identity.version}\ncommit=${identity.commit}\n`)
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { createHourlyBuildVersion } from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createHourlyBuildVersion', () => {
it('stamps the version with a zero-padded UTC timestamp', () => {
expect(createHourlyBuildVersion('1.4.160', new Date('2026-07-28T04:05:00Z'))).toBe(
'1.4.160-hourly.202607280405'
)
})
// Why: main's package.json carries the in-flight RC tail. Keeping it would make
// every hourly semver-NEWER than the RC it was cut from (1.4.160-rc.3-hourly.X >
// 1.4.160-rc.3), so an ordinary RC-channel check would offer untested hourly
// builds to RC users. Dropping it parks hourlies below both rc.N and stable,
// reachable only by an explicit pinned jump.
it('drops an in-flight rc tail so hourlies never outrank the rc series', () => {
const version = createHourlyBuildVersion('1.4.160-rc.3', new Date('2026-07-28T14:00:00Z'))
expect(version).toBe('1.4.160-hourly.202607281400')
expect(compareAppVersions(version, '1.4.160-rc.3')).toBeLessThan(0)
expect(compareAppVersions('1.4.160-rc.3-hourly.202607281400', '1.4.160-rc.3')).toBeGreaterThan(
0
)
})
it('rejects invalid input', () => {
expect(() => createHourlyBuildVersion('nope', new Date())).toThrow(/valid semver/)
expect(() => createHourlyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
@@ -0,0 +1,614 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { execFileSync, fork } from 'node:child_process'
import { existsSync, mkdtempSync, openSync, readFileSync, writeFileSync } from 'node:fs'
import net from 'node:net'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
median,
percentile,
processSnapshot,
sampleProcess
} from './macos-computer-helper-owner-loss-metrics.mjs'
import {
benchmarkTrialNeedsCleanup,
parseBenchmarkTrialResult,
processIdentityIsCurrent,
signalProcessIdentity,
spawnBenchmarkProcess,
throwBenchmarkTrialFailures,
writeProcessRecord
} from './macos-computer-helper-owner-loss-processes.mjs'
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
const INTERNAL_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_INTERNAL'
const EXPECTATION_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_EXPECTATION'
const HELPER_RECORD_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_HELPER_RECORD_PATH'
const RESULT_PATH_ENV = 'ORCA_COMPUTER_HELPER_OWNER_BENCH_RESULT_PATH'
const ACTIVE_REQUEST_COUNT = 100_000
const DEFAULT_TRIALS = 3
const OWNER_HOLD_MS = 31_000
const PROCESS_EXIT_TIMEOUT_MS = 5_000
const RETAIN_PROOF_MS = 3_000
const TRIAL_TIMEOUT_MS = OWNER_HOLD_MS + 4 * PROCESS_EXIT_TIMEOUT_MS + 120_000
const MIB = 1024 * 1024
const scriptPath = import.meta.filename
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const metricsPath = path.join(import.meta.dirname, 'macos-computer-helper-owner-loss-metrics.mjs')
const processCleanupPath = path.join(
import.meta.dirname,
'macos-computer-helper-owner-loss-processes.mjs'
)
const trialCleanupPath = path.join(
import.meta.dirname,
'macos-computer-helper-owner-loss-trial-cleanup.mjs'
)
const sidecarPath = path.join(repoRoot, 'out', 'main', 'computer-sidecar.js')
const helperAppPath = path.join(
repoRoot,
'native',
'computer-use-macos',
'.build',
'release',
'Orca Computer Use.app'
)
const helperPath = path.join(helperAppPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function waitForProcessExit(identity, timeoutMs) {
const startedAt = performance.now()
while (performance.now() - startedAt < timeoutMs) {
if (!processIdentityIsCurrent(identity)) {
return performance.now() - startedAt
}
await sleep(50)
}
return null
}
async function stopProcess(identity) {
if (!identity || !processIdentityIsCurrent(identity)) {
return
}
signalProcessIdentity(identity, helperPath, 'SIGTERM')
if ((await waitForProcessExit(identity, 2_000)) !== null) {
return
}
signalProcessIdentity(identity, helperPath, 'SIGKILL')
await waitForProcessExit(identity, 2_000)
}
function startSidecar() {
const errors = []
const child = fork(sidecarPath, [], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
ORCA_COMPUTER_SIDECAR: '1',
ORCA_COMPUTER_MACOS_HELPER_APP_PATH: helperAppPath
}
})
child.on('error', (error) => errors.push(error.stack ?? error.message))
child.stderr?.on('data', (chunk) => errors.push(String(chunk)))
return { child, errors }
}
function requestSidecar(sidecar, id, method) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup()
reject(new Error(`Sidecar ${method} request timed out: ${sidecar.errors.join('')}`))
}, 10_000)
const onMessage = (message) => {
if (message?.id !== id) {
return
}
cleanup()
if (message.ok) {
resolve(message.result)
} else {
reject(new Error(`Sidecar ${method} failed: ${JSON.stringify(message.error)}`))
}
}
const onExit = (code, signal) => {
cleanup()
reject(
new Error(
`Sidecar exited during ${method}: ${JSON.stringify({ code, signal, stderr: sidecar.errors.join('') })}`
)
)
}
const onError = (error) => {
cleanup()
reject(new Error(`Sidecar ${method} process error: ${error.message}`))
}
const cleanup = () => {
clearTimeout(timeout)
sidecar.child.off('message', onMessage)
sidecar.child.off('exit', onExit)
sidecar.child.off('error', onError)
}
sidecar.child.on('message', onMessage)
sidecar.child.once('exit', onExit)
sidecar.child.once('error', onError)
try {
sidecar.child.send({ id, method, params: {} })
} catch (error) {
onError(error)
}
})
}
async function waitForHelper(sidecarPid) {
const deadline = Date.now() + 10_000
while (Date.now() < deadline) {
const output = execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,command='], {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
for (const line of output.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)
if (
match &&
Number(match[2]) === sidecarPid &&
Number(match[3]) === Number(match[1]) &&
match[4].includes(helperPath) &&
match[4].includes(' --agent ')
) {
return { pid: Number(match[1]), pgid: Number(match[3]), command: match[4] }
}
}
await sleep(50)
}
throw new Error(`Could not find helper owned by sidecar ${sidecarPid}`)
}
function socketPathFromCommand(command) {
const match = command.match(/ --agent (.+?) --token-file /)
if (!match) {
throw new Error(`Could not read helper socket path from command: ${command}`)
}
return match[1]
}
function connectInvalidPeer(socketPath) {
return new Promise((resolve, reject) => {
const socket = net.createConnection(socketPath)
let accepted = false
const timeout = setTimeout(() => {
socket.destroy()
reject(new Error('Invalid-peer connection timed out'))
}, 5_000)
let buffer = ''
const cleanup = () => {
clearTimeout(timeout)
socket.off('error', onError)
socket.off('data', onData)
}
const onError = (error) => {
if (accepted) {
return
}
cleanup()
reject(error)
}
const onData = (chunk) => {
buffer += chunk
const newline = buffer.indexOf('\n')
if (newline < 0) {
return
}
let response
try {
response = JSON.parse(buffer.slice(0, newline))
} catch (error) {
cleanup()
socket.destroy()
reject(error)
return
}
if (response.ok !== false || response.error?.code !== 'permission_denied') {
cleanup()
socket.destroy()
reject(new Error(`Invalid peer was not rejected: ${JSON.stringify(response)}`))
return
}
clearTimeout(timeout)
socket.off('data', onData)
accepted = true
resolve(socket)
}
socket.setEncoding('utf8')
socket.on('error', onError)
socket.on('data', onData)
socket.once('connect', () => {
socket.write(
`${JSON.stringify({ id: 991, method: 'handshake', params: {}, token: 'invalid' })}\n`
)
})
})
}
async function waitForChildExit(child, timeoutMs) {
if (child.exitCode !== null || child.signalCode !== null) {
return true
}
return await Promise.race([
new Promise((resolve) => child.once('exit', () => resolve(true))),
sleep(timeoutMs).then(() => false)
])
}
async function startAuthenticatedSession() {
const sidecar = startSidecar()
let helper
try {
const capabilitiesResult = requestSidecar(sidecar, 1, 'capabilities').then(
(capabilities) => ({ capabilities }),
(error) => ({ error })
)
helper = await waitForHelper(sidecar.child.pid)
const helperRecordPath = process.env[HELPER_RECORD_PATH_ENV]
if (!helperRecordPath) {
throw new Error('Missing helper process record path')
}
writeProcessRecord(helperRecordPath, helper)
const { capabilities, error } = await capabilitiesResult
if (error) {
throw error
}
if (capabilities?.protocolVersion !== 1) {
throw new Error(`Unexpected helper handshake: ${JSON.stringify(capabilities)}`)
}
return { authenticated: capabilities.protocolVersion === 1, sidecar, helper }
} catch (error) {
sidecar.child.kill('SIGKILL')
await stopProcess(helper)
throw error
}
}
async function exerciseActiveRequests(sidecar) {
const latencies = []
const startedAt = performance.now()
for (let index = 0; index < ACTIVE_REQUEST_COUNT; index += 1) {
const requestStartedAt = performance.now()
const result = await requestSidecar(sidecar, 10_000 + index, 'listApps')
if (!Array.isArray(result?.apps)) {
throw new Error(`Unexpected listApps response: ${JSON.stringify(result)}`)
}
latencies.push(performance.now() - requestStartedAt)
}
const totalMs = performance.now() - startedAt
return {
totalMs,
requestsPerSecond: (ACTIVE_REQUEST_COUNT * 1_000) / totalMs,
medianLatencyMs: median(latencies),
p95LatencyMs: percentile(latencies, 0.95),
maxLatencyMs: Math.max(...latencies)
}
}
async function verifyGracefulClose() {
const { sidecar, helper } = await startAuthenticatedSession()
try {
const startedAt = performance.now()
sidecar.child.disconnect()
if (!(await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS))) {
throw new Error('Sidecar did not exit after graceful IPC close')
}
const helperExitMs = await waitForProcessExit(helper, PROCESS_EXIT_TIMEOUT_MS)
if (helperExitMs === null) {
throw new Error('Helper did not exit after graceful owner close')
}
return Math.round(performance.now() - startedAt)
} finally {
sidecar.child.kill('SIGKILL')
await stopProcess(helper)
}
}
async function runInternalTrial(expectation) {
let sidecar
let helper
let invalidPeer
let invalidPeerRejected = false
try {
const session = await startAuthenticatedSession()
sidecar = session.sidecar
helper = session.helper
const authenticatedAt = performance.now()
const initial = await sampleProcess(helper.pid)
const activeRequests = await exerciseActiveRequests(sidecar)
const remainingHoldMs = Math.max(0, OWNER_HOLD_MS - (performance.now() - authenticatedAt))
await sleep(remainingHoldMs)
const connected = await sampleProcess(helper.pid)
const invalidSocketPath = socketPathFromCommand(helper.command)
invalidPeer = await connectInvalidPeer(invalidSocketPath)
invalidPeerRejected = true
const survivedClaimDeadline =
performance.now() - authenticatedAt >= OWNER_HOLD_MS && isProcessAlive(helper.pid)
sidecar.child.kill('SIGKILL')
await waitForChildExit(sidecar.child, PROCESS_EXIT_TIMEOUT_MS)
const abruptExitMs = await waitForProcessExit(
helper,
expectation === 'reaped' ? PROCESS_EXIT_TIMEOUT_MS : RETAIN_PROOF_MS
)
const helperExitedAfterAbruptLoss = abruptExitMs !== null
if (expectation === 'reaped' && !helperExitedAfterAbruptLoss) {
throw new Error('Expected helper to exit after abrupt authenticated owner loss')
}
if (expectation === 'retained' && helperExitedAfterAbruptLoss) {
throw new Error('Expected baseline helper to remain after abrupt owner loss')
}
const postLossRssBytes = helperExitedAfterAbruptLoss ? 0 : processSnapshot(helper.pid).rssBytes
await stopProcess(helper)
helper = null
invalidPeer.destroy()
invalidPeer = null
const gracefulExitMs = await verifyGracefulClose()
return {
authenticated: session.authenticated,
survivedClaimDeadline,
invalidPeerRejectedAndDidNotRetain: invalidPeerRejected && helperExitedAfterAbruptLoss,
connectedRssBytes: connected.rssBytes,
connectedCpuMilliseconds: Math.max(
0,
Math.round((connected.cpuTimeSeconds - initial.cpuTimeSeconds) * 1_000)
),
activeRequests,
cpuSampleMs: Math.round(performance.now() - authenticatedAt),
helperExitedAfterAbruptLoss,
abruptExitMs: abruptExitMs === null ? null : Math.round(abruptExitMs),
postLossRssBytes,
gracefulExitMs
}
} finally {
invalidPeer?.destroy()
sidecar?.child.kill('SIGKILL')
await stopProcess(helper)
}
}
function parseArgs(argv) {
const options = { expect: '', trials: DEFAULT_TRIALS, output: '' }
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = argv[index + 1]
if (arg === '--expect' || arg === '--trials' || arg === '--output') {
if (!value) {
throw new Error(`Missing value for ${arg}`)
}
options[arg.slice(2)] = arg === '--trials' ? Number(value) : value
index += 1
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
if (!['retained', 'reaped'].includes(options.expect)) {
throw new Error('--expect must be retained or reaped')
}
if (!Number.isInteger(options.trials) || options.trials < 1) {
throw new Error('--trials must be a positive integer')
}
return options
}
function electronPath() {
const electronModulePath = fileURLToPath(import.meta.resolve('electron'))
return execFileSync(
process.execPath,
['-e', `process.stdout.write(require(${JSON.stringify(electronModulePath)}))`],
{ encoding: 'utf8' }
)
}
function buildArtifacts() {
execFileSync('pnpm', ['exec', 'electron-vite', 'build'], {
cwd: repoRoot,
stdio: 'inherit'
})
execFileSync('pnpm', ['build:computer-macos'], {
cwd: repoRoot,
stdio: 'inherit'
})
}
function artifactSha256(artifactPath) {
return createHash('sha256').update(readFileSync(artifactPath)).digest('hex')
}
function runTrial(executable, expectation) {
let launcherDir
let trialTempDir
let helperRecordPath
let resultPath
let stderrPath
let stdoutPath
let result
let serializedResult
let parsedResult
let parsedResultAvailable = false
let trialError
let cleanupError
let stderrDescriptor
let stdoutDescriptor
let trialOutput = ''
try {
launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-helper-owner-bench-launcher-'))
trialTempDir = mkdtempSync(path.join(path.sep, 'tmp', 'orca-owner-bench-'))
helperRecordPath = path.join(launcherDir, 'helper.json')
resultPath = path.join(launcherDir, 'result.json')
stderrPath = path.join(launcherDir, 'stderr.log')
stdoutPath = path.join(launcherDir, 'stdout.log')
writeFileSync(
path.join(launcherDir, 'package.json'),
JSON.stringify({ name: 'orca-helper-owner-benchmark', main: 'main.cjs' })
)
writeFileSync(
path.join(launcherDir, 'main.cjs'),
`import(${JSON.stringify(pathToFileURL(scriptPath).href)}).catch((error) => {
console.error(error)
process.exitCode = 1
})\n`
)
const env = {
...process.env,
TMPDIR: trialTempDir,
[INTERNAL_ENV]: '1',
[EXPECTATION_ENV]: expectation,
[HELPER_RECORD_PATH_ENV]: helperRecordPath,
[RESULT_PATH_ENV]: resultPath
}
delete env.ELECTRON_RUN_AS_NODE
stderrDescriptor = openSync(stderrPath, 'w')
stdoutDescriptor = openSync(stdoutPath, 'w')
result = spawnBenchmarkProcess(executable, [launcherDir], {
cwd: repoRoot,
env,
stdio: ['ignore', stdoutDescriptor, stderrDescriptor],
timeout: TRIAL_TIMEOUT_MS
})
if (result.status === 0 && existsSync(resultPath)) {
serializedResult = readFileSync(resultPath, 'utf8')
parsedResult = parseBenchmarkTrialResult(serializedResult)
parsedResultAvailable = true
}
} catch (error) {
trialError = error
} finally {
const failedTrial = benchmarkTrialNeedsCleanup(result, parsedResultAvailable)
const trialMarker = trialTempDir ? `TMPDIR=${trialTempDir}` : undefined
const cleanup = cleanupOwnerLossTrial({
failed: failedTrial,
pid: result?.pid,
marker: trialMarker,
recordPath: helperRecordPath,
helperPath,
tempDir: trialTempDir,
stderrDescriptor,
stdoutDescriptor,
outputPaths: [stderrPath, stdoutPath],
launcherDir
})
cleanupError = cleanup.error
trialOutput = cleanup.output
}
if (!trialError && result?.status !== 0) {
trialError = new Error(
`Electron trial failed (${result.error?.message ?? result.signal ?? result.status}):\n${trialOutput}`
)
}
if (!trialError && !serializedResult) {
trialError = new Error(`Electron trial did not write a result:\n${trialOutput}`)
}
throwBenchmarkTrialFailures(trialError, cleanupError)
return parsedResult
}
function runBenchmark() {
if (process.platform !== 'darwin') {
throw new Error('The computer-use helper owner benchmark is macOS-only')
}
const options = parseArgs(process.argv.slice(2))
const dirty = execFileSync('git', ['status', '--porcelain'], {
cwd: repoRoot,
encoding: 'utf8'
}).trim()
if (dirty) {
throw new Error('Commit or stash changes before running the provenance-bound benchmark')
}
buildArtifacts()
if (!existsSync(sidecarPath) || !existsSync(helperPath)) {
throw new Error('Fresh production sidecar/helper build did not produce the expected artifacts')
}
const executable = electronPath()
const results = Array.from({ length: options.trials }, () => runTrial(executable, options.expect))
const rssBytes = results.map((result) => result.connectedRssBytes)
const cpuMilliseconds = results.map((result) => result.connectedCpuMilliseconds)
const activeRequestTotals = results.map((result) => result.activeRequests.totalMs)
const activeRequestRates = results.map((result) => result.activeRequests.requestsPerSecond)
const activeRequestMedians = results.map((result) => result.activeRequests.medianLatencyMs)
const activeRequestP95s = results.map((result) => result.activeRequests.p95LatencyMs)
const activeRequestMaxes = results.map((result) => result.activeRequests.maxLatencyMs)
const postLossRssBytes = results.map((result) => result.postLossRssBytes)
const report = {
benchmark: 'macos-computer-helper-authenticated-owner-loss',
revision: execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: repoRoot,
encoding: 'utf8'
}).trim(),
artifacts: {
sidecarSha256: artifactSha256(sidecarPath),
helperSha256: artifactSha256(helperPath)
},
sources: {
benchmarkSha256: artifactSha256(scriptPath),
metricsSha256: artifactSha256(metricsPath),
processCleanupSha256: artifactSha256(processCleanupPath),
trialCleanupSha256: artifactSha256(trialCleanupPath)
},
expectation: options.expect,
trials: options.trials,
ownerHoldMs: OWNER_HOLD_MS,
activeRequestCount: ACTIVE_REQUEST_COUNT,
authenticated: results.every((result) => result.authenticated),
survivedClaimDeadline: results.every((result) => result.survivedClaimDeadline),
invalidPeerRejectedAndDidNotRetain: results.every(
(result) => result.invalidPeerRejectedAndDidNotRetain
),
connectedRssMiB: rssBytes.map((value) => Number((value / MIB).toFixed(2))),
medianConnectedRssMiB: Number((median(rssBytes) / MIB).toFixed(2)),
connectedCpuMilliseconds: cpuMilliseconds,
medianConnectedCpuMilliseconds: median(cpuMilliseconds),
activeRequestTotalMs: activeRequestTotals.map((value) => Number(value.toFixed(3))),
medianActiveRequestTotalMs: Number(median(activeRequestTotals).toFixed(3)),
activeRequestsPerSecond: activeRequestRates.map((value) => Number(value.toFixed(2))),
medianActiveRequestsPerSecond: Number(median(activeRequestRates).toFixed(2)),
activeRequestMedianLatencyMs: activeRequestMedians.map((value) => Number(value.toFixed(3))),
medianActiveRequestMedianLatencyMs: Number(median(activeRequestMedians).toFixed(3)),
activeRequestP95LatencyMs: activeRequestP95s.map((value) => Number(value.toFixed(3))),
medianActiveRequestP95LatencyMs: Number(median(activeRequestP95s).toFixed(3)),
activeRequestMaxLatencyMs: activeRequestMaxes.map((value) => Number(value.toFixed(3))),
helperExitedAfterAbruptLoss: results.map((result) => result.helperExitedAfterAbruptLoss),
abruptExitMs: results.map((result) => result.abruptExitMs),
postLossRssMiB: postLossRssBytes.map((value) => Number((value / MIB).toFixed(2))),
medianPostLossRssMiB: Number((median(postLossRssBytes) / MIB).toFixed(2)),
gracefulExitMs: results.map((result) => result.gracefulExitMs)
}
const serialized = `${JSON.stringify(report, null, 2)}\n`
process.stdout.write(serialized)
if (options.output) {
writeFileSync(path.resolve(options.output), serialized)
}
}
if (process.env[INTERNAL_ENV] === '1') {
const { app } = await import('electron')
await app.whenReady()
try {
const result = await runInternalTrial(process.env[EXPECTATION_ENV])
writeFileSync(process.env[RESULT_PATH_ENV], JSON.stringify(result))
} finally {
app.quit()
}
} else {
runBenchmark()
}
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { signalValidatedProcessGroup } from './macos-computer-helper-owner-loss-processes.mjs'
describe('macOS helper owner-loss benchmark group recovery', () => {
it('retains uncertain stop state across cleanup stage failures', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
const groupState = { stopped: false }
let scanCount = 0
let continueAttempts = 0
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount >= 3) {
throw new Error(
scanCount === 3 ? 'post-stop inspection failed' : 'final inspection failed'
)
}
return members
},
signalProcess: (pid, signal) => {
if (pid === -41 && signal === 'SIGCONT') {
continueAttempts += 1
if (continueAttempts === 1) {
throw new Error('first compensation failed')
}
}
}
}
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
).toThrow('Benchmark process group signal recovery failed')
expect(groupState.stopped).toBe(true)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('final inspection failed')
expect(continueAttempts).toBe(2)
expect(groupState.stopped).toBe(false)
})
it('retains an uncertain anchor stop across cleanup stage failures', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
const groupState = { stopped: false, anchorPid: null }
let scanCount = 0
let continueAttempts = 0
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount >= 2) {
throw new Error(
scanCount === 2 ? 'post-anchor inspection failed' : 'final inspection failed'
)
}
return members
},
signalProcess: (pid, signal) => {
if (pid === 41 && signal === 'SIGCONT') {
continueAttempts += 1
if (continueAttempts === 1) {
throw new Error('first anchor compensation failed')
}
}
}
}
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
).toThrow('Benchmark process group signal recovery failed')
expect(groupState.anchorPid).toBe(41)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('final inspection failed')
expect(continueAttempts).toBe(2)
expect(groupState.anchorPid).toBeNull()
})
it('recovers a retained anchor before selecting a new one', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const firstAnchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
const finalAnchor = { pid: 42, pgid: 41, command: `/child ${marker}` }
const groupState = { stopped: false, anchorPid: null }
let scanCount = 0
let firstAnchorContinues = 0
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount === 2) {
throw new Error('post-anchor inspection failed')
}
return scanCount === 1 ? [firstAnchor] : [finalAnchor]
},
signalProcess: (pid, signal) => {
if (pid === 41 && signal === 'SIGCONT') {
firstAnchorContinues += 1
if (firstAnchorContinues === 1) {
throw new Error('first anchor compensation failed')
}
}
}
}
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)
).toThrow('Benchmark process group signal recovery failed')
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(true)
expect(firstAnchorContinues).toBe(2)
expect(groupState).toEqual({ stopped: false, anchorPid: null })
})
it('preserves group authority errors when compensation fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const ownershipError = 'Benchmark process group no longer belongs to this trial'
let thrown
try {
signalValidatedProcessGroup(
41,
marker,
'SIGKILL',
{ stopped: true },
{
processIdentities: () => [{ pid: 41, pgid: 41, command: '/unrelated' }],
signalProcess: () => {
throw new Error('resume denied')
}
}
)
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors.map((error) => error.message)).toEqual([ownershipError, 'resume denied'])
})
})
@@ -0,0 +1,62 @@
import { execFileSync } from 'node:child_process'
const SAMPLE_COUNT = 5
const SAMPLE_INTERVAL_MS = 200
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function median(values) {
const sorted = [...values].sort((left, right) => left - right)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
export function percentile(values, fraction) {
const sorted = [...values].sort((left, right) => left - right)
return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]
}
function parseCpuTimeSeconds(value) {
const [dayOrTime, clock] = value.includes('-') ? value.split('-', 2) : [null, value]
const days = dayOrTime === null ? 0 : Number(dayOrTime)
const parts = clock.split(':').map(Number)
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
throw new Error(`Invalid process CPU time: ${value}`)
}
const seconds = parts.pop() ?? 0
const minutes = parts.pop() ?? 0
const hours = parts.pop() ?? 0
return days * 86_400 + hours * 3_600 + minutes * 60 + seconds
}
export function processSnapshot(pid) {
const raw = execFileSync(
'ps',
['-o', 'rss=', '-o', 'time=', '-o', 'command=', '-p', String(pid)],
{ encoding: 'utf8' }
).trim()
const match = raw.match(/^(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
throw new Error(`Could not inspect process ${pid}: ${raw}`)
}
return {
rssBytes: Number(match[1]) * 1024,
cpuTimeSeconds: parseCpuTimeSeconds(match[2]),
command: match[3]
}
}
export async function sampleProcess(pid) {
const samples = []
for (let index = 0; index < SAMPLE_COUNT; index += 1) {
samples.push(processSnapshot(pid))
await sleep(SAMPLE_INTERVAL_MS)
}
return {
rssBytes: median(samples.map((sample) => sample.rssBytes)),
cpuTimeSeconds: samples.at(-1).cpuTimeSeconds,
command: samples.at(-1).command
}
}
@@ -0,0 +1,418 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
const PROCESS_EXIT_TIMEOUT_MS = 2_000
const PROCESS_POLL_MS = 25
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
const processIdentityOperations = {
executePs: execFileSync,
signalProcess: process.kill.bind(process)
}
export function processIdentity(pid, operations = processIdentityOperations) {
if (!Number.isInteger(pid) || pid <= 0) {
return null
}
try {
const output = operations
.executePs('ps', ['-p', String(pid), '-o', 'pid=,pgid=,command='], {
encoding: 'utf8'
})
.trim()
const match = output.match(/^(\d+)\s+(\d+)\s+(.+)$/)
if (!match) {
throw new Error(`Could not parse process identity for ${pid}`)
}
return { pid: Number(match[1]), pgid: Number(match[2]), command: match[3] }
} catch (error) {
try {
operations.signalProcess(pid, 0)
} catch (lookupError) {
if (lookupError?.code === 'ESRCH') {
return null
}
}
throw error
}
}
function matchingDetachedProcesses(identities, expectedCommandFragments) {
return identities.filter(
(identity) =>
identity.pgid === identity.pid &&
expectedCommandFragments.every((fragment) => identity.command.includes(fragment))
)
}
const matchingProcessOperations = {
processIdentities,
signalProcessIdentity,
waitForIdentityExit
}
export function killProcessMatchingCommand(
expectedCommandFragments,
operations = matchingProcessOperations
) {
const matches = matchingDetachedProcesses(
operations.processIdentities(),
expectedCommandFragments
)
if (matches.length === 0) {
return false
}
const errors = []
for (const match of matches) {
try {
if (operations.signalProcessIdentity(match, expectedCommandFragments[0], 'SIGKILL')) {
operations.waitForIdentityExit(match)
}
} catch (error) {
errors.push(error)
}
}
try {
const remaining = matchingDetachedProcesses(
operations.processIdentities(),
expectedCommandFragments
)
if (remaining.length > 0) {
errors.push(
new Error(
`Benchmark helper cleanup left matching processes: ${remaining
.map((identity) => identity.pid)
.join(', ')}`
)
)
}
} catch (error) {
errors.push(error)
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(errors, 'Benchmark exact-command cleanup failed')
}
return true
}
function sleepSync(milliseconds) {
Atomics.wait(sleepBuffer, 0, 0, milliseconds)
}
function validateDetachedIdentity(identity, expectedCommandFragment) {
if (
!Number.isInteger(identity?.pid) ||
identity.pid <= 0 ||
identity.pgid !== identity.pid ||
typeof identity.command !== 'string' ||
!identity.command.includes(expectedCommandFragment)
) {
throw new Error('Recorded benchmark helper identity is invalid')
}
}
function sameIdentity(left, right) {
return left?.pid === right?.pid && left?.pgid === right?.pgid && left?.command === right?.command
}
function processIdentities(includeEnvironment = false) {
const args = includeEnvironment
? ['eww', '-axo', 'pid=,pgid=,command=']
: ['-axo', 'pid=,pgid=,command=']
const output = execFileSync('ps', args, {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
return output
.split('\n')
.map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/))
.filter(Boolean)
.map((match) => ({
pid: Number(match[1]),
pgid: Number(match[2]),
command: match[3]
}))
}
function waitForIdentityExit(identity) {
const deadline = Date.now() + PROCESS_EXIT_TIMEOUT_MS
while (Date.now() < deadline) {
if (!processIdentityIsCurrent(identity)) {
return true
}
sleepSync(PROCESS_POLL_MS)
}
throw new Error(`Recorded benchmark helper ${identity.pid} did not exit`)
}
export function spawnBenchmarkProcess(executable, args, options) {
return spawnSync(executable, args, {
...options,
detached: true,
killSignal: 'SIGKILL'
})
}
export function runBenchmarkCleanupStages(stages) {
const errors = []
for (const stage of stages) {
try {
stage()
} catch (error) {
errors.push(error)
}
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(errors, 'Benchmark trial cleanup failed')
}
}
export function throwBenchmarkTrialFailures(trialError, cleanupError) {
if (trialError && cleanupError) {
throw new AggregateError([trialError, cleanupError], 'Electron trial and cleanup failed')
}
if (trialError) {
throw trialError
}
if (cleanupError) {
throw cleanupError
}
}
export function parseBenchmarkTrialResult(serializedResult) {
return JSON.parse(serializedResult)
}
export function benchmarkTrialNeedsCleanup(spawnResult, parsedResultAvailable) {
return spawnResult?.status !== 0 || !parsedResultAvailable
}
const processGroupSignalOperations = {
processIdentities,
signalProcess: process.kill.bind(process)
}
function compensateStoppedGroup(pgid, groupState, operations) {
const errors = []
const targets = [
groupState.stopped ? [-pgid, 'stopped'] : null,
groupState.anchorPid ? [groupState.anchorPid, 'anchorPid'] : null
].filter(Boolean)
for (const [pid, stateKey] of targets) {
try {
operations.signalProcess(pid, 'SIGCONT')
groupState[stateKey] = stateKey === 'stopped' ? false : null
} catch (error) {
if (error?.code === 'ESRCH') {
groupState[stateKey] = stateKey === 'stopped' ? false : null
} else {
errors.push(error)
}
}
}
return errors
}
export function signalValidatedProcessGroup(
pgid,
environmentFragment,
signal,
groupState = { stopped: false, anchorPid: null },
operations = processGroupSignalOperations
) {
if (!Number.isInteger(pgid) || pgid <= 0) {
return false
}
let members
try {
members = operations.processIdentities(true).filter((identity) => identity.pgid === pgid)
} catch (error) {
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(
[error, ...recoveryErrors],
'Benchmark process group recovery failed before validation'
)
}
throw error
}
if (members.length === 0) {
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(recoveryErrors, 'Benchmark missing process group recovery failed')
}
return false
}
if (members.some((identity) => !identity.command.includes(environmentFragment))) {
const ownershipError = new Error('Benchmark process group no longer belongs to this trial')
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(
[ownershipError, ...recoveryErrors],
'Benchmark process group authority recovery failed'
)
}
throw ownershipError
}
if (groupState.anchorPid) {
try {
operations.signalProcess(groupState.anchorPid, 'SIGCONT')
groupState.anchorPid = null
} catch (error) {
if (error?.code === 'ESRCH') {
groupState.anchorPid = null
} else {
throw new AggregateError([error], 'Benchmark pending anchor recovery failed')
}
}
}
const anchor = members[0]
try {
operations.signalProcess(anchor.pid, 'SIGSTOP')
groupState.anchorPid = anchor.pid
const stoppedAnchor = operations
.processIdentities(true)
.find((identity) => identity.pid === anchor.pid)
if (!sameIdentity(stoppedAnchor, anchor)) {
throw new Error('Benchmark process group anchor changed before signaling')
}
operations.signalProcess(-pgid, 'SIGSTOP')
groupState.stopped = true
groupState.anchorPid = null
const stoppedMembers = operations
.processIdentities(true)
.filter((identity) => identity.pgid === pgid)
if (
stoppedMembers.length === 0 ||
stoppedMembers.some((identity) => !identity.command.includes(environmentFragment))
) {
throw new Error('Benchmark process group changed before signaling')
}
if (signal !== 'SIGSTOP') {
operations.signalProcess(-pgid, signal)
if (signal !== 'SIGKILL') {
operations.signalProcess(-pgid, 'SIGCONT')
}
groupState.stopped = false
groupState.anchorPid = null
}
return true
} catch (error) {
const recoveryErrors = compensateStoppedGroup(pgid, groupState, operations)
if (recoveryErrors.length > 0) {
throw new AggregateError(
[error, ...recoveryErrors],
'Benchmark process group signal recovery failed'
)
}
if (error.code === 'ESRCH') {
return false
}
throw error
}
}
export function writeProcessRecord(recordPath, processIdentity) {
const temporaryPath = `${recordPath}.${process.pid}.tmp`
writeFileSync(temporaryPath, JSON.stringify(processIdentity))
renameSync(temporaryPath, recordPath)
}
export function processIdentityIsCurrent(identity) {
return sameIdentity(processIdentity(identity?.pid), identity)
}
const processSignalOperations = {
processIdentity,
signalProcess: process.kill.bind(process)
}
export function signalProcessIdentity(
identity,
expectedCommandFragment,
signal,
operations = processSignalOperations
) {
validateDetachedIdentity(identity, expectedCommandFragment)
const currentIdentity = operations.processIdentity(identity.pid)
if (!currentIdentity) {
return false
}
if (!sameIdentity(currentIdentity, identity)) {
throw new Error('Recorded benchmark helper PID now belongs to another process')
}
let stopped = false
try {
operations.signalProcess(identity.pid, 'SIGSTOP')
stopped = true
const stoppedIdentity = operations.processIdentity(identity.pid)
if (!sameIdentity(stoppedIdentity, identity)) {
throw new Error('Recorded benchmark helper PID changed before signaling')
}
operations.signalProcess(-identity.pgid, signal)
if (signal !== 'SIGKILL') {
operations.signalProcess(-identity.pgid, 'SIGCONT')
}
stopped = false
return true
} catch (error) {
let resumeError
if (stopped) {
try {
operations.signalProcess(identity.pid, 'SIGCONT')
} catch (caught) {
if (caught?.code !== 'ESRCH') {
resumeError = caught
}
}
}
if (resumeError) {
throw new AggregateError([error, resumeError], 'Benchmark helper signal recovery failed')
}
if (error.code === 'ESRCH') {
return false
}
throw error
}
}
export function killRecordedProcess(recordPath, expectedCommandFragment) {
if (!existsSync(recordPath)) {
return false
}
const record = JSON.parse(readFileSync(recordPath, 'utf8'))
if (!signalProcessIdentity(record, expectedCommandFragment, 'SIGKILL')) {
return false
}
return waitForIdentityExit(record)
}
export function killRecordedAndMatchingProcesses(
recordPath,
recordedCommandFragment,
matchingCommandFragments
) {
const errors = []
try {
killRecordedProcess(recordPath, recordedCommandFragment)
} catch (error) {
errors.push(error)
}
try {
killProcessMatchingCommand(matchingCommandFragments)
} catch (error) {
errors.push(error)
}
if (errors.length === 1) {
throw errors[0]
}
if (errors.length > 1) {
throw new AggregateError(errors, 'Benchmark helper cleanup failed')
}
}
@@ -0,0 +1,558 @@
import { execFileSync, spawn } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
benchmarkTrialNeedsCleanup,
killProcessMatchingCommand,
killRecordedAndMatchingProcesses,
killRecordedProcess,
parseBenchmarkTrialResult,
processIdentity,
runBenchmarkCleanupStages,
signalProcessIdentity,
signalValidatedProcessGroup,
spawnBenchmarkProcess,
throwBenchmarkTrialFailures,
writeProcessRecord
} from './macos-computer-helper-owner-loss-processes.mjs'
import { cleanupOwnerLossTrial } from './macos-computer-helper-owner-loss-trial-cleanup.mjs'
const describeMacOS = process.platform === 'darwin' ? describe : describe.skip
const spawnedPids = new Set()
const temporaryDirectories = new Set()
afterEach(() => {
for (const pid of spawnedPids) {
try {
process.kill(pid, 'SIGKILL')
} catch {}
}
spawnedPids.clear()
for (const temporaryDirectory of temporaryDirectories) {
rmSync(temporaryDirectory, { recursive: true, force: true })
}
temporaryDirectories.clear()
})
describeMacOS('macOS helper owner-loss benchmark process cleanup', () => {
it('enforces a hard timeout when the trial ignores SIGTERM', () => {
const startedAt = Date.now()
const result = spawnBenchmarkProcess(
process.execPath,
['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 1_000)"],
{ stdio: 'ignore', timeout: 100 }
)
expect(result.error?.code).toBe('ETIMEDOUT')
expect(result.signal).toBe('SIGKILL')
expect(Date.now() - startedAt).toBeLessThan(2_000)
expect(() => process.kill(result.pid, 0)).toThrow()
})
it('runs every cleanup stage before aggregating errors', () => {
const completed = []
let thrown
try {
runBenchmarkCleanupStages([
() => {
completed.push(1)
throw new Error('first failure')
},
() => {
completed.push(2)
},
() => {
completed.push(3)
throw new Error('last failure')
}
])
} catch (error) {
thrown = error
}
expect(completed).toEqual([1, 2, 3])
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors.map((error) => error.message)).toEqual(['first failure', 'last failure'])
})
it('preserves malformed-result and cleanup failures', () => {
let trialError
try {
parseBenchmarkTrialResult('{malformed')
} catch (error) {
trialError = error
}
const cleanupError = new Error('cleanup failed')
let thrown
try {
throwBenchmarkTrialFailures(trialError, cleanupError)
} catch (error) {
thrown = error
}
expect(trialError).toBeInstanceOf(SyntaxError)
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors).toEqual([trialError, cleanupError])
})
it('cleans up a status-zero trial whose result could not be parsed', () => {
expect(benchmarkTrialNeedsCleanup(undefined, false)).toBe(true)
expect(benchmarkTrialNeedsCleanup({ status: 0 }, false)).toBe(true)
expect(benchmarkTrialNeedsCleanup({ status: 0 }, true)).toBe(false)
expect(benchmarkTrialNeedsCleanup({ status: 1 }, true)).toBe(true)
})
it('removes a launcher directory after partial trial setup', () => {
const launcherDir = mkdtempSync(path.join(tmpdir(), 'orca-owner-partial-setup-test-'))
temporaryDirectories.add(launcherDir)
const cleanup = cleanupOwnerLossTrial({
failed: true,
launcherDir,
outputPaths: []
})
expect(cleanup.error).toBeUndefined()
expect(existsSync(launcherDir)).toBe(false)
temporaryDirectories.delete(launcherDir)
})
it('kills a timed-out trial group only after validating its environment', async () => {
const temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'orca-owner-benchmark-group-test-'))
temporaryDirectories.add(temporaryDirectory)
const childPidPath = path.join(temporaryDirectory, 'child.pid')
const environmentName = `ORCA_OWNER_GROUP_${process.pid}`
const environmentValue = `${Date.now()}`
const fixture = `
const { spawn } = require('node:child_process')
const { writeFileSync } = require('node:fs')
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
stdio: 'ignore'
})
writeFileSync(${JSON.stringify(childPidPath)}, String(child.pid))
setInterval(() => {}, 1000)
`
const result = spawnBenchmarkProcess(process.execPath, ['-e', fixture], {
env: { ...process.env, [environmentName]: environmentValue },
stdio: 'ignore',
timeout: 100
})
const childPid = Number(readFileSync(childPidPath, 'utf8'))
spawnedPids.add(childPid)
const environmentFragment = `${environmentName}=${environmentValue}`
const groupState = { stopped: false }
expect(() =>
signalValidatedProcessGroup(result.pid, `${environmentName}=wrong`, 'SIGSTOP')
).toThrow('Benchmark process group no longer belongs to this trial')
expect(() => process.kill(childPid, 0)).not.toThrow()
expect(
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGSTOP', groupState)
).toBe(true)
expect(
signalValidatedProcessGroup(result.pid, environmentFragment, 'SIGKILL', groupState)
).toBe(true)
await expect
.poll(() => {
try {
process.kill(childPid, 0)
return true
} catch {
return false
}
})
.toBe(false)
spawnedPids.delete(childPid)
})
it('resumes the group after post-stop revalidation fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
{ pid: 42, pgid: 41, command: `/child ${marker}` }
]
const signals = []
let scanCount = 0
expect(() =>
signalValidatedProcessGroup(
41,
marker,
'SIGKILL',
{ stopped: false },
{
processIdentities: () => {
scanCount += 1
if (scanCount === 3) {
throw new Error('transient group inspection failure')
}
return members
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
)
).toThrow('transient group inspection failure')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[-41, 'SIGSTOP'],
[-41, 'SIGCONT']
])
})
it('compensates a possible stop after group anchor replacement', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
const signals = []
let scanCount = 0
expect(() =>
signalValidatedProcessGroup(
41,
marker,
'SIGKILL',
{ stopped: false },
{
processIdentities: () => {
scanCount += 1
return scanCount === 1 ? [anchor] : [replacement]
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
)
).toThrow('Benchmark process group anchor changed before signaling')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[41, 'SIGCONT']
])
})
it('resumes a previously frozen group when final inspection fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [{ pid: 41, pgid: 41, command: `/launcher ${marker}` }]
const signals = []
let scanCount = 0
const groupState = { stopped: false }
const operations = {
processIdentities: () => {
scanCount += 1
if (scanCount === 4) {
throw new Error('transient final inspection failure')
}
return members
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('transient final inspection failure')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[-41, 'SIGSTOP'],
[-41, 'SIGCONT']
])
})
it('resumes a previously frozen group when final anchor stop fails', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const members = [
{ pid: 41, pgid: 41, command: `/launcher ${marker}` },
{ pid: 42, pgid: 41, command: `/child ${marker}` }
]
const signals = []
let finalCall = false
const groupState = { stopped: false }
const missingProcessError = Object.assign(new Error('anchor exited'), { code: 'ESRCH' })
const operations = {
processIdentities: () => members,
signalProcess: (pid, signal) => {
signals.push([pid, signal])
if (finalCall && pid === 41 && signal === 'SIGSTOP') {
throw missingProcessError
}
}
}
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
finalCall = true
expect(signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)).toBe(false)
expect(signals.at(-1)).toEqual([-41, 'SIGCONT'])
})
it('resumes a previously frozen group after final anchor replacement', () => {
const marker = 'ORCA_OWNER_GROUP=trial'
const anchor = { pid: 41, pgid: 41, command: `/launcher ${marker}` }
const child = { pid: 42, pgid: 41, command: `/child ${marker}` }
const replacement = { pid: 41, pgid: 99, command: '/unrelated' }
const signals = []
let scanCount = 0
const groupState = { stopped: false }
const operations = {
processIdentities: () => {
scanCount += 1
return scanCount === 5 ? [replacement, child] : [anchor, child]
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
}
expect(signalValidatedProcessGroup(41, marker, 'SIGSTOP', groupState, operations)).toBe(true)
expect(() =>
signalValidatedProcessGroup(41, marker, 'SIGKILL', groupState, operations)
).toThrow('Benchmark process group anchor changed before signaling')
expect(signals.slice(-2)).toEqual([
[-41, 'SIGCONT'],
[41, 'SIGCONT']
])
})
it('kills a recorded helper in a separate process group', async () => {
const temporaryDirectory = mkdtempSync(
path.join(tmpdir(), 'orca-owner-benchmark-cleanup-test-')
)
temporaryDirectories.add(temporaryDirectory)
const recordPath = path.join(temporaryDirectory, 'helper.json')
const marker = `orca-owner-cleanup-${process.pid}-${Date.now()}`
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
detached: true,
stdio: 'ignore'
})
spawnedPids.add(helper.pid)
helper.unref()
const exited = new Promise((resolve) => helper.once('exit', resolve))
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
encoding: 'utf8'
}).trim()
const processGroup = Number(
execFileSync('ps', ['-p', String(helper.pid), '-o', 'pgid='], {
encoding: 'utf8'
}).trim()
)
writeProcessRecord(recordPath, { pid: helper.pid, pgid: processGroup, command })
expect(processGroup).toBe(helper.pid)
expect(killRecordedProcess(recordPath, marker)).toBe(true)
await exited
expect(() => process.kill(helper.pid, 0)).toThrow()
spawnedPids.delete(helper.pid)
})
it('kills every unrecorded helper using its unique trial command', async () => {
const marker = `orca-owner-unrecorded-${process.pid}-${Date.now()}`
const helpers = Array.from({ length: 2 }, () =>
spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
detached: true,
stdio: 'ignore'
})
)
for (const helper of helpers) {
spawnedPids.add(helper.pid)
helper.unref()
}
const exited = Promise.all(
helpers.map((helper) => new Promise((resolve) => helper.once('exit', resolve)))
)
expect(killProcessMatchingCommand([process.execPath, marker])).toBe(true)
await exited
for (const helper of helpers) {
expect(() => process.kill(helper.pid, 0)).toThrow()
spawnedPids.delete(helper.pid)
}
})
it('continues exact-match cleanup after an earlier match fails', () => {
const marker = `orca-owner-multiple-${process.pid}-${Date.now()}`
const matches = [
{ pid: 41, pgid: 41, command: `/helper ${marker}` },
{ pid: 42, pgid: 42, command: `/helper ${marker}` }
]
const attempted = []
let scanCount = 0
expect(() =>
killProcessMatchingCommand(['/helper', marker], {
processIdentities: () => {
scanCount += 1
return scanCount === 1 ? matches : [matches[0]]
},
signalProcessIdentity: (identity) => {
attempted.push(identity.pid)
if (identity.pid === matches[0].pid) {
throw new Error('identity changed')
}
return true
},
waitForIdentityExit: () => {}
})
).toThrow('Benchmark exact-command cleanup failed')
expect(attempted).toEqual([41, 42])
})
it('does not treat an identity query failure as process exit', () => {
const queryError = new Error('transient ps failure')
expect(() =>
processIdentity(41, {
executePs: () => {
throw queryError
},
signalProcess: () => {}
})
).toThrow(queryError)
})
it('resumes a helper when post-stop identity inspection fails', () => {
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
const signals = []
let inspectionCount = 0
expect(() =>
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
processIdentity: () => {
inspectionCount += 1
if (inspectionCount === 2) {
throw new Error('transient ps failure after stop')
}
return identity
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
})
).toThrow('transient ps failure after stop')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[41, 'SIGCONT']
])
})
it('compensates a possible stop after helper PID replacement', () => {
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
const signals = []
let inspectionCount = 0
expect(() =>
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
processIdentity: () => {
inspectionCount += 1
return inspectionCount === 1 ? identity : replacement
},
signalProcess: (pid, signal) => {
signals.push([pid, signal])
}
})
).toThrow('Recorded benchmark helper PID changed before signaling')
expect(signals).toEqual([
[41, 'SIGSTOP'],
[41, 'SIGCONT']
])
})
it('preserves helper identity errors when compensation fails', () => {
const identity = { pid: 41, pgid: 41, command: '/helper marker' }
const replacement = { pid: 41, pgid: 41, command: '/unrelated' }
let inspectionCount = 0
let thrown
try {
signalProcessIdentity(identity, 'marker', 'SIGKILL', {
processIdentity: () => {
inspectionCount += 1
return inspectionCount === 1 ? identity : replacement
},
signalProcess: (_pid, signal) => {
if (signal === 'SIGCONT') {
throw new Error('resume denied')
}
}
})
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(AggregateError)
expect(thrown.errors.map((error) => error.message)).toEqual([
'Recorded benchmark helper PID changed before signaling',
'resume denied'
])
})
it('treats a missing PID as process exit after an identity query failure', () => {
const missingProcessError = Object.assign(new Error('missing process'), { code: 'ESRCH' })
expect(
processIdentity(41, {
executePs: () => {
throw new Error('ps found no process')
},
signalProcess: () => {
throw missingProcessError
}
})
).toBeNull()
})
it('runs unique-command cleanup after an invalid process record', async () => {
const temporaryDirectory = mkdtempSync(
path.join(tmpdir(), 'orca-owner-benchmark-fallback-test-')
)
temporaryDirectories.add(temporaryDirectory)
const recordPath = path.join(temporaryDirectory, 'helper.json')
const marker = `orca-owner-invalid-record-${process.pid}-${Date.now()}`
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
detached: true,
stdio: 'ignore'
})
spawnedPids.add(helper.pid)
helper.unref()
const exited = new Promise((resolve) => helper.once('exit', resolve))
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
encoding: 'utf8'
}).trim()
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
expect(() =>
killRecordedAndMatchingProcesses(recordPath, marker, [process.execPath, marker])
).toThrow('Recorded benchmark helper identity is invalid')
await exited
expect(() => process.kill(helper.pid, 0)).toThrow()
spawnedPids.delete(helper.pid)
})
it('rejects a record that is not a detached process-group identity', () => {
const temporaryDirectory = mkdtempSync(
path.join(tmpdir(), 'orca-owner-benchmark-identity-test-')
)
temporaryDirectories.add(temporaryDirectory)
const recordPath = path.join(temporaryDirectory, 'helper.json')
const marker = `orca-owner-invalid-identity-${process.pid}-${Date.now()}`
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)', marker], {
stdio: 'ignore'
})
spawnedPids.add(helper.pid)
helper.unref()
const command = execFileSync('ps', ['-p', String(helper.pid), '-o', 'command='], {
encoding: 'utf8'
}).trim()
writeProcessRecord(recordPath, { pid: helper.pid, pgid: helper.pid - 1, command })
expect(() => killRecordedProcess(recordPath, marker)).toThrow(
'Recorded benchmark helper identity is invalid'
)
expect(() => process.kill(helper.pid, 0)).not.toThrow()
})
})
@@ -0,0 +1,63 @@
import { closeSync, existsSync, readFileSync, rmSync } from 'node:fs'
import {
killRecordedAndMatchingProcesses,
runBenchmarkCleanupStages,
signalValidatedProcessGroup
} from './macos-computer-helper-owner-loss-processes.mjs'
export function cleanupOwnerLossTrial(options) {
const groupState = { stopped: false, anchorPid: null }
let error
let output = ''
try {
runBenchmarkCleanupStages([
() => {
if (options.failed && Number.isInteger(options.pid) && options.marker) {
signalValidatedProcessGroup(options.pid, options.marker, 'SIGSTOP', groupState)
}
},
() => {
if (options.failed && options.recordPath && options.tempDir) {
killRecordedAndMatchingProcesses(options.recordPath, options.helperPath, [
options.helperPath,
options.tempDir
])
}
},
() => {
if (options.failed && Number.isInteger(options.pid) && options.marker) {
signalValidatedProcessGroup(options.pid, options.marker, 'SIGKILL', groupState)
}
},
() => {
if (options.stderrDescriptor !== undefined) {
closeSync(options.stderrDescriptor)
}
},
() => {
if (options.stdoutDescriptor !== undefined) {
closeSync(options.stdoutDescriptor)
}
},
() => {
output = (options.outputPaths ?? [])
.filter((outputPath) => outputPath && existsSync(outputPath))
.map((outputPath) => readFileSync(outputPath, 'utf8'))
.join('')
},
() => {
if (options.launcherDir) {
rmSync(options.launcherDir, { recursive: true, force: true })
}
},
() => {
if (options.tempDir) {
rmSync(options.tempDir, { recursive: true, force: true })
}
}
])
} catch (caught) {
error = caught
}
return { error, output }
}
@@ -297,116 +297,6 @@ describe('Electron runtime package contract', () => {
expect(releaseMacWorkflowText).not.toContain('SIGNPATH_')
})
it('preflights SignPath module install before Windows signing side effects', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const installStepIndexes = stepNames.flatMap((name, index) =>
name === 'Install SignPath PowerShell module' ? [index] : []
)
const buildIndex = stepNames.indexOf('Build Windows release artifacts')
const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime')
const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath')
const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath')
expect(verifyNodePtyIndex).toBe(buildIndex + 1)
expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1])
expect(installStepIndexes[0]).toBeLessThan(uploadIndex)
expect(steps[verifyNodePtyIndex].run).toContain(
'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
)
expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll')
const uploadThroughDownloadScript = steps
.slice(uploadIndex, downloadIndex + 1)
.map((step) => step.run ?? '')
.join('\n')
expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath')
const installStep = steps[installStepIndexes[0]]
const installRun = installStep.run
const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map(
([, seconds]) => seconds
)
expect(installStep.if).toBe("matrix.platform == 'win'")
expect(installStep.shell).toBe('pwsh')
expect(installRun).toContain(
'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))'
)
expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted')
expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted')
expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/)
expect(installRun).toContain(
"$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'"
)
expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/)
expect(sleepSeconds).toEqual(['15', '30'])
expect(installRun).toContain(
'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop'
)
expect(installRun).toContain('Import-Module SignPath')
expect(installRun).toContain(
'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop'
)
expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force')
expect(installRun).not.toContain('SignPath*')
expect(installRun.indexOf('if ($attempt -eq 3)')).toBeLessThan(
installRun.indexOf('Remove-Item -LiteralPath $signPathModulePath')
)
expect(installRun).toMatch(/if \(\$attempt -eq 3\) {\s+throw\s+}/)
expect(installRun).not.toMatch(/throw\s+\$_/)
})
it('verifies Windows inner binary signatures fail-open before publishing', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures')
const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence')
const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
expect(outerVerifyIndex).toBeGreaterThan(-1)
expect(innerVerifyIndex).toBe(outerVerifyIndex + 1)
expect(evidenceIndex).toBe(innerVerifyIndex + 1)
expect(publishIndex).toBe(evidenceIndex + 1)
// Why fail-open: unsigned inner binaries must warn, not block, until the
// flow is proven on a real release (issue #7785). Flip this to 'true'
// together with the workflow env to make the gate required.
expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false')
// Why: every step in the inner-signing chain must be unable to fail the
// release — a SignPath outage or timeout falls through to today's
// unsigned-inner flow instead of blocking the cut.
const innerChainStepNames = [
'Stage unsigned inner PE files for signing',
'Upload unsigned inner binaries for SignPath',
'Submit inner binaries signing request',
'Notify Slack that inner-binary signing is waiting for approval',
'Download signed inner binaries from SignPath',
'Restore signed inner binaries into unpacked app',
'Replace cached elevate.exe with the signed copy',
'Rebuild NSIS installer from signed unpacked app'
]
for (const stepName of innerChainStepNames) {
const step = steps[stepNames.indexOf(stepName)]
expect(step, stepName).toBeDefined()
expect(step['continue-on-error'], stepName).toBe(true)
}
})
it('publishes both Linux release matrix entries', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
@@ -0,0 +1,35 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
describe('packaged hang watchdog worker contract', () => {
it('boots the worker from app.asar in PR checks', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const smokeSource = readFileSync(
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
'utf8'
)
const smokeStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Smoke packaged hang watchdog worker'
)
expect(smokeStep.run).toBe(
'xvfb-run --auto-servernum node config/scripts/smoke-packaged-hang-watchdog-worker.mjs --app-dir=dist/linux-unpacked'
)
expect(smokeSource).toContain(
"process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]"
)
})
// Why: Electron ignores process.exitCode, so the gate needs app.exit plus a stdout assertion.
it('fails the smoke when the packaged worker never reports success', () => {
const smokeSource = readFileSync(
'config/scripts/smoke-packaged-hang-watchdog-worker.mjs',
'utf8'
)
expect(smokeSource).toContain('app.exit(1)')
expect(smokeSource).not.toContain('app.quit()')
expect(smokeSource).toContain('if (!result.stdout.includes(SUCCESS_LINE))')
})
})
@@ -177,7 +177,8 @@ describe('PR workflow parallelism', () => {
'git_compatibility',
'shell_contracts',
'test',
'package'
'package',
'package_windows'
])
})
})
@@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
describe('release-cut SignPath Slack approval pings', () => {
it('includes cut source ref/commit and who triggered the cut', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
)
const cutOutputs = workflow.jobs.cut.outputs
expect(cutOutputs.source_ref).toContain('steps.resolve.outputs.ref')
expect(cutOutputs.source_sha).toContain('steps.resolve.outputs.sha')
expect(cutOutputs.source_short_sha).toContain('steps.resolve.outputs.short_sha')
const steps = workflow.jobs.build.steps
const notifySteps = steps.filter(
(step) =>
step.name === 'Notify Slack that inner-binary signing is waiting for approval' ||
step.name === 'Notify Slack that Windows signing is waiting for approval'
)
expect(notifySteps).toHaveLength(2)
for (const step of notifySteps) {
expect(step.env.SOURCE_REF).toContain('needs.cut.outputs.source_ref')
expect(step.env.SOURCE_SHA).toContain('needs.cut.outputs.source_sha')
expect(step.env.SOURCE_SHORT_SHA).toContain('needs.cut.outputs.source_short_sha')
expect(step.env.CUT_BY).toMatch(/github\.(triggering_actor|actor)/)
expect(step.run).toContain('Source:')
expect(step.run).toContain('cut by')
}
})
})
@@ -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
})
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
#
# Provisions the credentials hourly-mac-build.yml uses to publish into
# stablyai/orca-hourly. GITHUB_TOKEN cannot be used: it is scoped to the repo
# running the workflow, and hourly artifacts are published to a different one.
#
# A GitHub App is used rather than a PAT because its private key does not expire
# — no yearly rotation — and it belongs to the org rather than to the person who
# created it, so it survives that person leaving.
#
# The key is read from a file and piped straight into `gh secret set`. It is never
# echoed, never passed as a command-line argument (argv is world-readable via
# `ps`), and never copied anywhere on disk.
#
# Usage: bash config/scripts/setup-hourly-release-token.sh [path/to/key.pem]
#
set -euo pipefail
# Guard: xtrace would echo the key to stderr on every expansion. Test before
# disabling, or the check reads the state this line just cleared and never fires.
if [[ -o xtrace ]]; then
echo "Refusing to run with xtrace enabled; it would echo the private key." >&2
exit 1
fi
set +x
MAIN_REPO="stablyai/orca"
HOURLY_REPO="stablyai/orca-hourly"
APP_ID_SECRET="HOURLY_RELEASE_APP_ID"
APP_KEY_SECRET="HOURLY_RELEASE_APP_PRIVATE_KEY"
fail() {
echo "error: $*" >&2
exit 1
}
command -v gh >/dev/null 2>&1 || fail "gh CLI not found. See https://cli.github.com"
gh auth status >/dev/null 2>&1 || fail "Not logged in. Run: gh auth login"
# Setting repo secrets requires admin; check before asking for anything.
if [[ "$(gh api "repos/$MAIN_REPO" --jq '.permissions.admin' 2>/dev/null)" != "true" ]]; then
fail "You need admin on $MAIN_REPO to set repository secrets."
fi
gh api "repos/$HOURLY_REPO" --jq '.full_name' >/dev/null 2>&1 ||
fail "$HOURLY_REPO does not exist or you cannot see it."
cat <<EOF
Create a GitHub App (one time — the key never expires)
──────────────────────────────────────────────────────
1. Open: https://github.com/organizations/stablyai/settings/apps/new
2. Name .................. orca-hourly-release
Homepage URL .......... https://github.com/$HOURLY_REPO
Webhook ............... UNCHECK "Active"
3. Repository permissions -> Contents: Read and write
(leave everything else alone)
4. "Where can this app be installed?" -> Only on this account
5. Create, then note the App ID shown at the top of the page.
6. Generate a private key (bottom of the page) — a .pem downloads.
7. Install App -> Only select repositories -> $HOURLY_REPO
EOF
read -rp "App ID (numeric): " APP_ID
[[ "$APP_ID" =~ ^[0-9]+$ ]] || fail "App ID must be numeric, got: ${APP_ID:-<empty>}"
KEY_PATH="${1:-}"
if [[ -z "$KEY_PATH" ]]; then
read -rp "Path to the downloaded .pem: " KEY_PATH
fi
# Expand a leading ~ so a pasted path works without quoting rules.
KEY_PATH="${KEY_PATH/#\~/$HOME}"
[[ -r "$KEY_PATH" ]] || fail "Cannot read key file: $KEY_PATH"
grep -q "BEGIN.*PRIVATE KEY" "$KEY_PATH" ||
fail "$KEY_PATH does not look like a PEM private key."
echo "Storing $APP_ID_SECRET in $MAIN_REPO..."
printf '%s' "$APP_ID" | gh secret set "$APP_ID_SECRET" --repo "$MAIN_REPO" ||
fail "Could not set $APP_ID_SECRET."
# Piped on stdin so the key never appears in argv or in shell history.
echo "Storing $APP_KEY_SECRET in $MAIN_REPO..."
gh secret set "$APP_KEY_SECRET" --repo "$MAIN_REPO" <"$KEY_PATH" ||
fail "Could not set $APP_KEY_SECRET."
echo
echo "Done. Both secrets are set on $MAIN_REPO."
echo
echo "Delete your local copy of the key — the workflow reads it from the secret,"
echo "and a .pem sitting in ~/Downloads is a standing credential:"
echo " rm '$KEY_PATH'"
echo
echo "Smoke-test the pipeline without waiting for the hour (after this merges):"
echo " gh workflow run hourly-mac-build.yml --repo $MAIN_REPO -f force=true"
echo " gh run watch --repo $MAIN_REPO"
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
describe('packaged skills CLI PR gates', () => {
it('builds and executes the Windows packaged CLI', () => {
const job = workflow.jobs.package_windows
const buildStep = job.steps.find((step) => step.name === 'Build package inputs')
const prepareStep = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
const packageStep = job.steps.find((step) => step.name === 'Package unpacked app')
const smokeStep = job.steps.find((step) => step.name === 'Smoke packaged CLI')
expect(job['runs-on']).toBe('windows-2022')
expect(buildStep.run).toBe('pnpm run build:release')
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.run).toContain('electron-builder')
expect(packageStep.run).toContain('--dir')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
expect(smokeStep.run).toBe(
'node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked'
)
const aggregateStep = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(aggregateStep.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}')
expect(aggregateStep.run).toContain('"$PACKAGE_WINDOWS"')
})
})
+36 -4
View File
@@ -3,6 +3,7 @@ import { execFile } from 'node:child_process'
import { tmpdir } from 'node:os'
import { basename, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import assert from 'node:assert/strict'
const execFileAsync = promisify(execFile)
@@ -37,10 +38,41 @@ const copiedAppDir = join(tempRoot, basename(appDir))
try {
await cp(appDir, copiedAppDir, { recursive: true, verbatimSymlinks: true })
const cliPath = getPackagedCliPath(copiedAppDir)
await execFileAsync(cliPath, ['--help'], {
env: { ...process.env, NODE_PATH: '' }
})
console.log(`[packaged-cli-smoke] ${cliPath} --help succeeded outside the repo`)
const env = { ...process.env, NODE_PATH: '' }
delete env.ORCA_CLI_CWD
const run = (args) =>
execFileAsync(cliPath, args, {
env,
killSignal: 'SIGKILL',
maxBuffer: 16 * 1024 * 1024,
timeout: 30_000
})
await run(['--help'])
const list = JSON.parse((await run(['skills', 'list', '--json'])).stdout)
assert(list.topics.some((topic) => topic.name === 'orca-cli'))
assert.match((await run(['skills', 'get', 'orca-cli'])).stdout, /name: orca-cli/)
assert.match((await run(['skills', 'get', 'computer-use'])).stdout, /name: computer-use/)
const install = JSON.parse(
(
await run([
'skills',
'install',
'--skill',
'orca-cli',
'--agent',
'codex',
'--dry-run',
'--json'
])
).stdout
)
const update = JSON.parse(
(await run(['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json'])).stdout
)
assert.equal(install.executed, false)
assert.equal(update.executed, false)
console.log(`[packaged-cli-smoke] help and skills commands passed via ${cliPath}`)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
@@ -0,0 +1,179 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Worker } from 'node:worker_threads'
const INTERNAL_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_INTERNAL'
const ASAR_ENV = 'ORCA_PACKAGED_WATCHDOG_SMOKE_ASAR'
const TIMEOUT_MS = 100
const CHECK_INTERVAL_MS = 20
const POLL_TIMEOUT_MS = 5_000
const SUCCESS_LINE = '[packaged-watchdog-smoke] app.asar worker detected and recovered a stall'
function sleep(ms) {
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
}
function readAppDirArg(argv) {
const explicit = argv.find((arg) => arg.startsWith('--app-dir='))
if (explicit) {
return explicit.slice('--app-dir='.length)
}
if (process.platform === 'darwin') {
return 'dist/mac-arm64/Orca.app'
}
if (process.platform === 'win32') {
return 'dist/win-unpacked'
}
return 'dist/linux-unpacked'
}
function getResourcesDir(appDir) {
return process.platform === 'darwin' || appDir.endsWith('.app')
? join(appDir, 'Contents', 'Resources')
: join(appDir, 'resources')
}
function readMarker(markerPath) {
try {
return JSON.parse(readFileSync(markerPath, 'utf8'))
} catch {
return null
}
}
async function waitForMarker(markerPath, predicate, workerError) {
const deadline = Date.now() + POLL_TIMEOUT_MS
while (Date.now() < deadline) {
if (workerError.current) {
throw workerError.current
}
const marker = readMarker(markerPath)
if (predicate(marker)) {
return marker
}
await sleep(CHECK_INTERVAL_MS)
}
throw new Error(`Timed out waiting for packaged watchdog marker at ${markerPath}`)
}
async function waitForExit(worker, workerError) {
const exitCode = await Promise.race([
new Promise((resolveExit) => worker.once('exit', resolveExit)),
sleep(POLL_TIMEOUT_MS).then(() => {
throw new Error('Timed out waiting for packaged watchdog worker to exit')
})
])
if (workerError.current) {
throw workerError.current
}
if (exitCode !== 0) {
throw new Error(`Packaged watchdog worker exited with code ${exitCode}`)
}
}
async function runInternal() {
const appAsar = process.env[ASAR_ENV]
if (!appAsar) {
throw new Error(`Missing ${ASAR_ENV}`)
}
const { app } = await import('electron')
const tempRoot = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-smoke-'))
const markerPath = join(tempRoot, 'main-thread-hang.json')
const entryPath = join(appAsar, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
let worker
try {
await app.whenReady()
if (!existsSync(entryPath)) {
throw new Error(`Packaged watchdog entry is missing from app.asar: ${entryPath}`)
}
const workerError = { current: null }
worker = new Worker(entryPath, {
workerData: {
parentPid: process.pid,
markerPath,
timeoutMs: TIMEOUT_MS,
checkIntervalMs: CHECK_INTERVAL_MS
}
})
worker.once('error', (error) => {
workerError.current = error
})
await waitForMarker(markerPath, (marker) => marker?.selfRecovered === false, workerError)
worker.postMessage({ type: 'heartbeat' })
await waitForMarker(markerPath, (marker) => marker?.selfRecovered === true, workerError)
worker.postMessage({ type: 'shutdown' })
await waitForExit(worker, workerError)
worker = undefined
console.log(SUCCESS_LINE)
} finally {
await worker?.terminate()
rmSync(tempRoot, { recursive: true, force: true })
}
}
function runSmoke() {
const appDir = resolve(readAppDirArg(process.argv.slice(2)))
const appAsar = join(getResourcesDir(appDir), 'app.asar')
if (!existsSync(appAsar)) {
throw new Error(`Packaged app archive is missing: ${appAsar}`)
}
const require = createRequire(import.meta.url)
const executable = require('electron')
const launcherDir = mkdtempSync(join(tmpdir(), 'orca-packaged-watchdog-launcher-'))
const launcherPath = join(launcherDir, 'main.cjs')
writeFileSync(
join(launcherDir, 'package.json'),
JSON.stringify({ name: 'orca-packaged-watchdog-smoke', main: 'main.cjs' })
)
writeFileSync(
launcherPath,
`import(${JSON.stringify(pathToFileURL(import.meta.filename).href)}).catch((error) => {
console.error(error)
process.exitCode = 1
})\n`
)
const env = { ...process.env, [INTERNAL_ENV]: '1', [ASAR_ENV]: appAsar, NODE_PATH: '' }
delete env.ELECTRON_RUN_AS_NODE
try {
const electronArgs =
process.platform === 'linux' ? ['--no-sandbox', launcherDir] : [launcherDir]
const result = spawnSync(executable, electronArgs, {
env,
encoding: 'utf8',
timeout: 15_000
})
if (result.status !== 0) {
throw new Error(
`Packaged watchdog smoke failed (${result.error?.message ?? result.signal ?? result.status}):\n` +
`${result.stderr || result.stdout}`
)
}
// Why: Electron discards process.exitCode, so status 0 alone can't prove the worker ran.
if (!result.stdout.includes(SUCCESS_LINE)) {
throw new Error(
`Packaged watchdog smoke did not report success:\n${result.stderr || result.stdout}`
)
}
process.stdout.write(result.stdout)
} finally {
rmSync(launcherDir, { recursive: true, force: true })
}
}
if (process.env[INTERNAL_ENV] === '1') {
// Why: a graceful quit exits 0 regardless of process.exitCode; only app.exit propagates failure.
const { app } = await import('electron')
try {
await runInternal()
app.exit(0)
} catch (error) {
console.error(error)
app.exit(1)
}
} else {
runSmoke()
}
@@ -0,0 +1,229 @@
const { existsSync, readFileSync, realpathSync } = require('node:fs')
const { builtinModules, createRequire, isBuiltin } = require('node:module')
const { dirname, isAbsolute, join, relative, resolve, sep } = require('node:path')
const { spawnSync } = require('node:child_process')
const ts = require('typescript-api')
const BUILTINS = new Set(builtinModules.flatMap((name) => [name, `node:${name}`]))
const CLI_COMMAND_TIMEOUT_MS = 30_000
function artifactPath(outDir, file) {
return relative(outDir, file).split(sep).join('/')
}
function runtimeImportSpecifiers(source, file) {
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
false,
ts.ScriptKind.JS
)
const specifiers = []
function visit(node) {
if (ts.isCallExpression(node) && node.arguments.length > 0) {
const [argument] = node.arguments
const expression = node.expression
const isRequire = ts.isIdentifier(expression) && expression.text === 'require'
const isRequireResolve =
ts.isPropertyAccessExpression(expression) &&
ts.isIdentifier(expression.expression) &&
expression.expression.text === 'require' &&
expression.name.text === 'resolve'
const isDynamicImport = expression.kind === ts.SyntaxKind.ImportKeyword
if ((isRequire || isRequireResolve || isDynamicImport) && ts.isStringLiteralLike(argument)) {
specifiers.push(argument.text)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return specifiers
}
function isOutsideRoot(root, target) {
const pathFromRoot = relative(root, target)
return isAbsolute(pathFromRoot) || pathFromRoot === '..' || pathFromRoot.startsWith(`..${sep}`)
}
function isOptionalPackageImport(artifactRoot, importer, specifier) {
if (specifier.startsWith('.') || isAbsolute(specifier)) {
return false
}
const segments = specifier.split('/')
const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]
let directory = realpathSync(dirname(importer))
while (!isOutsideRoot(artifactRoot, directory)) {
const packageJson = join(directory, 'package.json')
if (existsSync(packageJson)) {
try {
const manifest = JSON.parse(readFileSync(packageJson, 'utf8'))
return (
Object.hasOwn(manifest.optionalDependencies ?? {}, packageName) ||
manifest.peerDependenciesMeta?.[packageName]?.optional === true
)
} catch {
return false
}
}
if (directory === artifactRoot) {
break
}
directory = dirname(directory)
}
return false
}
function resolveRuntimeImport(outDir, artifactRoot, importer, specifier) {
if (BUILTINS.has(specifier) || isBuiltin(specifier)) {
return null
}
let resolved
try {
resolved = createRequire(importer).resolve(specifier)
} catch (error) {
if (isOptionalPackageImport(artifactRoot, importer, specifier)) {
return null
}
const detail = error instanceof Error ? error.message : String(error)
throw new Error(
`[verify-skills-cli-runtime] missing runtime import "${specifier}" from ` +
`${artifactPath(outDir, importer)}: ${detail}`
)
}
if (isOutsideRoot(artifactRoot, resolved)) {
throw new Error(
`[verify-skills-cli-runtime] import "${specifier}" from ` +
`${artifactPath(outDir, importer)} resolved outside ${artifactRoot}: ${resolved}`
)
}
return resolved
}
function collectRuntimeClosure(outDir, artifactRoot = dirname(outDir)) {
outDir = realpathSync(outDir)
artifactRoot = realpathSync(artifactRoot)
if (isOutsideRoot(artifactRoot, outDir)) {
throw new Error(`[verify-skills-cli-runtime] ${outDir} is outside ${artifactRoot}`)
}
const entry = resolve(outDir, 'cli', 'index.js')
if (!existsSync(entry)) {
throw new Error(`[verify-skills-cli-runtime] missing entry ${entry}`)
}
const pending = [entry]
const visited = new Set()
while (pending.length > 0) {
const file = pending.pop()
if (!file || visited.has(file)) {
continue
}
visited.add(file)
const source = readFileSync(file, 'utf8')
for (const specifier of runtimeImportSpecifiers(source, file)) {
const resolved = resolveRuntimeImport(outDir, artifactRoot, file, specifier)
if (resolved && !isOutsideRoot(artifactRoot, resolved) && /\.(?:c|m)?js$/.test(resolved)) {
pending.push(resolved)
}
}
}
return [...visited].sort()
}
function runCli(outDir, args, timeoutMs = CLI_COMMAND_TIMEOUT_MS) {
const entry = resolve(outDir, 'cli', 'index.js')
const env = { ...process.env, NODE_PATH: '' }
delete env.ORCA_CLI_CWD
const result = spawnSync(process.execPath, [entry, ...args], {
cwd: dirname(outDir),
encoding: 'utf8',
env,
killSignal: 'SIGKILL',
maxBuffer: 16 * 1024 * 1024,
timeout: timeoutMs
})
if (result.error || result.signal || result.status !== 0) {
const detail = [
result.error?.message,
result.signal ? `terminated by ${result.signal}` : null,
result.stdout,
result.stderr
]
.filter(Boolean)
.join('\n')
throw new Error(
`[verify-skills-cli-runtime] ${args.join(' ')} exited ${String(result.status)}\n${detail}`
)
}
return result.stdout
}
function parseJson(label, output) {
try {
return JSON.parse(output)
} catch {
throw new Error(`[verify-skills-cli-runtime] ${label} emitted invalid JSON:\n${output}`)
}
}
function verifySkillsCliRuntime(outDir, artifactRoot = dirname(outDir), options = {}) {
const absoluteOutDir = resolve(outDir)
const closure = collectRuntimeClosure(absoluteOutDir, resolve(artifactRoot))
if (options.executeCommands === false) {
return { closureFiles: closure.length, commands: 0 }
}
const list = parseJson('skills list', runCli(absoluteOutDir, ['skills', 'list', '--json']))
const topicNames = new Set(list.topics?.map((topic) => topic.name))
for (const topic of ['orca-cli', 'computer-use']) {
if (!topicNames.has(topic)) {
throw new Error(`[verify-skills-cli-runtime] skills list omitted ${topic}`)
}
const guide = runCli(absoluteOutDir, ['skills', 'get', topic])
if (!guide.includes(`name: ${topic}`)) {
throw new Error(`[verify-skills-cli-runtime] skills get ${topic} returned the wrong guide`)
}
}
const install = parseJson(
'skills install --dry-run',
runCli(absoluteOutDir, [
'skills',
'install',
'--skill',
'orca-cli',
'--agent',
'codex',
'--dry-run',
'--json'
])
)
const update = parseJson(
'skills update --dry-run',
runCli(absoluteOutDir, ['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json'])
)
if (install.executed !== false || update.executed !== false) {
throw new Error('[verify-skills-cli-runtime] a dry-run reported execution')
}
return { closureFiles: closure.length, commands: 5 }
}
if (require.main === module) {
try {
const result = verifySkillsCliRuntime(process.argv[2] ?? 'out')
console.log(
`[verify-skills-cli-runtime] ${result.closureFiles} closure files and ` +
`${result.commands} commands passed`
)
} catch (error) {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
}
}
module.exports = { collectRuntimeClosure, runCli, verifySkillsCliRuntime }
@@ -0,0 +1,230 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { realpathSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
collectRuntimeClosure,
runCli,
verifySkillsCliRuntime
} = require('./verify-skills-cli-runtime.cjs')
async function writeSkillsCliFixture(outDir, handlerSource) {
const cliDir = join(outDir, 'cli')
const handlerDir = join(cliDir, 'handlers')
await mkdir(handlerDir, { recursive: true })
await writeFile(join(cliDir, 'index.js'), "require('./handlers/skills')\n", 'utf8')
await writeFile(join(handlerDir, 'skills.js'), handlerSource, 'utf8')
}
describe('skills CLI runtime closure', () => {
it('runs after Electron composes the final output', async () => {
const packageJson = JSON.parse(
await readFile(new URL('../../package.json', import.meta.url), 'utf8')
)
for (const scriptName of ['build:desktop', 'build:release']) {
const script = packageJson.scripts[scriptName]
expect(script.indexOf('build:electron-vite')).toBeLessThan(
script.indexOf('verify:built-skills-cli')
)
}
})
it('reports the missing final-artifact import and its owner', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
await writeSkillsCliFixture(root, "require('../../main/codex-cli/command')\n")
expect(() => collectRuntimeClosure(root)).toThrow(
/missing runtime import "\.\.\/\.\.\/main\/codex-cli\/command" from cli\/handlers\/skills\.js/
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('walks static and dynamic relative imports', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
const sharedDir = join(root, 'shared')
await mkdir(sharedDir, { recursive: true })
await writeSkillsCliFixture(
root,
"require('../../shared/first.js'); import('../../shared/second.js')\n"
)
await writeFile(join(sharedDir, 'first.js'), '', 'utf8')
await writeFile(join(sharedDir, 'second.js'), '', 'utf8')
expect(
collectRuntimeClosure(root)
.map((file) => relative(realpathSync(root), file))
.sort()
).toEqual(['cli/handlers/skills.js', 'cli/index.js', 'shared/first.js', 'shared/second.js'])
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('ignores import-shaped text in comments and strings', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
await writeSkillsCliFixture(
root,
[
"// require('../../missing-comment.js')",
'const message = "import(\'../../missing-string.js\')"',
"const template = `require.resolve('../../missing-template.js')`"
].join('\n')
)
expect(collectRuntimeClosure(root)).toHaveLength(2)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('can inspect a cross-arch artifact without executing it', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
await writeSkillsCliFixture(root, '')
expect(verifySkillsCliRuntime(root, undefined, { executeCommands: false })).toEqual({
closureFiles: 2,
commands: 0
})
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('bounds command execution time', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
await writeSkillsCliFixture(root, 'setInterval(() => {}, 1_000)\n')
expect(() => runCli(root, [], 50)).toThrow(/ETIMEDOUT|terminated by SIGKILL/)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('rejects bare imports resolved outside the artifact', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
const artifactRoot = join(root, 'artifact')
const outDir = join(artifactRoot, 'out')
const externalPackageDir = join(root, 'node_modules', 'external-package')
await mkdir(externalPackageDir, { recursive: true })
await writeSkillsCliFixture(outDir, "require('external-package')\n")
await writeFile(
join(externalPackageDir, 'package.json'),
JSON.stringify({ main: 'index.js' }),
'utf8'
)
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
/external-package.*resolved outside/s
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('rejects package dependencies resolved outside the artifact', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
const artifactRoot = join(root, 'artifact')
const outDir = join(artifactRoot, 'out')
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
const externalPackageDir = join(root, 'node_modules', 'ancestor-dependency')
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
await mkdir(packageDir, { recursive: true })
await mkdir(externalPackageDir, { recursive: true })
await writeFile(
join(packageDir, 'package.json'),
JSON.stringify({ main: 'index.js' }),
'utf8'
)
await writeFile(join(packageDir, 'index.js'), "require('ancestor-dependency')\n", 'utf8')
await writeFile(
join(externalPackageDir, 'package.json'),
JSON.stringify({ main: 'index.js' }),
'utf8'
)
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
/ancestor-dependency.*resolved outside/s
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('allows absent dependencies declared optional by their package', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
const artifactRoot = join(root, 'artifact')
const outDir = join(artifactRoot, 'out')
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
await mkdir(packageDir, { recursive: true })
await writeFile(
join(packageDir, 'package.json'),
JSON.stringify({
main: 'index.js',
peerDependencies: { 'optional-native': '*' },
peerDependenciesMeta: { 'optional-native': { optional: true } }
}),
'utf8'
)
await writeFile(
join(packageDir, 'index.js'),
"try { require('optional-native') } catch {}\n",
'utf8'
)
expect(collectRuntimeClosure(outDir, artifactRoot)).toHaveLength(3)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('rejects optional dependencies resolved only outside the artifact', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
const artifactRoot = join(root, 'artifact')
const outDir = join(artifactRoot, 'out')
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
const externalPackageDir = join(root, 'node_modules', 'optional-native')
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
await mkdir(packageDir, { recursive: true })
await mkdir(externalPackageDir, { recursive: true })
await writeFile(
join(packageDir, 'package.json'),
JSON.stringify({
main: 'index.js',
optionalDependencies: { 'optional-native': '*' }
}),
'utf8'
)
await writeFile(join(packageDir, 'index.js'), "require('optional-native')\n", 'utf8')
await writeFile(
join(externalPackageDir, 'package.json'),
JSON.stringify({ main: 'index.js' }),
'utf8'
)
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
/optional-native.*resolved outside/s
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,161 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const readWorkflow = (relativePath) => parse(readFileSync(join(projectDir, relativePath), 'utf8'))
describe('Windows signing workflow contract', () => {
it('preflights SignPath module install before Windows signing side effects', () => {
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const installStepIndexes = stepNames.flatMap((name, index) =>
name === 'Install SignPath PowerShell module' ? [index] : []
)
const buildIndex = stepNames.indexOf('Build Windows release artifacts')
const verifyNodePtyIndex = stepNames.indexOf('Verify Windows node-pty ConPTY runtime')
const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath')
const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath')
expect(verifyNodePtyIndex).toBe(buildIndex + 1)
expect(installStepIndexes).toEqual([verifyNodePtyIndex + 1])
expect(installStepIndexes[0]).toBeLessThan(uploadIndex)
expect(steps[verifyNodePtyIndex].run).toContain(
'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
)
expect(steps[verifyNodePtyIndex].run).toContain('conpty/conpty.dll')
const uploadThroughDownloadScript = steps
.slice(uploadIndex, downloadIndex + 1)
.map((step) => step.run ?? '')
.join('\n')
expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath')
const installStep = steps[installStepIndexes[0]]
expect(installStep.if).toBe("matrix.platform == 'win'")
expect(installStep.uses).toBe('./.github/actions/install-signpath-module')
expect(installStep.run).toBeUndefined()
const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml')
const actionStep = installAction.runs.steps[0]
const installRun = actionStep.run
const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map(
([, seconds]) => seconds
)
expect(installAction.runs.using).toBe('composite')
expect(actionStep.shell).toBe('pwsh')
expect(installRun).toContain(
'if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue))'
)
expect(installRun).toContain('Register-PSRepository -Default -InstallationPolicy Trusted')
expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted')
expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/)
expect(installRun).toContain(
"$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'"
)
expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/)
expect(sleepSeconds).toContain('15')
expect(sleepSeconds).toContain('30')
expect(installRun).toContain(
'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop'
)
expect(installRun).toContain('Import-Module SignPath -ErrorAction Stop')
expect(installRun).toContain(
'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop'
)
expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force')
expect(installRun).not.toContain('SignPath*')
expect(installRun).not.toMatch(/throw\s+\$_/)
})
it('falls back to a hash-pinned SignPath nupkg when the gallery API is down', () => {
const installAction = readWorkflow('.github/actions/install-signpath-module/action.yml')
const installRun = installAction.runs.steps[0].run
// Why: the gallery API 403s during Azure Front Door incidents while its CDN
// stays up, so a pinned nupkg is the fallback. The hash pin is the only
// integrity check on that route — losing it would let any payload install.
const { 'fallback-version': version, 'fallback-sha256': sha256 } = installAction.inputs
expect(version.default).toMatch(/^4\.\d+\.\d+$/)
expect(sha256.default).toMatch(/^[0-9a-f]{64}$/)
expect(installRun).toContain('Get-FileHash -LiteralPath $nupkg -Algorithm SHA256')
expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()')
expect(installRun).toContain('throw "SHA-256 mismatch for $source')
expect(installRun).toContain(
'https://cdn.powershellgallery.com/packages/signpath.$version.nupkg'
)
// The module only resolves by name when the folder matches its ModuleVersion.
expect(installRun).toContain(
'$versionRoot = Join-Path -Path $signPathModulePath -ChildPath $version'
)
// The fallback only runs after the gallery route is exhausted, and still
// fails the job when neither route produced a usable module.
expect(installRun.indexOf('$installed = $true')).toBeLessThan(
installRun.indexOf('if (-not $installed)')
)
expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module')
})
it('shares one SignPath module install path between release and rehearsal', () => {
const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml')
const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name)
const installIndex = stepNames.indexOf('Install SignPath PowerShell module')
// Why: the rehearsal exists to prove the real signing flow, so it must
// install the module exactly the way the release job does.
expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].uses).toBe(
'./.github/actions/install-signpath-module'
)
expect(rehearsalWorkflow.jobs.rehearse.steps[installIndex].run).toBeUndefined()
expect(installIndex).toBeLessThan(
stepNames.indexOf('Download signed inner binaries from SignPath')
)
})
it('verifies Windows inner binary signatures fail-open before publishing', () => {
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures')
const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence')
const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
expect(outerVerifyIndex).toBeGreaterThan(-1)
expect(innerVerifyIndex).toBe(outerVerifyIndex + 1)
expect(evidenceIndex).toBe(innerVerifyIndex + 1)
expect(publishIndex).toBe(evidenceIndex + 1)
// Why fail-open: unsigned inner binaries must warn, not block, until the
// flow is proven on a real release (issue #7785). Flip this to 'true'
// together with the workflow env to make the gate required.
expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false')
// Why: every step in the inner-signing chain must be unable to fail the
// release — a SignPath outage or timeout falls through to today's
// unsigned-inner flow instead of blocking the cut.
const innerChainStepNames = [
'Stage unsigned inner PE files for signing',
'Upload unsigned inner binaries for SignPath',
'Submit inner binaries signing request',
'Notify Slack that inner-binary signing is waiting for approval',
'Download signed inner binaries from SignPath',
'Restore signed inner binaries into unpacked app',
'Replace cached elevate.exe with the signed copy',
'Rebuild NSIS installer from signed unpacked app'
]
for (const stepName of innerChainStepNames) {
const step = steps[stepNames.indexOf(stepName)]
expect(step, stepName).toBeDefined()
expect(step['continue-on-error'], stepName).toBe(true)
}
})
})
-2
View File
@@ -12,8 +12,6 @@
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/ipc/local-agent-install-dir-detection.ts",
"../src/main/ipc/tui-agent-detection-commands.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
+2
View File
@@ -11,6 +11,8 @@
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
"../src/main/ipc/worktree-metadata-merge.ts",
"../src/main/ipc/worktree-path-comparison.ts",
"../src/main/wsl-distro-list-output.ts",
"../src/main/wsl-distro-retry.ts",
"../src/main/wsl.ts"
],
"compilerOptions": {
+6 -3
View File
@@ -286,9 +286,12 @@ Four independent review lenses over the full diff; confirmed findings fixed:
Accepted gaps (reviewed, deliberately not addressed here): old version-namespaced
install dirs accrete across upgrades (~200KB each); an outdated running daemon
/p-translates the guest endpoint path until it restarts (hook scripts fall back to env
coords, which same-port binding keeps correct); `wslDistroCache` caches a transient
empty list for the app run (pre-existing semantics, now load-bearing for default-distro
resolution); default-distro resolution caches the first answer for the app run.
coords, which same-port binding keeps correct); default-distro resolution caches the
first non-empty answer for the app run.
`wslDistroCache` no longer caches a transient empty list for the app run — an empty
result is re-probed after a short window, so a distro registered mid-session is picked
up. `isWslAvailable` likewise re-probes instead of latching a failure.
## 2026-07-09 round-4 external adversarial review
+50 -24
View File
@@ -6,10 +6,17 @@ import tailwindcss from '@tailwindcss/vite'
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
import packageJson from './package.json' with { type: 'json' }
const BUNDLED_MAIN_DEPENDENCIES = new Set(['@xterm/headless', '@xterm/addon-serialize'])
const BUNDLED_MAIN_DEPENDENCIES = new Set([
'@xterm/headless',
'@xterm/addon-serialize',
// Why: Windows NSIS deploys app.asar before external resources; bootstrap must
// not race the later resources/node_modules copy.
'zod'
])
const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter(
(dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency)
)
const BOOTSTRAP_FATAL_EXIT_GUARD_KEY = '__ORCA_BOOTSTRAP_FATAL_EXIT_GUARD__'
function isExternalMainModule(source: string): boolean {
if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) {
@@ -164,19 +171,47 @@ function createStartupDiagnosticsBanner(chunkName: string): string {
`
}
function createStartupDiagnosticsBootstrapPlugin() {
export function createBootstrapFatalExitBanner(): string {
// Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement.
return `
;(() => {
const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)}
if (typeof globalThis[guardKey] === 'function') {
return
}
let exitScheduled = false
const exitAfterBootstrapFailure = () => {
if (exitScheduled) {
return
}
exitScheduled = true
process.exitCode = 1
setImmediate(() => process.exit(1))
}
globalThis[guardKey] = () => {
process.off('uncaughtException', exitAfterBootstrapFailure)
delete globalThis[guardKey]
}
process.once('uncaughtException', exitAfterBootstrapFailure)
})();
`
}
function createMainBootstrapPlugin() {
return {
name: 'orca-startup-diagnostics-bootstrap',
name: 'orca-main-bootstrap',
generateBundle(_options, bundle) {
const mainChunk = bundle['index.js']
if (!mainChunk || mainChunk.type !== 'chunk') {
return
}
// Why: source-level startup diagnostics run after Rollup's generated
// prelude and require() list. Mutate the final emitted chunk so macOS
// launch failures can identify the earliest JS boundary reached.
mainChunk.code = createStartupDiagnosticsBanner(mainChunk.fileName) + mainChunk.code
// Why: source guards and diagnostics run after Rollup's generated require
// prelude, too late to handle a missing bootstrap dependency.
mainChunk.code =
createBootstrapFatalExitBanner() +
createStartupDiagnosticsBanner(mainChunk.fileName) +
mainChunk.code
}
}
}
@@ -186,10 +221,10 @@ export const electronViteConfig: UserConfig = {
build: {
// Why: daemon-entry.js is asar-unpacked so child_process.fork() can
// execute it from disk. Node's module resolution from the unpacked
// directory cannot reach into app.asar, so pure-JS dependencies used
// by the daemon must be bundled rather than externalized.
// directory cannot reach into app.asar; startup-critical pure JS must
// also survive a partially copied Windows resources tree.
externalizeDeps: {
exclude: ['@xterm/headless', '@xterm/addon-serialize']
exclude: [...BUNDLED_MAIN_DEPENDENCIES]
},
rollupOptions: {
// Why: native dependencies must resolve from packaged node_modules,
@@ -208,9 +243,8 @@ export const electronViteConfig: UserConfig = {
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
// Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked
// main thread (macOS 26 AppKit scene-update deadlock) and can record
// the stall for the next launch to report.
// Why: a worker thread survives the macOS 26 AppKit main-thread deadlock
// without paying for another Electron process.
'main-thread-hang-watchdog-entry': resolve(
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
),
@@ -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
+69 -65
View File
@@ -114,7 +114,7 @@ import {
loadTerminalAccessoryLayout
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
import {
clearTerminalLiveInputFocusTimer,
focusTerminalLiveInputTarget,
@@ -127,6 +127,11 @@ import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-sen
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import { resolveMobileTerminalInputGate } from '../../../../src/terminal/terminal-input-connection-gate'
import {
buildTerminalSendParams,
TERMINAL_INPUT_SEND_OPTIONS
} from '../../../../src/terminal/terminal-send-request'
import {
getTerminalCommandKeyboardType,
getTerminalLiveInputKeyboardType
@@ -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) {
+123 -1
View File
@@ -14,6 +14,7 @@ const secureStoreMock = vi.hoisted(() => ({
}))
const scheduleCleanupMock = vi.hoisted(() => vi.fn())
const platformMock = vi.hoisted(() => ({ OS: 'ios' }))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: asyncStorageMock
@@ -25,7 +26,7 @@ vi.mock('expo-secure-store', () => ({
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' }
Platform: platformMock
}))
vi.mock('./host-credential-cleanup', () => ({
@@ -70,6 +71,7 @@ describe('host-store list mutations', () => {
beforeEach(() => {
vi.clearAllMocks()
resetHostStoreForTests()
platformMock.OS = 'ios'
resetMobileRelayHostOverlayStoreForTests()
scheduleCleanupMock.mockReset()
scheduleCleanupMock.mockResolvedValue(undefined)
@@ -179,6 +181,25 @@ describe('host-store list mutations', () => {
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
})
it('keeps the normal iOS save on the existing default keychain service', async () => {
await saveHost({
id: 'host-new',
name: 'New Host',
endpoint: 'ws://127.0.0.1:3',
publicKeyB64: 'key-new',
deviceToken: 'new-token',
lastConnected: 0
})
expect(secureStoreMock.setItemAsync).toHaveBeenCalledWith(
'orca.host-token.host-new',
'new-token',
{
keychainAccessible: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY'
}
)
})
it('commits the removal when credential cleanup scheduling rejects', async () => {
scheduleCleanupMock.mockRejectedValue(new Error('intent storage unavailable'))
@@ -328,3 +349,104 @@ describe('host-store list mutations', () => {
expect(storedHostsRaw).toBe('{')
})
})
describe('host-store pairing save after an Android encryption rejection', () => {
const NEW_HOST = {
id: 'host-1782629088232',
name: 'Host 1',
endpoint: 'ws://192.168.0.56:6769',
publicKeyB64: 'desktop-key',
lastConnected: 0,
deviceToken: 'device-token'
}
// Why: the verbatim Android rejection from #6600 — expo maps a null-message GeneralSecurityException to this.
const ENCRYPT_REJECTION = new Error(
"Could not encrypt the value for key 'orca.host-token.host-1782629088232' under keychain 'key_v1'. Caused by: unknown"
)
const GENERATION_KEY = 'orca:pairing-keychain-generation'
let storedHostsRaw: string
let storedGenerationRaw: string | null
beforeEach(() => {
vi.clearAllMocks()
resetHostStoreForTests()
platformMock.OS = 'android'
resetMobileRelayHostOverlayStoreForTests()
scheduleCleanupMock.mockReset()
scheduleCleanupMock.mockResolvedValue(undefined)
storedHostsRaw = '[]'
storedGenerationRaw = null
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === HOSTS_STORAGE_KEY) {
return storedHostsRaw
}
// Why: the generation record is durable on device; a forgetful mock would fake a broken read path.
return key === GENERATION_KEY ? storedGenerationRaw : null
})
asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => {
if (key === HOSTS_STORAGE_KEY) {
storedHostsRaw = raw
} else if (key === GENERATION_KEY) {
storedGenerationRaw = raw
}
})
secureStoreMock.deleteItemAsync.mockResolvedValue(undefined)
secureStoreMock.getItemAsync.mockResolvedValue(null)
})
it('saves the host when the reported Android failure is alias-local (#6600)', async () => {
const written = new Map<string | undefined, string>()
// Why: simulate the unverified alias-local case; no affected physical device was available.
secureStoreMock.setItemAsync.mockImplementation(
async (_key: string, value: string, options?: { keychainService?: string }) => {
if (options?.keychainService === undefined) {
throw ENCRYPT_REJECTION
}
written.set(options.keychainService, value)
}
)
await expect(saveHost(NEW_HOST)).resolves.toBeUndefined()
expect(written.get('orca.pairing.v1')).toBe('device-token')
expect(JSON.parse(storedHostsRaw)).toEqual([
{
id: NEW_HOST.id,
name: NEW_HOST.name,
endpoint: NEW_HOST.endpoint,
publicKeyB64: NEW_HOST.publicKeyB64,
lastConnected: NEW_HOST.lastConnected
}
])
})
it('still surfaces the failure when no keystore alias can accept the token', async () => {
secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION)
await expect(saveHost(NEW_HOST)).rejects.toBe(ENCRYPT_REJECTION)
})
it('serves the rotated token to loadHosts so the saved host survives a relaunch', async () => {
const written = new Map<string | undefined, string>()
secureStoreMock.setItemAsync.mockImplementation(
async (_key: string, value: string, options?: { keychainService?: string }) => {
if (options?.keychainService === undefined) {
throw ENCRYPT_REJECTION
}
written.set(options.keychainService, value)
}
)
await saveHost(NEW_HOST)
// Why: a fresh process has no token cache, so the host list has to come back off the rotated alias.
resetHostStoreForTests()
secureStoreMock.getItemAsync.mockImplementation(
async (_key: string, options?: { keychainService?: string }) =>
written.get(options?.keychainService) ?? null
)
const hosts = await loadHosts()
expect(hosts).toHaveLength(1)
expect(hosts[0]!.deviceToken).toBe('device-token')
})
})
+10 -10
View File
@@ -1,5 +1,4 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import {
HostProfileSchema,
@@ -8,6 +7,12 @@ import {
type StoredHostProfile
} from './types'
import { getNextHostNameFromHosts } from './host-names'
import {
deletePairingKeychainItem,
readPairingKeychainItem,
resetPairingKeychainForTests,
writePairingKeychainItem
} from './pairing-keychain'
import {
retryPendingHostCredentialCleanups,
scheduleHostCredentialCleanup
@@ -27,12 +32,6 @@ const STORAGE_KEY = 'orca:hosts'
const TOKEN_KEY_PREFIX = 'orca.host-token.'
const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:'
// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off iCloud Keychain and out of backup restores onto another device.
// Reads/writes stay silent (no biometric prompt) because we don't request access control flags.
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
function tokenKey(hostId: string): string {
return `${TOKEN_KEY_PREFIX}${hostId}`
}
@@ -46,7 +45,7 @@ async function readDeviceToken(hostId: string): Promise<string | null> {
if (Platform.OS === 'web') {
return AsyncStorage.getItem(webTokenKey(hostId))
}
return SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
return readPairingKeychainItem(tokenKey(hostId))
}
async function writeDeviceToken(hostId: string, token: string): Promise<void> {
@@ -54,7 +53,7 @@ async function writeDeviceToken(hostId: string, token: string): Promise<void> {
await AsyncStorage.setItem(webTokenKey(hostId), token)
return
}
await SecureStore.setItemAsync(tokenKey(hostId), token, KEYCHAIN_OPTIONS)
await writePairingKeychainItem(tokenKey(hostId), token)
}
async function deleteDeviceToken(hostId: string): Promise<void> {
@@ -62,7 +61,7 @@ async function deleteDeviceToken(hostId: string): Promise<void> {
await AsyncStorage.removeItem(webTokenKey(hostId))
return
}
await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
await deletePairingKeychainItem(tokenKey(hostId))
}
async function deleteHostCredentials(hostId: string): Promise<void> {
@@ -342,4 +341,5 @@ export function resetHostStoreForTests(): void {
hostListMutation = Promise.resolve()
tokenCache.clear()
inflightLoad = null
resetPairingKeychainForTests()
}
@@ -1,5 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn()
}))
const secureStore = vi.hoisted(() => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
@@ -7,6 +11,7 @@ const secureStore = vi.hoisted(() => ({
}))
const platform = vi.hoisted(() => ({ OS: 'ios' }))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
vi.mock('expo-secure-store', () => ({
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
...secureStore
@@ -20,6 +25,7 @@ import {
writeMobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import { resetPairingKeychainForTests } from './pairing-keychain'
const journal = {
metadata: {
@@ -62,8 +68,11 @@ describe('mobile relay credential bundle', () => {
beforeEach(() => {
vi.clearAllMocks()
resetPairingKeychainForTests()
platform.OS = 'ios'
stored = null
asyncStorage.getItem.mockResolvedValue(null)
asyncStorage.setItem.mockResolvedValue(undefined)
secureStore.getItemAsync.mockImplementation(async () => stored)
secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => {
stored = value
@@ -1,8 +1,12 @@
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import { z } from 'zod'
import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract'
import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal'
import {
deletePairingKeychainItem,
readPairingKeychainItem,
writePairingKeychainItem
} from './pairing-keychain'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
const ResumeCredentialSchema = z
@@ -38,10 +42,6 @@ export const MobileRelayCredentialBundleSchema = z
export type MobileRelayCredentialBundle = z.infer<typeof MobileRelayCredentialBundleSchema>
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
function credentialKey(hostId: string): string {
return `orca.mobile-relay.credentials.${hostId}`
}
@@ -74,7 +74,7 @@ export async function readMobileRelayCredentialBundle(
hostId: string
): Promise<MobileRelayCredentialBundle | null> {
requireNativeSecretStore()
const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
const raw = await readPairingKeychainItem(credentialKey(hostId))
if (raw === null) {
return null
}
@@ -91,18 +91,14 @@ export async function writeMobileRelayCredentialBundle(
): Promise<void> {
requireNativeSecretStore()
const validated = MobileRelayCredentialBundleSchema.parse(bundle)
await SecureStore.setItemAsync(
credentialKey(validated.hostId),
JSON.stringify(validated),
KEYCHAIN_OPTIONS
)
await writePairingKeychainItem(credentialKey(validated.hostId), JSON.stringify(validated))
}
export async function deleteMobileRelayCredentialBundle(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
await deletePairingKeychainItem(credentialKey(hostId))
}
function requireNativeSecretStore(): void {
@@ -1,7 +1,11 @@
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import { z } from 'zod'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import {
deletePairingKeychainItem,
readPairingKeychainItem,
writePairingKeychainItem
} from './pairing-keychain'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
@@ -17,10 +21,6 @@ export const MobileRelayDirectUpgradeJournalSchema = z
export type MobileRelayDirectUpgradeJournal = z.infer<typeof MobileRelayDirectUpgradeJournalSchema>
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
function journalKey(hostId: string): string {
return `orca.mobile-relay.direct-upgrade.${hostId}`
}
@@ -43,7 +43,7 @@ export async function readMobileRelayDirectUpgradeJournal(
hostId: string
): Promise<MobileRelayDirectUpgradeJournal | null> {
requireNativeSecretStore()
const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS)
const raw = await readPairingKeychainItem(journalKey(hostId))
if (!raw) {
return null
}
@@ -60,18 +60,14 @@ export async function writeMobileRelayDirectUpgradeJournal(
): Promise<void> {
requireNativeSecretStore()
const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal)
await SecureStore.setItemAsync(
journalKey(parsed.hostId),
JSON.stringify(parsed),
KEYCHAIN_OPTIONS
)
await writePairingKeychainItem(journalKey(parsed.hostId), JSON.stringify(parsed))
}
export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS)
await deletePairingKeychainItem(journalKey(hostId))
}
function encodeBase64Url(value: Uint8Array): string {
@@ -31,6 +31,8 @@ import {
import type { PairingOffer } from './types'
const now = Date.UTC(2026, 6, 13)
const GENERATION_KEY = 'orca:pairing-keychain-generation'
const JOURNAL_PRESENCE_KEY = 'orca:pairing-keychain-presence:orca.mobile-relay.pairing-journal.v1'
const offer = {
v: 2,
endpoint: 'ws://192.168.1.10:6768',
@@ -51,6 +53,8 @@ const offer = {
describe('mobile relay pairing journal store', () => {
let metadataRaw: string | null
let secretRaw: string | null
let generationRaw: string | null
let presenceRaw: string | null
beforeEach(() => {
vi.clearAllMocks()
@@ -58,12 +62,32 @@ describe('mobile relay pairing journal store', () => {
platform.OS = 'ios'
metadataRaw = null
secretRaw = null
asyncStorage.getItem.mockImplementation(async () => metadataRaw)
asyncStorage.setItem.mockImplementation(async (_key: string, value: string) => {
metadataRaw = value
generationRaw = null
presenceRaw = null
asyncStorage.getItem.mockImplementation(async (key: string) => {
if (key === GENERATION_KEY) {
return generationRaw
}
if (key === JOURNAL_PRESENCE_KEY) {
return presenceRaw
}
return metadataRaw
})
asyncStorage.removeItem.mockImplementation(async () => {
metadataRaw = null
asyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
if (key === GENERATION_KEY) {
generationRaw = value
} else if (key === JOURNAL_PRESENCE_KEY) {
presenceRaw = value
} else {
metadataRaw = value
}
})
asyncStorage.removeItem.mockImplementation(async (key: string) => {
if (key === JOURNAL_PRESENCE_KEY) {
presenceRaw = null
} else {
metadataRaw = null
}
})
secureStore.getItemAsync.mockImplementation(async () => secretRaw)
secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => {
@@ -94,6 +118,36 @@ describe('mobile relay pairing journal store', () => {
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal)
})
it('retries the journal under a distinct alias before relay pairing connects (#6600)', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
now,
randomBytes: (length) => new Uint8Array(length).fill(length)
})
secureStore.setItemAsync.mockImplementation(
async (_key: string, value: string, options?: { keychainService?: string }) => {
if (options?.keychainService === undefined) {
throw new Error(
"Could not encrypt the value for key 'orca.mobile-relay.pairing-journal.v1' under keychain 'key_v1'. Caused by: unknown"
)
}
secretRaw = value
}
)
platform.OS = 'android'
await expect(saveMobileRelayPairingJournal(journal)).resolves.toBeUndefined()
expect(generationRaw).toBe('1')
expect(secureStore.setItemAsync).toHaveBeenLastCalledWith(
'orca.mobile-relay.pairing-journal.v1',
expect.any(String),
expect.objectContaining({ keychainService: 'orca.pairing.v1' })
)
})
it('records a provisional winner only for the active journal identity', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
@@ -223,7 +277,7 @@ describe('mobile relay pairing journal store', () => {
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable'))
secureStore.setItemAsync.mockRejectedValue(new Error('keychain unavailable'))
await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/keychain/)
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
@@ -1,5 +1,4 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import {
MobileRelayPairingJournalMetadataSchema,
@@ -7,12 +6,15 @@ import {
type MobileRelayPairingJournal,
type MobileRelayPairingJournalMetadata
} from './mobile-relay-pairing-journal'
import {
deletePairingKeychainItem,
readPairingKeychainItem,
resetPairingKeychainForTests,
writePairingKeychainItem
} from './pairing-keychain'
const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1'
const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1'
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
let journalMutation: Promise<void> = Promise.resolve()
export async function saveMobileRelayPairingJournal(
@@ -39,7 +41,7 @@ export async function saveMobileRelayPairingJournal(
// Why: metadata-first makes a crash before the keychain write recover as
// an incomplete journal, never as an untracked bearer secret.
await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata))
await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, JSON.stringify(secrets), KEYCHAIN_OPTIONS)
await writePairingKeychainItem(JOURNAL_SECRET_KEY, JSON.stringify(secrets))
})
journalMutation = mutation.catch(() => {})
return mutation
@@ -50,7 +52,7 @@ export async function loadMobileRelayPairingJournal(): Promise<MobileRelayPairin
const load = journalMutation.then(async () => {
const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
if (rawMetadata === null) {
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
await deletePairingKeychainItem(JOURNAL_SECRET_KEY).catch(() => {})
return null
}
const metadata = parseMetadata(rawMetadata)
@@ -58,7 +60,7 @@ export async function loadMobileRelayPairingJournal(): Promise<MobileRelayPairin
await removeIncompleteJournal()
return null
}
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
const rawSecrets = await readPairingKeychainItem(JOURNAL_SECRET_KEY)
if (rawSecrets === null) {
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
return null
@@ -83,7 +85,7 @@ async function removeIncompleteJournal(): Promise<void> {
// Why: metadata is the discoverable cleanup pointer; remove it before the
// native secret so a second crash can only leave a self-cleaning orphan.
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
await deletePairingKeychainItem(JOURNAL_SECRET_KEY).catch(() => {})
}
export async function updateMobileRelayPairingJournal(
@@ -114,7 +116,7 @@ export async function clearMobileRelayPairingJournal(journalId: string): Promise
throw new Error('stale mobile relay pairing journal')
}
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
await deletePairingKeychainItem(JOURNAL_SECRET_KEY)
})
journalMutation = mutation.catch(() => {})
return mutation
@@ -147,4 +149,5 @@ function requireNativeSecretStore(): void {
/** Test-only: drain the module mutation chain between cases. */
export function resetMobileRelayPairingJournalStoreForTests(): void {
journalMutation = Promise.resolve()
resetPairingKeychainForTests()
}
@@ -0,0 +1,403 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}))
const secureStoreMock = vi.hoisted(() => ({
deleteItemAsync: vi.fn(),
getItemAsync: vi.fn(),
setItemAsync: vi.fn()
}))
const platformMock = vi.hoisted(() => ({ OS: 'android' }))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock }))
vi.mock('expo-secure-store', () => ({
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
...secureStoreMock
}))
vi.mock('react-native', () => ({ Platform: platformMock }))
import {
deletePairingKeychainItem,
readPairingKeychainItem,
resetPairingKeychainForTests,
writePairingKeychainItem
} from './pairing-keychain'
const GENERATION_KEY = 'orca:pairing-keychain-generation'
const TOKEN_KEY = 'orca.host-token.host-1782629088232'
const TOKEN_PRESENCE_KEY = `orca:pairing-keychain-presence:${TOKEN_KEY}`
// Why: the exact Android failure from #6600 — expo maps a null-message GeneralSecurityException to this.
const ENCRYPT_REJECTION = new Error(
`Could not encrypt the value for key '${TOKEN_KEY}' under keychain 'key_v1'. Caused by: unknown`
)
type Options = { keychainService?: string } | undefined
function serviceOf(options: Options): string | undefined {
return options?.keychainService
}
describe('pairing keychain', () => {
let generationRecord: string | null
beforeEach(() => {
vi.clearAllMocks()
resetPairingKeychainForTests()
platformMock.OS = 'android'
generationRecord = null
asyncStorageMock.getItem.mockImplementation(async (key: string) =>
key === GENERATION_KEY ? generationRecord : null
)
asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => {
if (key === GENERATION_KEY) {
generationRecord = raw
}
})
secureStoreMock.setItemAsync.mockResolvedValue(undefined)
secureStoreMock.deleteItemAsync.mockResolvedValue(undefined)
secureStoreMock.getItemAsync.mockResolvedValue(null)
})
it('writes under the default keychain service so existing installs keep their tokens', async () => {
await writePairingKeychainItem(TOKEN_KEY, 'token')
expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1)
const [key, value, options] = secureStoreMock.setItemAsync.mock.calls[0]!
expect(key).toBe(TOKEN_KEY)
expect(value).toBe('token')
// Why: passing any keychainService would change the keystore alias and orphan every already-stored token.
expect(serviceOf(options as Options)).toBeUndefined()
expect(generationRecord).toBeNull()
})
it('recovers when the reported Android encryption failure is alias-local', async () => {
// Why: simulate the unverified alias-local case; no affected physical device was available.
secureStoreMock.setItemAsync.mockImplementation(
async (_k: string, _v: string, options: Options) => {
if (serviceOf(options) === undefined) {
throw ENCRYPT_REJECTION
}
}
)
await writePairingKeychainItem(TOKEN_KEY, 'token')
const rotated = secureStoreMock.setItemAsync.mock.calls.at(-1)!
expect(serviceOf(rotated[2] as Options)).toBe('orca.pairing.v1')
expect(rotated[1]).toBe('token')
expect(generationRecord).toBe('1')
})
it('surfaces the original error when a rotated write also fails', async () => {
secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION)
await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION)
expect(generationRecord).toBe('1:pending')
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
})
it('retries an unconfirmed alias without consuming another generation', async () => {
generationRecord = '1:pending'
secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION)
await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION)
expect(generationRecord).toBe('1:pending')
expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1)
expect(serviceOf(secureStoreMock.setItemAsync.mock.calls[0]![2] as Options)).toBe(
'orca.pairing.v1'
)
})
it('confirms an unconfirmed alias after its first successful write', async () => {
generationRecord = '1:pending'
await writePairingKeychainItem(TOKEN_KEY, 'token')
expect(generationRecord).toBe('1')
expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1)
expect(serviceOf(secureStoreMock.setItemAsync.mock.calls[0]![2] as Options)).toBe(
'orca.pairing.v1'
)
})
it('does not rotate on an iOS keychain failure', async () => {
platformMock.OS = 'ios'
secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION)
await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION)
expect(generationRecord).toBeNull()
expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1)
})
it('never stores a token under a generation it could not durably record', async () => {
// Why: reads only walk back from the recorded generation, so a token written under an
// unrecorded one is silently unreachable after a relaunch and the host vanishes.
asyncStorageMock.setItem.mockImplementation(async (key: string) => {
if (key === GENERATION_KEY) {
throw new Error('storage full')
}
})
secureStoreMock.setItemAsync.mockImplementation(
async (_k: string, _v: string, options: Options) => {
if (serviceOf(options) === undefined) {
throw ENCRYPT_REJECTION
}
}
)
await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION)
const rotatedWrites = secureStoreMock.setItemAsync.mock.calls.filter(
(call) => serviceOf(call[2] as Options) !== undefined
)
expect(rotatedWrites).toHaveLength(0)
expect(generationRecord).toBeNull()
})
it('records the rotation before storing the token so a relaunch can still find it', async () => {
const order: string[] = []
asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => {
if (key === GENERATION_KEY) {
generationRecord = raw
order.push(`record:${raw}`)
}
})
secureStoreMock.setItemAsync.mockImplementation(
async (_k: string, _v: string, options: Options) => {
const service = serviceOf(options)
if (service === undefined) {
throw ENCRYPT_REJECTION
}
order.push(`store:${service}`)
}
)
await writePairingKeychainItem(TOKEN_KEY, 'token')
expect(order).toEqual(['record:1:pending', 'store:orca.pairing.v1', 'record:1'])
})
it('keeps a successful rotated write reachable when confirmation storage fails', async () => {
asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => {
if (key !== GENERATION_KEY) {
return
}
if (raw === '1') {
throw new Error('storage unavailable')
}
generationRecord = raw
})
secureStoreMock.setItemAsync.mockImplementation(
async (_k: string, _v: string, options: Options) => {
if (serviceOf(options) === undefined) {
throw ENCRYPT_REJECTION
}
}
)
await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION)
expect(generationRecord).toBe('1:pending')
secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) =>
serviceOf(options) === 'orca.pairing.v1' ? 'token' : null
)
await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('token')
})
it('reads through the rotated service once a rotation has been committed', async () => {
generationRecord = '1'
secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) =>
serviceOf(options) === 'orca.pairing.v1' ? 'rotated-token' : null
)
await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token')
})
it('falls back to a retired service so rotation does not orphan a still-readable token', async () => {
generationRecord = '2'
secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) =>
serviceOf(options) === undefined ? 'legacy-token' : null
)
await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('legacy-token')
// Why: probes must walk v2 -> v1 -> default rather than stopping at the current generation.
expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(3)
})
it('does not return a stale older value when the current alias throws', async () => {
generationRecord = '1'
const currentError = new Error('Could not decrypt the value')
secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => {
if (serviceOf(options) === 'orca.pairing.v1') {
throw currentError
}
return 'legacy-token'
})
await expect(readPairingKeychainItem(TOKEN_KEY)).rejects.toBe(currentError)
expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1)
})
it('does not return a stale older value when a recorded current item decrypts as null', async () => {
generationRecord = '1'
let presenceRecord: string | null = null
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === GENERATION_KEY) {
return generationRecord
}
return key === TOKEN_PRESENCE_KEY ? presenceRecord : null
})
asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => {
if (key === GENERATION_KEY) {
generationRecord = raw
}
if (key === TOKEN_PRESENCE_KEY) {
presenceRecord = raw
}
})
await writePairingKeychainItem(TOKEN_KEY, 'rotated-token')
expect(presenceRecord).toBe('1')
expect(asyncStorageMock.setItem.mock.invocationCallOrder[0]).toBeLessThan(
secureStoreMock.setItemAsync.mock.invocationCallOrder[0]!
)
resetPairingKeychainForTests()
secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) =>
serviceOf(options) === undefined ? 'legacy-token' : null
)
await expect(readPairingKeychainItem(TOKEN_KEY)).rejects.toThrow(/recorded generation/)
expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1)
})
it('reads an older value while a newly recorded alias has no item yet', async () => {
generationRecord = '1:pending'
secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) =>
serviceOf(options) === undefined ? 'legacy-token' : null
)
await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('legacy-token')
})
it('probes every bounded service when the generation record is temporarily unreadable', async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === GENERATION_KEY) {
throw new Error('storage unavailable')
}
return null
})
secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) =>
serviceOf(options) === 'orca.pairing.v2' ? 'rotated-token' : null
)
await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token')
expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(7)
})
it('does not rotate from a guessed generation when the record is unreadable', async () => {
const storageError = new Error('storage unavailable')
asyncStorageMock.getItem.mockRejectedValueOnce(storageError)
await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(storageError)
expect(asyncStorageMock.setItem).not.toHaveBeenCalled()
expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled()
})
it('deletes the token under every generation so rotation cannot strand a live credential', async () => {
generationRecord = '2'
await deletePairingKeychainItem(TOKEN_KEY)
const services = secureStoreMock.deleteItemAsync.mock.calls.map((call) =>
serviceOf(call[1] as Options)
)
expect(services).toEqual(['orca.pairing.v2', 'orca.pairing.v1', undefined])
expect(asyncStorageMock.removeItem).toHaveBeenCalledWith(TOKEN_PRESENCE_KEY)
})
it('reports a partial delete failure after attempting every generation', async () => {
generationRecord = '2'
const deleteError = new Error('delete failed')
secureStoreMock.deleteItemAsync.mockImplementation(async (_k: string, options: Options) => {
if (serviceOf(options) === 'orca.pairing.v2') {
throw deleteError
}
})
await expect(deletePairingKeychainItem(TOKEN_KEY)).rejects.toBe(deleteError)
expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledTimes(3)
expect(asyncStorageMock.removeItem).not.toHaveBeenCalled()
})
it('serializes writes that share the global generation', async () => {
let releaseFirst!: () => void
const firstWrite = new Promise<void>((resolve) => {
releaseFirst = resolve
})
secureStoreMock.setItemAsync.mockImplementationOnce(async () => firstWrite)
const first = writePairingKeychainItem(TOKEN_KEY, 'first')
const second = writePairingKeychainItem(`${TOKEN_KEY}.second`, 'second')
await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1))
releaseFirst()
await Promise.all([first, second])
expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(2)
})
it.each(['not-a-number', '', ' ', '0:pending'])(
'probes rotated services when generation record %j is malformed',
async (raw) => {
generationRecord = raw
secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) =>
serviceOf(options) === 'orca.pairing.v2' ? 'rotated-token' : null
)
await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token')
}
)
it.each(['not-a-number', '', ' ', '0:pending'])(
'refuses to write from malformed generation record %j',
async (raw) => {
generationRecord = raw
await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toThrow(
/generation record is invalid/
)
expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled()
}
)
it.each(['not-a-number', '', ' ', '0:pending'])(
'deletes every bounded service when generation record %j is malformed',
async (raw) => {
generationRecord = raw
await deletePairingKeychainItem(TOKEN_KEY)
expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledTimes(9)
}
)
it('stops rotating at the generation cap rather than probing unbounded services', async () => {
generationRecord = '8'
secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION)
await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION)
expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1)
expect(generationRecord).toBe('8')
})
})
+257
View File
@@ -0,0 +1,257 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps pairing credentials off iCloud Keychain and backup restores.
const BASE_OPTIONS: SecureStore.SecureStoreOptions = {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
}
const GENERATION_STORAGE_KEY = 'orca:pairing-keychain-generation'
const PRESENCE_STORAGE_PREFIX = 'orca:pairing-keychain-presence:'
const SERVICE_PREFIX = 'orca.pairing.v'
// Why: every generation adds one read probe per miss; bound pathological recovery cost.
const MAX_GENERATION = 8
/**
* Why: expo-secure-store shares one Android keystore alias across every
* unauthenticated item under a keychain service. The #6600 rejection proves
* encryption reached expo's null-message GeneralSecurityException branch; a
* fresh service selects a distinct alias and can recover an alias-local fault.
*/
function serviceForGeneration(generation: number): string | undefined {
// Why: generation 0 must keep expo's default service so existing credentials stay readable.
return generation <= 0 ? undefined : `${SERVICE_PREFIX}${generation}`
}
function optionsForGeneration(generation: number): SecureStore.SecureStoreOptions {
const keychainService = serviceForGeneration(generation)
return keychainService ? { ...BASE_OPTIONS, keychainService } : BASE_OPTIONS
}
type LoadedGeneration =
| { generation: number; pending: boolean; reliable: true }
| { generation: 0; pending: false; reliable: false; error: unknown }
type GenerationState = { generation: number; pending: boolean }
type PresenceChange = { storageKey: string; previousRaw: string | null }
let cachedGeneration: GenerationState | null = null
let keychainMutation: Promise<void> = Promise.resolve()
function parseGeneration(raw: string | null): LoadedGeneration {
if (raw === null) {
return { generation: 0, pending: false, reliable: true }
}
const pending = raw.endsWith(':pending')
const generationRaw = pending ? raw.slice(0, -':pending'.length) : raw
const parsed = Number(generationRaw)
if (
!Number.isInteger(parsed) ||
parsed < 0 ||
parsed > MAX_GENERATION ||
(pending && parsed === 0) ||
String(parsed) !== generationRaw
) {
return {
generation: 0,
pending: false,
reliable: false,
error: new Error('pairing keychain generation record is invalid')
}
}
return { generation: parsed, pending, reliable: true }
}
function parsePresenceGeneration(raw: string | null): number | null {
if (raw === null) {
return null
}
const parsed = Number(raw)
if (
!Number.isInteger(parsed) ||
parsed < 0 ||
parsed > MAX_GENERATION ||
String(parsed) !== raw
) {
throw new Error('pairing keychain presence record is invalid')
}
return parsed
}
function presenceStorageKey(key: string): string {
return `${PRESENCE_STORAGE_PREFIX}${key}`
}
async function loadPresenceGeneration(key: string): Promise<number | null> {
if (Platform.OS !== 'android') {
return null
}
return parsePresenceGeneration(await AsyncStorage.getItem(presenceStorageKey(key)))
}
async function loadGeneration(): Promise<LoadedGeneration> {
if (cachedGeneration !== null) {
return { ...cachedGeneration, reliable: true }
}
try {
const loaded = parseGeneration(await AsyncStorage.getItem(GENERATION_STORAGE_KEY))
if (loaded.reliable) {
cachedGeneration = { generation: loaded.generation, pending: loaded.pending }
}
return loaded
} catch (error) {
// Why: don't cache or rotate from a guess; a later read may recover the durable pointer.
return { generation: 0, pending: false, reliable: false, error }
}
}
// Why: reads only walk back from the recorded generation, so persist before writing under it.
async function commitGeneration(generation: number, pending: boolean): Promise<void> {
const raw = pending ? `${generation}:pending` : String(generation)
await AsyncStorage.setItem(GENERATION_STORAGE_KEY, raw)
cachedGeneration = { generation, pending }
}
function enqueueKeychainMutation(operation: () => Promise<void>): Promise<void> {
const mutation = keychainMutation.then(operation)
keychainMutation = mutation.catch(() => {})
return mutation
}
async function preparePresenceWrite(
key: string,
generation: number
): Promise<PresenceChange | null> {
if (Platform.OS !== 'android') {
return null
}
// Why: Expo Android returns null for both absent and undecryptable entries.
const storageKey = presenceStorageKey(key)
const previousRaw = await AsyncStorage.getItem(storageKey)
parsePresenceGeneration(previousRaw)
await AsyncStorage.setItem(storageKey, String(generation))
return { storageKey, previousRaw }
}
async function restorePresence(change: PresenceChange | null): Promise<void> {
if (!change) {
return
}
if (change.previousRaw === null) {
await AsyncStorage.removeItem(change.storageKey)
return
}
await AsyncStorage.setItem(change.storageKey, change.previousRaw)
}
async function setItemAtGeneration(key: string, value: string, generation: number): Promise<void> {
const presenceChange = await preparePresenceWrite(key, generation)
try {
await SecureStore.setItemAsync(key, value, optionsForGeneration(generation))
} catch (error) {
// Why: a failed rollback leaves reads fail-closed at the attempted generation.
await restorePresence(presenceChange).catch(() => {})
throw error
}
}
export async function readPairingKeychainItem(key: string): Promise<string | null> {
await keychainMutation
const presenceGeneration = await loadPresenceGeneration(key)
if (presenceGeneration !== null) {
const value = await SecureStore.getItemAsync(key, optionsForGeneration(presenceGeneration))
if (value === null) {
throw new Error('pairing keychain item is unavailable at its recorded generation')
}
return value
}
const loaded = await loadGeneration()
const firstCandidate = loaded.reliable ? loaded.generation : MAX_GENERATION
for (let candidate = firstCandidate; candidate >= 0; candidate -= 1) {
const value = await SecureStore.getItemAsync(key, optionsForGeneration(candidate))
if (value !== null) {
return value
}
}
return null
}
async function writePairingKeychainItemImpl(key: string, value: string): Promise<void> {
const loaded = await loadGeneration()
if (!loaded.reliable) {
throw loaded.error
}
const generation = loaded.generation
let firstError: unknown
try {
await setItemAtGeneration(key, value, generation)
if (loaded.pending) {
await commitGeneration(generation, false)
}
return
} catch (error) {
firstError = error
}
// Why: retry an unconfirmed alias instead of exhausting generations on a device-wide failure.
if (loaded.pending || !isAndroidEncryptionFailure(firstError)) {
throw firstError
}
const rotated = generation + 1
if (rotated > MAX_GENERATION) {
throw firstError
}
try {
await commitGeneration(rotated, true)
} catch {
throw firstError
}
try {
await setItemAtGeneration(key, value, rotated)
await commitGeneration(rotated, false)
} catch {
throw firstError
}
}
function isAndroidEncryptionFailure(error: unknown): boolean {
if (Platform.OS !== 'android' || !error || typeof error !== 'object') {
return false
}
const message = 'message' in error ? error.message : null
return typeof message === 'string' && message.includes('Could not encrypt the value for key')
}
export function writePairingKeychainItem(key: string, value: string): Promise<void> {
return enqueueKeychainMutation(() => writePairingKeychainItemImpl(key, value))
}
async function deletePairingKeychainItemImpl(key: string): Promise<void> {
const loaded = await loadGeneration()
const firstCandidate = loaded.reliable ? loaded.generation : MAX_GENERATION
let firstError: unknown
for (let candidate = firstCandidate; candidate >= 0; candidate -= 1) {
try {
await SecureStore.deleteItemAsync(key, optionsForGeneration(candidate))
} catch (error) {
firstError ??= error
}
}
if (firstError !== undefined) {
throw firstError
}
if (Platform.OS === 'android') {
await AsyncStorage.removeItem(presenceStorageKey(key))
}
}
export function deletePairingKeychainItem(key: string): Promise<void> {
return enqueueKeychainMutation(() => deletePairingKeychainItemImpl(key))
}
/** Test-only: drop cached generation and mutation state between cases. */
export function resetPairingKeychainForTests(): void {
cachedGeneration = null
keychainMutation = Promise.resolve()
}
@@ -0,0 +1,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)
}
})
})
+6
View File
@@ -54,6 +54,9 @@ export type SendRequestOptions = {
* against the post-connect clock, and squeezing them to the floor after a slow
* reconnect would fail sends that used to land. */
budgetSpansConnect?: boolean
/** Reject immediately when not connected — a send parked in the connect wait
* replays stale terminal bytes into the PTY after reconnect. */
failWhenDisconnected?: boolean
}
type SubscribeOptions = {
@@ -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? {
@@ -0,0 +1,50 @@
public struct AgentSessionConnectionID: Hashable, Sendable {
public let rawValue: UInt64
public init(rawValue: UInt64) {
self.rawValue = rawValue
}
}
public enum AgentSessionRegistration: Sendable {
case rejected
case claimed
case retained
}
public struct AgentSessionOwnership: Sendable {
private var authenticatedConnections: Set<AgentSessionConnectionID> = []
private var wasClaimed = false
private var sessionClosed = false
public init() {}
public mutating func registerConnection(
_ connection: AgentSessionConnectionID,
authenticated: Bool
) -> AgentSessionRegistration {
guard authenticated, !sessionClosed else { return .rejected }
let inserted = authenticatedConnections.insert(connection).inserted
guard inserted else { return .rejected }
guard !wasClaimed else { return .retained }
wasClaimed = true
return .claimed
}
public mutating func disconnect(_ connection: AgentSessionConnectionID) -> Bool {
guard authenticatedConnections.remove(connection) != nil else { return false }
guard wasClaimed, authenticatedConnections.isEmpty else { return false }
sessionClosed = true
return true
}
}
public func isAuthenticatedAgentSession(
expectedToken: String?,
requestToken: String?,
authorizedPeer: Bool
) -> Bool {
guard authorizedPeer else { return false }
guard let expectedToken else { return true }
return requestToken == expectedToken
}
@@ -0,0 +1,182 @@
import Darwin
import Dispatch
import Foundation
public final class AuthenticatedConnectionHangupMonitor: @unchecked Sendable {
typealias RegisterEvents = @Sendable (
Int32,
UnsafePointer<kevent>?,
Int32
) -> POSIXErrorCode?
typealias CloseDescriptor = @Sendable (Int32) -> Void
private static let cancelEventIdentifier: UInt = 1
private let closeDescriptor: CloseDescriptor
private let eventQueue: DispatchQueue
private let eventQueueDescriptor: Int32
private let onEvent: @Sendable () -> Void
private let stateLock = NSLock()
private let onHangup: @Sendable () -> Void
private var isCancelled = false
private var didReportHangup = false
private var isFinished = false
private var isStarted = false
public convenience init(
fileDescriptor: Int32,
queue: DispatchQueue = DispatchQueue(
label: "com.stablyai.orca.computer-use-owner-hangup"
),
onEvent: @escaping @Sendable () -> Void = {},
onHangup: @escaping @Sendable () -> Void
) throws {
try self.init(
fileDescriptor: fileDescriptor,
queue: queue,
registerEvents: { descriptor, events, count in
guard kevent(descriptor, events, count, nil, 0, nil) == 0 else {
return POSIXErrorCode(rawValue: errno) ?? .EIO
}
return nil
},
closeDescriptor: { descriptor in
close(descriptor)
},
onEvent: onEvent,
onHangup: onHangup
)
}
init(
fileDescriptor: Int32,
queue: DispatchQueue = DispatchQueue(
label: "com.stablyai.orca.computer-use-owner-hangup"
),
registerEvents: RegisterEvents,
closeDescriptor: @escaping CloseDescriptor,
onEvent: @escaping @Sendable () -> Void = {},
onHangup: @escaping @Sendable () -> Void
) throws {
self.closeDescriptor = closeDescriptor
eventQueue = queue
self.onEvent = onEvent
self.onHangup = onHangup
let descriptor = kqueue()
guard descriptor >= 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
guard fileDescriptor >= 0 else {
closeDescriptor(descriptor)
throw POSIXError(.EBADF)
}
eventQueueDescriptor = descriptor
var registrations = [
kevent(
ident: UInt(fileDescriptor),
filter: Int16(EVFILT_READ),
flags: UInt16(EV_ADD | EV_CLEAR),
fflags: UInt32(NOTE_LOWAT),
data: Int.max,
udata: nil
),
kevent(
ident: Self.cancelEventIdentifier,
filter: Int16(EVFILT_USER),
flags: UInt16(EV_ADD | EV_CLEAR),
fflags: 0,
data: 0,
udata: nil
)
]
let registrationError = registrations.withUnsafeMutableBufferPointer { buffer in
registerEvents(descriptor, buffer.baseAddress, Int32(buffer.count))
}
if let registrationError {
isFinished = true
closeDescriptor(descriptor)
throw POSIXError(registrationError)
}
}
deinit {
cancel()
}
public func start() {
stateLock.lock()
guard !isStarted, !isCancelled, !isFinished else {
stateLock.unlock()
return
}
isStarted = true
stateLock.unlock()
eventQueue.async { [self] in
waitForHangup()
}
}
public func cancel() {
stateLock.lock()
guard !isFinished else {
stateLock.unlock()
return
}
isCancelled = true
guard isStarted else {
isFinished = true
closeDescriptor(eventQueueDescriptor)
stateLock.unlock()
return
}
var event = kevent(
ident: Self.cancelEventIdentifier,
filter: Int16(EVFILT_USER),
flags: 0,
fflags: UInt32(NOTE_TRIGGER),
data: 0,
udata: nil
)
_ = kevent(eventQueueDescriptor, &event, 1, nil, 0, nil)
stateLock.unlock()
}
private func waitForHangup() {
defer { finish() }
while true {
var event = kevent()
let result = kevent(eventQueueDescriptor, nil, 0, &event, 1, nil)
if result < 0 && errno == EINTR {
continue
}
guard result > 0 else { return }
onEvent()
if event.filter == Int16(EVFILT_USER) {
return
}
if event.flags & UInt16(EV_EOF | EV_ERROR) != 0 {
reportHangup()
return
}
}
}
private func finish() {
stateLock.lock()
isFinished = true
closeDescriptor(eventQueueDescriptor)
stateLock.unlock()
}
private func reportHangup() {
stateLock.lock()
guard !isCancelled, !didReportHangup else {
stateLock.unlock()
return
}
didReportHangup = true
stateLock.unlock()
onHangup()
}
}
@@ -0,0 +1,104 @@
import OrcaComputerUseMacOSCore
import XCTest
final class AgentSessionOwnershipTests: XCTestCase {
func testUnclaimedDisconnectDoesNotTerminateAgent() {
var ownership = AgentSessionOwnership()
XCTAssertFalse(ownership.disconnect(connection(12)))
}
func testUnauthenticatedConnectionCannotClaimOrRetainAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: false), .rejected)
XCTAssertFalse(ownership.disconnect(connection(12)))
}
func testLastAuthenticatedDisconnectTerminatesAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
XCTAssertTrue(ownership.disconnect(connection(12)))
}
func testAgentWaitsForEveryAuthenticatedConnectionToClose() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
XCTAssertEqual(ownership.registerConnection(connection(13), authenticated: true), .retained)
XCTAssertFalse(ownership.disconnect(connection(12)))
XCTAssertTrue(ownership.disconnect(connection(13)))
}
func testDuplicateRegistrationDoesNotRetainAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .rejected)
XCTAssertTrue(ownership.disconnect(connection(12)))
}
func testClosedSessionRejectsNewConnections() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(connection(12), authenticated: true), .claimed)
XCTAssertTrue(ownership.disconnect(connection(12)))
XCTAssertEqual(ownership.registerConnection(connection(13), authenticated: true), .rejected)
XCTAssertFalse(ownership.disconnect(connection(13)))
}
func testStaleDisconnectCannotRemoveReusedFileDescriptorOwner() {
var ownership = AgentSessionOwnership()
let staleConnection = connection(12)
let otherConnection = connection(13)
let reusedDescriptorConnection = connection(14)
XCTAssertEqual(ownership.registerConnection(staleConnection, authenticated: true), .claimed)
XCTAssertEqual(ownership.registerConnection(otherConnection, authenticated: true), .retained)
XCTAssertFalse(ownership.disconnect(staleConnection))
XCTAssertEqual(
ownership.registerConnection(reusedDescriptorConnection, authenticated: true),
.retained
)
XCTAssertFalse(ownership.disconnect(staleConnection))
XCTAssertFalse(ownership.disconnect(otherConnection))
XCTAssertTrue(ownership.disconnect(reusedDescriptorConnection))
}
func testTokenlessSessionStillRequiresAuthorizedPeer() {
XCTAssertFalse(isAuthenticatedAgentSession(
expectedToken: nil,
requestToken: nil,
authorizedPeer: false
))
XCTAssertTrue(isAuthenticatedAgentSession(
expectedToken: nil,
requestToken: nil,
authorizedPeer: true
))
}
func testTokenSessionRequiresAuthorizedPeerAndMatchingToken() {
XCTAssertFalse(isAuthenticatedAgentSession(
expectedToken: "expected",
requestToken: "expected",
authorizedPeer: false
))
XCTAssertFalse(isAuthenticatedAgentSession(
expectedToken: "expected",
requestToken: "wrong",
authorizedPeer: true
))
XCTAssertTrue(isAuthenticatedAgentSession(
expectedToken: "expected",
requestToken: "expected",
authorizedPeer: true
))
}
}
private func connection(_ rawValue: UInt64) -> AgentSessionConnectionID {
AgentSessionConnectionID(rawValue: rawValue)
}
@@ -0,0 +1,195 @@
import Darwin
import Dispatch
@testable import OrcaComputerUseMacOSCore
import XCTest
final class AuthenticatedConnectionHangupMonitorTests: XCTestCase {
func testRejectsInvalidDescriptorDuringSetup() {
XCTAssertThrowsError(
try AuthenticatedConnectionHangupMonitor(
fileDescriptor: -1,
onHangup: {}
)
)
}
func testRegistrationFailureClosesEventQueueOnce() {
let closes = DescriptorCloseRecorder()
XCTAssertThrowsError(
try AuthenticatedConnectionHangupMonitor(
fileDescriptor: 42,
registerEvents: { _, _, _ in .EBADF },
closeDescriptor: { descriptor in
closes.recordAndClose(descriptor)
},
onHangup: {}
)
)
XCTAssertEqual(closes.count, 1)
}
func testReportsPeerCloseWhileRequestHandlingIsBlocked() throws {
let descriptors = try makeSocketPair()
let hangup = expectation(description: "peer hangup")
let processingStarted = expectation(description: "processing started")
let releaseProcessing = DispatchSemaphore(value: 0)
let processingFinished = DispatchSemaphore(value: 0)
let monitor = try AuthenticatedConnectionHangupMonitor(
fileDescriptor: descriptors.local,
onHangup: {
hangup.fulfill()
}
)
monitor.start()
DispatchQueue.global().async {
processingStarted.fulfill()
releaseProcessing.wait()
processingFinished.signal()
}
wait(for: [processingStarted], timeout: 1)
close(descriptors.peer)
wait(for: [hangup], timeout: 1)
XCTAssertEqual(processingFinished.wait(timeout: .now() + 0.05), .timedOut)
releaseProcessing.signal()
XCTAssertEqual(processingFinished.wait(timeout: .now() + 1), .success)
monitor.cancel()
close(descriptors.local)
}
func testReadableDataDoesNotLookLikeHangup() throws {
let descriptors = try makeSocketPair()
let hangup = expectation(description: "peer hangup")
let callbacks = CallbackRecorder()
let events = CallbackRecorder()
let monitor = try AuthenticatedConnectionHangupMonitor(
fileDescriptor: descriptors.local,
onEvent: {
events.record()
},
onHangup: {
callbacks.record()
hangup.fulfill()
}
)
monitor.start()
var byte: UInt8 = 7
XCTAssertEqual(write(descriptors.peer, &byte, 1), 1)
usleep(50_000)
XCTAssertEqual(callbacks.count, 0)
XCTAssertEqual(events.count, 0)
close(descriptors.peer)
wait(for: [hangup], timeout: 1)
XCTAssertEqual(events.count, 1)
monitor.cancel()
close(descriptors.local)
}
func testCancelPreventsLaterHangupCallback() throws {
let descriptors = try makeSocketPair()
let hangup = expectation(description: "peer hangup")
hangup.isInverted = true
let monitor = try AuthenticatedConnectionHangupMonitor(
fileDescriptor: descriptors.local,
onHangup: {
hangup.fulfill()
}
)
monitor.start()
monitor.cancel()
close(descriptors.peer)
wait(for: [hangup], timeout: 0.1)
close(descriptors.local)
}
func testCancelReleasesMonitorWhileSocketStaysOpen() throws {
let descriptors = try makeSocketPair()
var monitor: AuthenticatedConnectionHangupMonitor? = try AuthenticatedConnectionHangupMonitor(
fileDescriptor: descriptors.local,
onHangup: {}
)
weak var retainedMonitor = monitor
monitor?.cancel()
monitor = nil
let deadline = Date().addingTimeInterval(1)
while retainedMonitor != nil, Date() < deadline {
usleep(10_000)
}
XCTAssertNil(retainedMonitor)
close(descriptors.peer)
close(descriptors.local)
}
func testStartReportsHangupQueuedAfterSuccessfulSetup() throws {
let descriptors = try makeSocketPair()
let hangup = expectation(description: "queued peer hangup")
let monitor = try AuthenticatedConnectionHangupMonitor(
fileDescriptor: descriptors.local,
onHangup: {
hangup.fulfill()
}
)
close(descriptors.peer)
monitor.start()
wait(for: [hangup], timeout: 1)
monitor.cancel()
close(descriptors.local)
}
}
private final class CallbackRecorder: @unchecked Sendable {
private let lock = NSLock()
private var recordedCount = 0
var count: Int {
lock.lock()
defer { lock.unlock() }
return recordedCount
}
func record() {
lock.lock()
recordedCount += 1
lock.unlock()
}
}
private final class DescriptorCloseRecorder: @unchecked Sendable {
private let lock = NSLock()
private var closeCount = 0
var count: Int {
lock.lock()
defer { lock.unlock() }
return closeCount
}
func recordAndClose(_ descriptor: Int32) {
lock.lock()
closeCount += 1
let shouldClose = closeCount == 1
lock.unlock()
if shouldClose {
close(descriptor)
}
}
}
private func makeSocketPair() throws -> (local: Int32, peer: Int32) {
var descriptors: [Int32] = [0, 0]
guard socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0 else {
throw POSIXError(.EIO)
}
return (descriptors[0], descriptors[1])
}
+7 -3
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.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",
+2 -2
View File
@@ -41,7 +41,7 @@ vi.mock('../../main/claude-accounts/keychain', () => ({
readActiveClaudeKeychainCredentialsStrict: readKeychainMock,
writeActiveClaudeKeychainCredentials: writeKeychainMock
}))
vi.mock('../../main/codex-cli/command', () => ({
vi.mock('../../shared/node-cli-command-resolution', () => ({
getVersionManagerBinPaths: getVersionManagerBinPathsMock,
resolveCliCommand: resolveCliCommandMock
}))
@@ -49,7 +49,7 @@ vi.mock('../../main/codex-cli/command', () => ({
import { ACCOUNT_HANDLERS } from './account'
import type { HandlerContext } from '../dispatch'
import type { RuntimeClient } from '../runtime-client'
import { getCmdExePath } from '../../main/win32-utils'
import { getCmdExePath } from '../../shared/windows-batch-spawn'
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
function successfulChild(): EventEmitter {
+5 -2
View File
@@ -12,8 +12,11 @@ import {
readActiveClaudeKeychainCredentialsStrict,
writeActiveClaudeKeychainCredentials
} from '../../main/claude-accounts/keychain'
import { getVersionManagerBinPaths, resolveCliCommand } from '../../main/codex-cli/command'
import { getSpawnArgsForWindows } from '../../main/win32-utils'
import {
getVersionManagerBinPaths,
resolveCliCommand
} from '../../shared/node-cli-command-resolution'
import { getSpawnArgsForWindows } from '../../shared/windows-batch-spawn'
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types'
+7 -4
View File
@@ -3,14 +3,17 @@ import type { CommandHandler } from '../dispatch'
import { RuntimeClientError } from '../runtime-client'
import { delimiter, dirname } from 'node:path'
import { getRepeatedStringFlag } from '../flags'
import { resolveCliCommand } from '../../main/codex-cli/command'
import { detectCommandsInInstallDirs } from '../../main/ipc/local-agent-install-dir-detection'
import { resolveCliCommand } from '../../shared/node-cli-command-resolution'
import { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection'
import {
getTuiAgentDetectionProbeCommands,
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
resolveDetectedTuiAgentIds
} from '../../main/ipc/tui-agent-detection-commands'
import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError } from '../../main/win32-utils'
} from '../../shared/tui-agent-detection-commands'
import {
getSpawnArgsForWindows,
UnsafeWindowsBatchArgumentsError
} from '../../shared/windows-batch-spawn'
import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys'
import {
buildAgentFeatureSkillInstallArgs,
+4 -4
View File
@@ -41,8 +41,8 @@ function findElectronViteMainEntries(): Set<string> {
describe('CLI imports of main-process modules', () => {
// Why: electron-vite cleans out/main and emits only its declared entries, so a
// `src/main/*` module the CLI imports but the config omits is deleted by the
// build that runs after `build:cli` — the command then dies at require time
// with "Cannot find module". Nothing else catches this before packaging.
// build that runs after `build:cli` — keep source-level feedback ahead of the
// final-artifact runtime verifier.
it('has an electron-vite entry for every main module the CLI imports', () => {
const entries = findElectronViteMainEntries()
const missing = findMainImports().filter(({ module }) => !entries.has(module))
@@ -52,7 +52,7 @@ describe('CLI imports of main-process modules', () => {
it('finds the imports it is meant to guard', () => {
// Why: a broken matcher would make the guard above vacuously pass.
expect(findMainImports().length).toBeGreaterThanOrEqual(4)
expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(4)
expect(findMainImports().length).toBeGreaterThanOrEqual(2)
expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2)
})
})
+3 -3
View File
@@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter } from 'node:path'
import type * as CodexCliCommandModule from '../main/codex-cli/command'
import type * as CodexCliCommandModule from '../shared/node-cli-command-resolution'
const {
detectCommandsMock,
@@ -19,12 +19,12 @@ const {
// Why: agent detection probes the real machine, so pin it or every install
// assertion depends on what the test runner happens to have installed.
vi.mock('../main/ipc/local-agent-install-dir-detection', () => ({
vi.mock('../shared/local-agent-install-dir-detection', () => ({
detectCommandsInInstallDirs: detectCommandsMock
}))
// Why: override only the npx lookup so the real Windows .cmd rail still runs.
vi.mock('../main/codex-cli/command', async (importOriginal) => ({
vi.mock('../shared/node-cli-command-resolution', async (importOriginal) => ({
...(await importOriginal<typeof CodexCliCommandModule>()),
resolveCliCommand: resolveCliCommandMock
}))
@@ -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)
+10 -1
View File
@@ -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
View File
@@ -1,226 +1 @@
import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
type ResolveCommandOptions = {
pathEnv?: string | null
platform?: NodeJS.Platform
homePath?: string
}
function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] {
if (platform === 'win32') {
return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName]
}
return [commandName]
}
function splitPath(pathEnv: string | null | undefined): string[] {
if (!pathEnv) {
return []
}
return pathEnv
.split(delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
}
function parseVersionSegment(raw: string): number[] {
return raw
.replace(/^v/i, '')
.split('.')
.map((segment) => Number.parseInt(segment, 10))
.map((segment) => (Number.isFinite(segment) ? segment : 0))
}
function compareVersionDesc(left: string, right: string): number {
const leftParts = parseVersionSegment(left)
const rightParts = parseVersionSegment(right)
const length = Math.max(leftParts.length, rightParts.length)
for (let index = 0; index < length; index += 1) {
const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0)
if (delta !== 0) {
return delta
}
}
return right.localeCompare(left)
}
function findFirstExecutable(
platform: NodeJS.Platform,
directories: string[],
executableNames: string[]
): string | null {
for (const directory of directories) {
for (const executableName of executableNames) {
const candidate = join(directory, executableName)
if (isRunnableCommand(platform, candidate)) {
return candidate
}
}
}
return null
}
function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean {
try {
const stats = statSync(candidate)
if (!stats.isFile()) {
return false
}
if (platform === 'win32') {
return true
}
// Why: GUI fallback probing should skip placeholders/directories so spawn
// can continue to a runnable CLI instead of failing later with EACCES/EISDIR.
accessSync(candidate, constants.X_OK)
return true
} catch {
return false
}
}
function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] {
const directories = [
join(homePath, '.volta', 'bin'),
join(homePath, '.asdf', 'shims'),
join(homePath, '.fnm', 'aliases', 'default', 'bin'),
// Why: mise (formerly rtx) exposes managed tool binaries via a shims
// directory, similar to asdf. Without this, users who installed node
// or CLI tools through mise can't be found by the fallback probe.
join(homePath, '.local', 'share', 'mise', 'shims')
]
if (platform === 'win32') {
// Why: Anthropic's native Windows installer places claude.exe here, and
// GUI-launched Orca may not inherit the user's PATH entry for it.
directories.push(join(homePath, '.local', 'bin'))
directories.push(join(homePath, 'AppData', 'Roaming', 'npm'))
directories.push(join(homePath, 'AppData', 'Local', 'pnpm'))
directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin'))
} else {
directories.push(join(homePath, '.local', 'bin'))
// Why: pnpm uses platform-specific global bin directories that differ from
// npm's ~/.local/bin. macOS follows the ~/Library convention while Linux
// uses the XDG-compatible ~/.local/share path. Without these, users who
// installed via `pnpm add -g` can't be found by the fallback probe.
if (platform === 'darwin') {
directories.push(join(homePath, 'Library', 'pnpm'))
} else {
directories.push(join(homePath, '.local', 'share', 'pnpm'))
}
directories.push(join(homePath, '.yarn', 'bin'))
}
// Why: bun uses ~/.bun/bin on all platforms for globally installed packages.
directories.push(join(homePath, '.bun', 'bin'))
return directories
}
function getNvmVersionDirectories(homePath: string): string[] {
const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node')
if (!existsSync(nvmVersionsDir)) {
return []
}
return readdirSync(nvmVersionsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(compareVersionDesc)
.map((entry) => join(nvmVersionsDir, entry, 'bin'))
}
function getVersionManagerDirectories(
platform: NodeJS.Platform,
homePath: string,
executableNames: string[]
): string[] {
const directories = getBaseVersionManagerDirectories(platform, homePath)
// Why: GUI-launched Electron apps do not inherit shell init from nvm, so
// command resolution probes the newest installed Node versions explicitly.
const firstNvmMatch = findFirstExecutable(
platform,
getNvmVersionDirectories(homePath),
executableNames
)
if (firstNvmMatch) {
directories.unshift(dirname(firstNvmMatch))
}
return directories
}
export function resolveCliCommand(
commandName: string,
options: ResolveCommandOptions = {}
): string {
const platform = options.platform ?? process.platform
const executableNames = getExecutableNames(platform, commandName)
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames)
if (pathCandidate) {
return pathCandidate
}
const homePath = options.homePath ?? homedir()
const versionManagerCandidate = findFirstExecutable(
platform,
getVersionManagerDirectories(platform, homePath, executableNames),
executableNames
)
return versionManagerCandidate ?? commandName
}
export function resolveCliCommands(
commandNames: readonly string[],
options: ResolveCommandOptions = {}
): Map<string, string> {
const platform = options.platform ?? process.platform
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
const pathDirectories = splitPath(pathEnv)
const homePath = options.homePath ?? homedir()
// Why: agent detection probes many CLIs at once; compute expensive install
// directories, especially nvm versions, once per detection pass.
const installDirectories = [
...getNvmVersionDirectories(homePath),
...getBaseVersionManagerDirectories(platform, homePath)
]
const resolved = new Map<string, string>()
for (const commandName of new Set(commandNames)) {
const executableNames = getExecutableNames(platform, commandName)
const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames)
const installCandidate =
pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames)
resolved.set(commandName, installCandidate ?? commandName)
}
return resolved
}
export function resolveCodexCommand(options: ResolveCommandOptions = {}): string {
return resolveCliCommand('codex', options)
}
export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string {
return resolveCliCommand('claude', options)
}
// Why: GUI-launched Electron apps inherit a minimal PATH that excludes Node
// version manager directories. CLI tools like codex/claude are Node scripts
// with #!/usr/bin/env node shebangs — they need `node` in PATH to execute,
// not just to be *found*. This function returns the version manager bin paths
// so the caller can augment process.env.PATH at startup.
export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] {
const platform = options.platform ?? process.platform
const homePath = options.homePath ?? homedir()
const nodeNames = getExecutableNames(platform, 'node')
return getVersionManagerDirectories(platform, homePath, nodeNames)
}
export * from '../../shared/node-cli-command-resolution'
@@ -22,13 +22,13 @@ describe('promptForGpuFallbackRestart', () => {
await expect(promptForGpuFallbackRestart(parentWindow as never)).resolves.toBe('restart')
expect(showMessageBoxMock).toHaveBeenCalledWith(parentWindow, {
type: 'warning',
buttons: ['Restart with Software Rendering', 'Keep Running'],
buttons: ['Restart in Safe Graphics Mode', 'Keep Running'],
defaultId: 0,
cancelId: 1,
title: 'Restart Orca?',
title: 'Restart Orca in Safe Graphics Mode?',
message: "Orca's graphics process has crashed repeatedly.",
detail:
'Restart to switch to software rendering and reduce the chance of the app window crashing. If you keep running, Orca may become unstable.'
'Safe graphics mode disables hardware acceleration and WebGL for this Orca version. Terminals and 3D content may render more slowly. Keep Running leaves graphics settings unchanged.'
})
})
@@ -4,13 +4,13 @@ export type GpuFallbackRestartDecision = 'restart' | 'continue'
const GPU_FALLBACK_RESTART_OPTIONS: MessageBoxOptions = {
type: 'warning',
buttons: ['Restart with Software Rendering', 'Keep Running'],
buttons: ['Restart in Safe Graphics Mode', 'Keep Running'],
defaultId: 0,
cancelId: 1,
title: 'Restart Orca?',
title: 'Restart Orca in Safe Graphics Mode?',
message: "Orca's graphics process has crashed repeatedly.",
detail:
'Restart to switch to software rendering and reduce the chance of the app window crashing. If you keep running, Orca may become unstable.'
'Safe graphics mode disables hardware acceleration and WebGL for this Orca version. Terminals and 3D content may render more slowly. Keep Running leaves graphics settings unchanged.'
}
export async function promptForGpuFallbackRestart(
+216
View File
@@ -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'
}
+3 -3
View File
@@ -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')
+35 -5
View File
@@ -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
})
})
+23 -4
View File
@@ -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 }
}
+14 -2
View File
@@ -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}$/)
+13 -10
View File
@@ -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
}
+151 -1
View File
@@ -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', () => {
+288 -30
View File
@@ -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'
}
+29
View File
@@ -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()
})
})
+62
View File
@@ -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
}
}
+2
View File
@@ -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)
})
})
+23 -8
View File
@@ -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')
+10 -7
View File
@@ -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