Merge remote-tracking branch 'origin/main' into Jinwoo-H/create-workspace-host-2

# Conflicts:
#	src/renderer/src/components/NewWorkspaceComposerCard.tsx
#	src/renderer/src/components/settings/SshPane.tsx
This commit is contained in:
OrcaWin
2026-07-21 20:59:29 -07:00
4538 changed files with 529285 additions and 81186 deletions
+4
View File
@@ -6,3 +6,7 @@
/config/scripts/run-internal-dev-setup.mjs text eol=lf
/config/scripts/verify-cli-bin.mjs text eol=lf
/config/scripts/verify-release-required-assets.mjs text eol=lf
/skill-guides/*.md text eol=lf
/skill-stubs/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
/src/cli/bundled-skill-guides.ts text eol=lf
+1
View File
@@ -1,6 +1,7 @@
name: Bug report
description: Report a problem with Orca
title: '[Bug]: '
type: Bug
labels:
- bug
body:
@@ -1,6 +1,7 @@
name: Feature request
description: Suggest an improvement for Orca
title: '[Feature]: '
type: Feature
labels:
- enhancement
body:
+31 -1
View File
@@ -6,20 +6,33 @@ on:
- '.github/workflows/computer-e2e.yml'
- 'config/electron-builder.config.cjs'
- 'config/scripts/build-computer-macos.mjs'
- 'config/scripts/build-windows-cli-launcher.mjs'
- 'config/scripts/build-windows-cli-launcher.test.mjs'
- 'config/scripts/computer-e2e-workflow.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
- 'config/scripts/computer-use-smoke.test.mjs'
- 'config/scripts/daemon-boot-smoke.mjs'
- 'config/scripts/windows-daemon-workspace-close-repro.mjs'
- 'config/scripts/verify-computer-native.mjs'
# Why: the native-smoke job boots the built terminal daemon under plain
# Node, so any change to the daemon bundle graph or the main build must
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
- 'electron.vite.config.ts'
- 'build-plugins/**'
- 'src/main/daemon/**'
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
- 'native/computer-use-windows/**'
- 'native/windows-cli-launcher/**'
- 'skills/computer-use/SKILL.md'
- 'src/cli/**'
- 'src/main/computer/**'
- 'src/main/runtime/rpc/dispatcher.ts'
- 'src/main/runtime/rpc/errors.ts'
- 'src/main/runtime/rpc/methods/computer*.ts'
- 'src/main/ssh/ssh-remote-cli-launcher.ts'
- 'src/main/ssh/ssh-remote-cli-launcher.test.ts'
- 'src/shared/computer-use-*.ts'
- 'tests/e2e/computer-linux.e2e.ts'
- 'tests/e2e/computer-mac.e2e.ts'
@@ -62,8 +75,14 @@ jobs:
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
# is no root config) and falls back to the 5s default timeout with no
# Windows worker cap, so the real csc.exe launcher-compile tests time out
# on hosted Windows. Use the shared config so this job matches pnpm test.
- run: >-
pnpm vitest run
pnpm vitest run --config config/vitest.config.ts
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/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
@@ -98,6 +117,17 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
# Why: boot the BUILT daemon-entry under plain Node the way production
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
# (leaked electron require) while every other check passed; this fails
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
- name: Daemon boot smoke
run: node config/scripts/daemon-boot-smoke.mjs
# Why: workspace removal overlaps graceful renderer teardown with the
# forced main-process sweep; exercise that exact pair against real ConPTY.
- name: Windows daemon workspace-close repro
if: runner.os == 'Windows'
run: node config/scripts/windows-daemon-workspace-close-repro.mjs
- if: runner.os == 'Linux'
env:
ORCA_COMPUTER_E2E: '1'
+56
View File
@@ -173,3 +173,59 @@ jobs:
path: test-results/
retention-days: 7
if-no-files-found: ignore
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
# stays consistent across workflows even while this job is ubuntu-latest.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download E2E build output
uses: actions/download-artifact@v8
with:
name: e2e-build-out
path: out/
# Why: this is the release-path proof that the deployed Linux relay keeps
# its PTY and explorer live across a real watcher SIGSEGV.
- name: Run Docker SSH watcher isolation E2E
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-traces-ssh-docker-watcher-isolation
path: test-results/
retention-days: 7
if-no-files-found: ignore
@@ -9,6 +9,8 @@ on:
- 'tests/e2e/fixtures/terminal-emoji-table.md'
- 'tests/e2e/helpers/**'
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
- 'src/renderer/src/lib/pane-manager/**'
workflow_dispatch:
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Typecheck
run: npx tsc --noEmit
run: pnpm typecheck
- name: Test
run: pnpm test
+41 -1
View File
@@ -15,9 +15,14 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: the freshness registry is derived from immutable release tags,
# so shallow PR checkouts cannot verify historical official identities.
fetch-depth: 0
persist-credentials: false
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -77,6 +82,14 @@ jobs:
- name: Enforce max-lines ratchet (no new bypasses)
run: pnpm run check:max-lines-ratchet
# Why: the CLI embeds guide content while the skills CLI installs generated
# projections from the repository, so stale output would split those two truths.
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
- name: Verify skill freshness manifest
run: pnpm run verify:skill-bundle-manifest
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
@@ -103,6 +116,33 @@ jobs:
- name: Typecheck
run: pnpm typecheck
# Why: real old Git diagnostics differ from mocked errors. Keep the
# fallback predicates executable across the baseline, transition, and
# current command shapes so a newly added flag cannot silently regress.
- name: Verify Git binary compatibility matrix
run: |
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$RUNNER_TEMP/git-2.25.5"
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
for spec in \
"alpine/git:edge-2.38.1|2.38.1" \
"alpine/git:v2.49.1|2.49.1"; do
image="${spec%%|*}"
version="${spec#*|}"
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
done
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
# it for Node so orchestration tests can load the native module.
+366 -2
View File
@@ -41,6 +41,11 @@ on:
required: false
default: false
type: boolean
version_suffix:
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only.
required: false
type: string
default: ''
permissions:
contents: write
@@ -192,6 +197,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
run: |
set -euo pipefail
@@ -366,6 +372,18 @@ jobs:
fi
new="${base}-rc.$((highest_rc + 1))"
fi
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
# Why a dot-appended identifier (rc.N.perf): it sorts just
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
# branch builds never outrank the main RC series and cannot
# hijack the update channel; clients find them by matching the
# identifier ("perf") in the prerelease components.
if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then
echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2
exit 1
fi
new="${new}.${VERSION_SUFFIX}"
fi
;;
patch|minor|major)
new="$(bump "$latest_stable" "$KIND")"
@@ -719,6 +737,10 @@ jobs:
~/.cache/electron-builder
runs-on: ${{ matrix.os }}
# Why: hosted runners hard-cap jobs at 6h; the Windows SignPath waits
# (1h inner + 4h installer) are budgeted to fit under this with the
# build itself, so a slow approval can't kill the job mid-flow.
timeout-minutes: 360
permissions:
actions: read
@@ -804,7 +826,10 @@ jobs:
TAG: ${{ needs.cut.outputs.tag }}
run: |
set -euo pipefail
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
# Why the optional trailing identifier: suffixed side-branch RCs
# (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry
# identity as plain RCs.
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then
identity=rc
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
identity=stable
@@ -838,6 +863,20 @@ jobs:
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
- name: Gate runtime file-watcher process isolation
if: runner.os == 'Linux'
run: |
# Why: #8212 is a native-process crash contract. Prove both the Node
# host and the exact Electron runtime survive SIGSEGV before packaging.
node config/scripts/runtime-file-watcher-fault-harness.mjs
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
- name: Gate SSH relay watcher process isolation
run: |
# Why: the remote native watcher shares a daemon with live PTYs.
# Kill only its child and require both PTY and watch recovery before packaging.
node config/scripts/relay-watcher-fault-harness.mjs
- name: Publish release artifacts (Linux)
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
uses: nick-fields/retry@v4
@@ -958,6 +997,244 @@ jobs:
}
}
# ── Windows inner-binary signing (issue #7785) ─────────────────────
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE
# files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip
# request, then the installer is rebuilt from the signed tree before the
# existing installer signing request below. Every step in this chain is
# fail-open (continue-on-error + outcome gating): any failure ships the
# original installer with unsigned inner binaries, exactly like releases
# did before this chain existed. Rehearsed end to end in run 28988432001
# (.github/workflows/windows-signing-rehearsal.yml).
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
- name: Stage unsigned inner PE files for signing
id: stage-inner
if: matrix.platform == 'win'
continue-on-error: true
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
$list = New-Object System.Collections.Generic.List[string]
$skipped = New-Object System.Collections.Generic.List[string]
Get-ChildItem -Path $root -Recurse -File |
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
if ($signature.Status -eq 'Valid') {
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
return
}
$destination = Join-Path $stage.FullName $relative
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
Copy-Item -Path $_.FullName -Destination $destination -Force
$list.Add($relative)
}
if (-not ($list -contains 'Orca.exe')) {
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
}
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
}
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
$list | ForEach-Object { Write-Host " $_" }
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success'
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }}
path: signing-stage/**
if-no-files-found: error
- name: Submit inner binaries signing request
id: submit-inner-signing
if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success'
continue-on-error: true
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: release-signing
artifact-configuration-slug: windows-inner-binaries-zip
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
wait-for-completion: false
- name: Notify Slack that inner-binary signing is waiting for approval
id: notify-inner-signing
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
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 }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
}
$requestUrl = $env:SIGNPATH_REQUEST_URL
if ([string]::IsNullOrWhiteSpace($requestUrl)) {
$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>"
$payload = @{
text = $message
blocks = @(
@{
type = 'section'
text = @{
type = 'mrkdwn'
text = $message
}
}
)
} | ConvertTo-Json -Depth 5
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
# Why gate on the notify outcome too: if nobody was told to approve,
# don't hold the release for the approval window — fall through and
# ship like today instead. The 1h wait (vs the installer's 4h) keeps
# both waits plus the build inside the 360-minute job cap; missing it
# falls through to today's unsigned-inner flow rather than blocking.
- name: Download signed inner binaries from SignPath
id: download-signed-inner
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
-ApiToken $env:SIGNPATH_API_TOKEN `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-inner.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 3600
New-Item -ItemType Directory -Path signed-inner -Force
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
# Why: copy back strictly by the staged list so a layout mismatch in the
# returned artifact fails loudly (into fail-open) instead of silently
# shipping a mix of signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
id: restore-signed-inner
if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$failures = New-Object System.Collections.Generic.List[string]
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
Select-Object -First 1
if ($null -eq $signed) {
$failures.Add("missing from signed artifact: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
$failures.Add("returned without a signature: $relative")
continue
}
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
}
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
}
# Why this step exists: electron-builder's CopyElevateHelper re-copies a
# pristine elevate.exe from its download cache over resources\elevate.exe
# on EVERY nsis pack — including the --prepackaged rebuild below — which
# clobbered the SignPath signature in v1.4.129-rc.4. There is no supported
# way to disable just the copy, so we overwrite the cache's copy with our
# signed one (identical bytes plus signature) so the clobber becomes a
# no-op. Known quirk: the cache persists across releases via actions/cache,
# so later runs may see elevate.exe as already signed and skip staging it —
# that is fine (the signature is timestamped) and the evidence gate checks
# elevate.exe in the shipped installer unconditionally. If this ever causes
# trouble, delete this step; the only effect is elevate.exe shipping
# unsigned again, which the evidence gate will flag.
- name: Replace cached elevate.exe with the signed copy
id: sign-elevate-cache
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
$signed = 'dist/win-unpacked/resources/elevate.exe'
if (-not (Test-Path $signed)) {
Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.'
exit 0
}
$signature = Get-AuthenticodeSignature -FilePath $signed
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap."
exit 0
}
$cached = @(Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter elevate.exe -ErrorAction SilentlyContinue)
if ($cached.Count -eq 0) {
Write-Host '::warning::No cached elevate.exe found (electron-builder cache layout changed?); the rebuild will pack the unsigned copy and the evidence gate will flag it.'
exit 0
}
foreach ($file in $cached) {
Copy-Item -Path $signed -Destination $file.FullName -Force
Write-Host "Replaced $($file.FullName) with the SignPath-signed copy."
}
- name: Rebuild NSIS installer from signed unpacked app
id: rebuild-nsis-signed
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
# Why: keep the pre-rebuild artifacts so a failed rebuild can fall
# back to shipping them unchanged (fail-open).
New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null
Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force
Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
}
- name: Roll back to original installer after failed rebuild
if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure'
shell: pwsh
run: |
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force
Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
}
# ── End Windows inner-binary signing ───────────────────────────────
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
id: upload-unsigned-windows-installer
@@ -992,6 +1269,7 @@ jobs:
SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }}
TAG: ${{ needs.cut.outputs.tag }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }}
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
@@ -1002,7 +1280,9 @@ jobs:
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
}
$message = "Orca Windows release $env:TAG is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
# 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>"
$payload = @{
text = $message
blocks = @(
@@ -1085,6 +1365,90 @@ jobs:
}
$signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint
# Why: evidence gate for inner-binary signing (issue #7785, supersedes
# PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node
# by extracting the shipped installer). Warn-only until the flow has been
# proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED
# to 'true' so unsigned inner binaries block the release.
- name: Verify Windows inner binary signatures
if: matrix.platform == 'win'
shell: pwsh
env:
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
run: |
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
if ($required) { throw $message }
Write-Host "::warning::$message"
exit 0
}
# Why try/catch: while the gate is warn-only, even an unexpected
# script error (extraction hiccup, missing file) must not block
# the release — only the flip to required makes failures fatal.
try {
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
# Why: verify the files a user actually gets on disk, not the build
# tree — 7z parses the NSIS exe directly as its embedded payload.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
$root = Resolve-Path 'inner-evidence-extract'
# Why elevate.exe is always appended: staging skips already-signed
# files, and the persisted electron-builder cache can carry a
# previously signed elevate.exe — so it may be absent from the list
# in some runs, yet it is the file most at risk of losing its
# signature in the NSIS rebuild. Verify it in every release.
$targets = @(Get-Content 'inner-signing-list.txt')
if ($targets -notcontains 'resources\elevate.exe') {
$targets += 'resources\elevate.exe'
}
foreach ($relative in $targets) {
$path = Join-Path $root $relative
if (-not (Test-Path $path)) {
$failures.Add("missing from installer payload: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $path
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $relative, $subject
$report.Add($line)
Write-Host $line
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
$failures.Add("not signed by SignPath Foundation: $relative ($($signature.Status), $subject)")
}
}
Set-Content -Path 'inner-signing-evidence.txt' -Value ($report -join "`n")
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::warning::$_" }
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
if ($required) { throw $message }
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
} else {
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
}
} catch {
if ($required) { throw }
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
}
- name: Upload Windows inner signing evidence
if: always() && matrix.platform == 'win'
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
path: |
inner-signing-evidence.txt
inner-signing-list.txt
if-no-files-found: ignore
retention-days: 30
- name: Publish signed Windows release artifacts
if: matrix.platform == 'win'
uses: nick-fields/retry@v4
+17 -1
View File
@@ -96,7 +96,10 @@ jobs:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
# Why the optional trailing identifier: suffixed side-branch RCs
# (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry
# identity as plain RCs.
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then
identity=rc
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
identity=stable
@@ -117,6 +120,19 @@ jobs:
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
- name: Gate runtime file-watcher process isolation
run: |
# Why: #8212 is a native-process crash contract. Prove both the Node
# host and the exact Electron runtime survive SIGSEGV before packaging.
node config/scripts/runtime-file-watcher-fault-harness.mjs
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
- name: Gate SSH relay watcher process isolation
run: |
# Why: the remote native watcher shares a daemon with live PTYs.
# Kill only its child and require both PTY and watch recovery before packaging.
node config/scripts/relay-watcher-fault-harness.mjs
- name: Publish release artifacts (macOS)
uses: nick-fields/retry@v4
with:
@@ -0,0 +1,50 @@
name: Skill update round trip
on:
pull_request:
paths:
- 'skills/**'
- 'resources/skills/**'
- 'config/scripts/verify-skill-update-roundtrip.mjs'
- 'src/main/skills/skill-freshness-eligibility.ts'
- 'src/shared/skill-freshness.ts'
- '.github/workflows/skill-update-roundtrip.yml'
merge_group:
push:
branches:
- main
jobs:
roundtrip:
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
shape: [symlink, copy]
autocrlf: ['false', 'true']
skills-cli: ['1.5.17']
include:
- os: ubuntu-latest
shape: symlink
autocrlf: 'false'
skills-cli: latest
continue-on-error: ${{ matrix.skills-cli == 'latest' }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Verify targeted update convergence and copy behavior
env:
GITHUB_TOKEN: ${{ github.token }}
SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }}
run: >-
node config/scripts/verify-skill-update-roundtrip.mjs
--cli=${{ matrix.skills-cli }}
--autocrlf=${{ matrix.autocrlf }}
--shape=${{ matrix.shape }}
@@ -0,0 +1,195 @@
name: Windows Crash-Survival E2E
# Why: proves the detached daemon-host relocation makes terminal sessions SURVIVE
# a CRASH of Orca's main process (GitHub #7742), the companion guarantee to the
# update-survival harness. Builds an (unsigned) installer FROM THIS BRANCH,
# silent-installs it, opens a terminal, force-kills ONLY the app main (no
# tree-kill), and asserts the daemon + shell stay alive, a relaunch adopts the
# same daemon and shell, and post-crash input causes no pwsh FailFast. Targeted
# pull requests keep it as a durable regression gate. A CI runner is the only
# safe place to install — see the relocation post-mortem.
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- '.github/workflows/win-crash-survival-e2e.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'electron.vite.config.ts'
- 'build-plugins/**'
- 'config/electron-builder.config.cjs'
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'native/**'
- 'resources/win32/**'
- 'src/main/daemon/**'
- 'src/main/index.ts'
- 'src/main/ipc/pty*.ts'
- 'src/main/persistence.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/main/startup/first-window-startup-services.ts'
- 'src/main/window/attach-main-window-services.ts'
- 'src/preload/**'
- 'src/renderer/src/App.tsx'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/hooks/useIpcEvents.ts'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/renderer/src/lib/session-write-subscriber.ts'
- 'src/renderer/src/lib/workspace-session-host-persistence.ts'
- 'src/renderer/src/store/slices/terminals.ts'
- 'src/shared/pty-session-id-format.ts'
- 'tools/win-crash-survival-e2e/**'
- 'tools/win-update-e2e/**'
# Why: source tests and benchmarks do not change the packaged artifact;
# their own verify jobs cover them without spending a Windows build slot.
- '!src/**/*.test.*'
- '!src/**/*.bench.*'
workflow_dispatch:
inputs:
expect:
description: Expected outcome profile
required: true
type: choice
default: survival
options:
- survival
- orphaned
permissions:
contents: read
concurrency:
group: win-crash-survival-e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
crash-survival:
name: crash-survival (packaged build)
runs-on: windows-2022
timeout-minutes: 50
env:
EXPECT: ${{ inputs.expect || 'survival' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only builds and runs the harness locally; it never pushes.
persist-credentials: false
# Why: setup-node can restore pnpm's content-addressed store only after
# the pnpm binary exists, avoiding repeat dependency downloads per run.
- 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: Install dependencies
run: pnpm install --frozen-lockfile
# Why: cache the built installer by production build inputs, excluding
# tests/reliability metadata so harness-only edits skip electron-builder.
# The daemon relocation lives under src/, so product changes still rebuild.
- name: Cache branch installer
id: cache-installer
uses: actions/cache@v4
with:
path: dist/orca-windows-setup.exe
key: >-
crash-survival-installer-${{ hashFiles(
'src/**',
'!src/**/*.test.*',
'!src/**/*.bench.*',
'config/**',
'!config/**/*.test.*',
'!config/reliability-gates.jsonc',
'!config/max-lines-baseline.txt',
'!config/vitest.config.ts',
'build-plugins/**',
'native/**',
'resources/**',
'electron.vite.config.ts',
'tsconfig.json',
'vite.web.config.ts',
'.npmrc',
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml'
) }}
# Why: production edits miss the installer cache by design, but Electron
# and NSIS downloads are lockfile-owned and need not be fetched again.
- name: Cache electron-builder downloads
if: steps.cache-installer.outputs.cache-hit != 'true'
uses: actions/cache@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: crash-survival-electron-builder-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
crash-survival-electron-builder-
- name: Build Windows installer (unsigned)
if: steps.cache-installer.outputs.cache-hit != 'true'
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
pnpm run build:desktop
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
# Why: silent-install the branch build so the crash harness has a packaged
# Orca.exe to drive. A clean CI runner installs to the default per-user
# location (%LOCALAPPDATA%\Programs\Orca), which the harness auto-locates.
- name: Silent-install branch build
shell: pwsh
run: |
$exe = "dist/orca-windows-setup.exe"
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
Start-Process -FilePath $exe -ArgumentList '/S' -Wait
$installed = Join-Path $env:LOCALAPPDATA 'Programs/Orca/Orca.exe'
if (-not (Test-Path $installed)) {
$installed = Join-Path $env:LOCALAPPDATA 'Programs/orca/Orca.exe'
}
if (-not (Test-Path $installed)) { throw "Installed Orca.exe not found after silent install" }
"ORCA_EXE=$installed" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# Why: crash ONLY the app main and assert the relocated daemon + its shell
# survive, then that relaunch adopts them and input causes no pwsh FailFast.
- name: Run crash-survival harness
id: harness
shell: pwsh
env:
ORCA_E2E_DIAG_DIR: artifacts/diag
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$log = "artifacts/crash-survival-output.log"
node tools/win-crash-survival-e2e/run.mjs `
--expect "$env:EXPECT" `
--exe-path "$env:ORCA_EXE" `
--soak-seconds 8 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
- name: Upload crash-survival output
if: always()
uses: actions/upload-artifact@v7
with:
name: win-crash-survival-output
path: |
artifacts/crash-survival-output.log
artifacts/diag/**
retention-days: 7
if-no-files-found: warn
@@ -73,7 +73,7 @@ jobs:
uses: actions/cache@v4
with:
path: dist/orca-windows-setup.exe
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }}
- name: Build Windows installer (unsigned)
if: steps.cache-installer.outputs.cache-hit != 'true'
@@ -0,0 +1,360 @@
# Windows inner-binary signing rehearsal.
#
# Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed
# inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires
# a two-request flow: sign the unpacked PE files first, then build the NSIS
# installer from the signed tree, then sign the installer. This workflow
# rehearses that entire flow from a branch, end to end, without publishing
# anything — so the release pipeline on main is never at risk while we verify.
#
# Runs only via manual dispatch. Use the test-signing policy for iteration
# (auto-approved test certificate) and release-signing to rehearse the
# production flow (requires manual SignPath approvals). Rehearsed successfully
# with test-signing (run 28987534795) and release-signing (run 28988432001).
name: Windows signing rehearsal
on:
workflow_dispatch:
inputs:
signing-policy-slug:
description: SignPath signing policy
type: choice
default: test-signing
options:
- test-signing
- release-signing
inner-artifact-configuration-slug:
description: SignPath artifact configuration for the inner-binaries zip
type: string
default: windows-inner-binaries-zip
jobs:
rehearse:
# Why: SignPath origin verification requires GitHub-hosted runners, and
# windows-2022 matches the real release job's build environment.
runs-on: windows-2022
timeout-minutes: 360
permissions:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: nothing here pushes; keep the token out of .git/config.
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-win-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-win-
- name: Install dependencies
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: rehearsal builds are never published, so the official-build
# secrets (telemetry key, diagnostics URL) are intentionally omitted.
- name: Build app
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
- name: Package unpacked Windows app
shell: pwsh
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --dir --publish never
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/win-unpacked/Orca.exe')) {
throw 'electron-builder --dir did not produce dist/win-unpacked/Orca.exe'
}
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe, signed vendor DLLs) must
# keep their original signer, so they are excluded from the request.
- name: Stage unsigned PE files for inner signing
id: stage-inner
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
$list = New-Object System.Collections.Generic.List[string]
$skipped = New-Object System.Collections.Generic.List[string]
Get-ChildItem -Path $root -Recurse -File |
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
if ($signature.Status -eq 'Valid') {
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
return
}
$destination = Join-Path $stage.FullName $relative
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
Copy-Item -Path $_.FullName -Destination $destination -Force
$list.Add($relative)
}
if (-not ($list -contains 'Orca.exe')) {
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
}
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
}
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
$list | ForEach-Object { Write-Host " $_" }
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-unsigned-${{ github.run_id }}
path: signing-stage/**
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
- name: Submit inner binaries signing request
id: submit-inner-signing
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
artifact-configuration-slug: ${{ inputs.inner-artifact-configuration-slug || 'windows-inner-binaries-zip' }}
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
wait-for-completion: false
- name: Download signed inner binaries from SignPath
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-ApiToken $env:SIGNPATH_API_TOKEN `
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-inner.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 3600
New-Item -ItemType Directory -Path signed-inner -Force
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
# Why: copy back strictly by the staged list so a layout mismatch in the
# returned artifact fails loudly instead of silently shipping a mix of
# signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$failures = New-Object System.Collections.Generic.List[string]
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
Select-Object -First 1
if ($null -eq $signed) {
$failures.Add("missing from signed artifact: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
$failures.Add("returned without a signature: $relative")
continue
}
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
}
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
}
- name: Build NSIS installer from signed unpacked app
shell: pwsh
run: |
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
}
- name: Upload unsigned Windows installer for SignPath
id: upload-unsigned-installer
uses: actions/upload-artifact@v7
with:
name: orca-windows-installer-unsigned-${{ github.run_id }}
path: dist/orca-windows-setup.exe
if-no-files-found: error
- name: Submit Windows installer signing request
id: submit-installer-signing
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
artifact-configuration-slug: github-actions-windows-installer
github-artifact-id: ${{ steps.upload-unsigned-installer.outputs.artifact-id }}
wait-for-completion: false
- name: Download signed Windows installer from SignPath
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-installer-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-ApiToken $env:SIGNPATH_API_TOKEN `
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-windows.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 14400
New-Item -ItemType Directory -Path signed-windows -Force
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
# Why: signing changes installer bytes, so the updater metadata must be
# regenerated exactly like the release job's staging step does.
- name: Stage signed Windows installer and regenerate updater metadata
shell: pwsh
run: |
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
if ($null -eq $signedInstaller) {
throw 'Signed Windows installer was not returned by SignPath.'
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$installer = Get-Item 'dist/orca-windows-setup.exe'
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
$stream = [System.IO.File]::OpenRead($installer.FullName)
try {
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hash = [Convert]::ToBase64String($sha512.ComputeHash($stream))
} finally {
if ($null -ne $sha512) { $sha512.Dispose() }
$stream.Dispose()
}
$latestYml = Get-Content -Path 'dist/latest.yml' -Raw
$latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', {
param($match)
"$($match.Groups[1].Value)sha512: $hash"
})
$latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)"
$latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)"
Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline
# Why: this is the pass/fail heart of the rehearsal — the same evidence
# check that will later gate releases (see PR #7170). Test certificates
# do not chain to a trusted root, so Status=Valid is only required for
# release-signing runs; test-signing runs require a present signature.
- name: Verify signatures end to end
shell: pwsh
env:
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }}
run: |
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
$requireValid = $env:SIGNING_POLICY -eq 'release-signing'
function Test-Signature([string]$label, [string]$path) {
$signature = Get-AuthenticodeSignature -FilePath $path
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $label, $subject
$script:report.Add($line)
Write-Host $line
if ($null -eq $signature.SignerCertificate -or $signature.Status -eq 'NotSigned') {
$script:failures.Add("unsigned: $label")
} elseif ($script:requireValid -and $signature.Status -ne 'Valid') {
$script:failures.Add("not Valid under release-signing: $label ($($signature.Status))")
} elseif ($script:requireValid -and $subject -notlike '*CN=SignPath Foundation*') {
$script:failures.Add("unexpected signer: $label ($subject)")
}
}
Test-Signature 'installer: orca-windows-setup.exe' 'dist/orca-windows-setup.exe'
# Extract the signed installer and verify the files a user actually
# gets on disk — including the exact file from issue #7785.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
$root = Resolve-Path 'extracted-app'
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$path = Join-Path $root $relative
if (-not (Test-Path $path)) {
$failures.Add("missing from installer payload: $relative")
continue
}
Test-Signature "installed: $relative" $path
}
Set-Content -Path 'signing-evidence.txt' -Value ($report -join "`n")
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signing rehearsal failed with $($failures.Count) problems."
}
Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries plus the installer are signed."
- name: Upload rehearsal evidence and installer
if: always()
uses: actions/upload-artifact@v7
with:
name: windows-signing-rehearsal-${{ inputs.signing-policy-slug || 'test-signing' }}-${{ github.run_id }}
path: |
signing-evidence.txt
inner-signing-list.txt
dist/orca-windows-setup.exe
dist/orca-windows-setup.exe.blockmap
dist/latest.yml
retention-days: 7
@@ -0,0 +1,103 @@
name: Windows terminal restart E2E
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- '.github/workflows/windows-terminal-restart-e2e.yml'
- 'package.json'
- 'pnpm-lock.yaml'
- 'electron.vite.config.ts'
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'tests/playwright.config.ts'
- 'tests/e2e/global-setup.ts'
- 'tests/e2e/global-teardown.ts'
- 'tests/e2e/helpers/**'
- 'tests/e2e/restart-restore-terminal-input.spec.ts'
- 'tests/e2e/restored-terminal-input-readiness.unit.test.ts'
- 'tests/e2e/terminal-probe-input-sequence.ts'
- 'tests/e2e/terminal-probe-input-sequence.unit.test.ts'
- 'tests/e2e/terminal-restart-persistence.spec.ts'
- 'src/main/daemon/**'
- 'src/main/ipc/pty*.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/preload/**'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/shared/pty-session-id-format.ts'
workflow_dispatch:
inputs:
ref:
description: Ref to check out
required: false
type: string
permissions:
contents: read
concurrency:
group: windows-terminal-restart-e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
windows-terminal-restart:
name: Windows terminal restart regressions
runs-on: windows-2022
timeout-minutes: 30
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
# Why: pnpm must exist before setup-node resolves its dependency cache.
- 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: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Electron app for Windows E2E
run: pnpm exec electron-vite build --mode e2e
- name: Run Windows terminal restart regressions
env:
SKIP_BUILD: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
ORCA_REQUIRE_WINDOWS_TERMINAL_RESTART_E2E: '1'
# Why: pnpm forwards a literal `--` to Playwright, which makes the
# grep and worker flags positional filters instead of CLI options.
run: >-
pnpm run test:e2e
tests/e2e/restart-restore-terminal-input.spec.ts
tests/e2e/terminal-restart-persistence.spec.ts
--grep "clean restart with a live daemon session|cold-restored pane accepts typing|daemon snapshot relaunch preserves"
--workers=1
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: windows-terminal-restart-playwright-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore
+16 -9
View File
@@ -60,6 +60,9 @@ pnpm-debug.log*
# Coverage
coverage/
# Prod release scan output (accidental adds)
prod-release-scan-*.md
# Benchmark run output
/tools/benchmarks/results/*.json
/.bench-fixtures/
@@ -74,20 +77,21 @@ design-docs/
# Machine-local agent hook endpoint files may contain auth tokens.
/agent-hooks/
# Local-only design/planning docs (not checked in).
# Durable docs must live in one of the allow-listed locations below.
# Local-only design/planning docs (not checked in), including most of docs/reference/.
# Durable docs that should be tracked must live in one of the allow-listed
# locations below (assets, readme, STYLEGUIDE, mobile terminal shortcut bar,
# and the tracked reference docs linked from AGENTS.md / README.md).
docs/**
!docs/
!docs/assets/
!docs/assets/**
!docs/readme/
!docs/readme/**
!docs/reference/
!docs/reference/**
# Keep generated React perf scan ledgers local; durable docs still use docs/reference.
docs/reference/react-performance-audit.md
!docs/STYLEGUIDE.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
# Stably CLI (only docs/ are tracked)
.stably/*
@@ -97,9 +101,9 @@ docs/reference/react-performance-audit.md
validation-screenshots/
.stably-browser
# PR verification evidence screenshots are referenced from notes but should not
# be committed.
notes/artifacts/
# Local scratch notes and PR evidence screenshots (not part of the product).
/notes/
/pr-evidence/
# Playwright
test-results/
@@ -119,3 +123,6 @@ src/renderer/src/i18n/locales/.zh-catalog-cache.json
src/renderer/src/i18n/locales/.ko-catalog-cache.json
src/renderer/src/i18n/locales/.ja-catalog-cache.json
src/renderer/src/i18n/locales/.es-catalog-cache.json
# Bench result JSONs are working artifacts
tools/benchmarks/results/terminal-pipeline-*.json
-4
View File
@@ -1,6 +1,2 @@
shamefully-hoist=true
minimum-release-age=4320
minimum-release-age-exclude[]=electron@42.3.3
minimum-release-age-exclude[]=mermaid@11.15.0
minimum-release-age-exclude[]=@mermaid-js/parser@1.1.1
minimum-release-age-exclude[]=serve-sim@0.1.40
+16 -9
View File
@@ -4,17 +4,13 @@
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
## Code Comments: Document the "Why", Briefly
When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does.
Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point.
## Concise/Brief Non-obviosu code comments ONLY
* Only when code is non-obvious, add code comment explaining **why** (not HOW).
* BE CONCISE — ideally 1 line.
## Lint Rules: Do Not Disable Max Lines
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead.
This is enforced in CI by `pnpm check:max-lines-ratchet` (`config/scripts/check-max-lines-ratchet.mjs`). Files already over the limit are grandfathered in `config/max-lines-baseline.txt`; that list may only **shrink**. Adding a new suppression fails the build. If you remove a suppression (great — split it!), run `pnpm check:max-lines-ratchet --prune` to drop its baseline line.
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`.
## File and Module Naming
@@ -36,6 +32,18 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
All changes must consider the SSH use case. Don't assume local-only execution.
## Git Binary Compatibility
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
When adding or changing a Git command:
- Check when every subcommand and option was introduced. For newer behavior, keep a baseline-compatible fallback or degrade safely.
- Use `GitCapabilityCache` with a narrow unsupported-error predicate so recurring operations do not retry a known-invalid command. Do not rely only on `git --version`; wrappers such as `simple-git` do not remove host-version differences.
- Scope capability state to the host that executes Git: native, WSL distro, SSH provider, or relay connection. Cover the first fallback, later cached calls, concurrent probes, and relevant host isolation in tests.
- Keep the real-binary compatibility contract in PR CI current. When adopting a newer Git feature, add its version boundary so the preferred command and fallback both run against representative Git releases.
- Preserve commands that begin with global Git options such as `-c` before the subcommand, including auto-maintenance suppression used by worktree-create fetches.
## Git Provider Compatibility
Source-control and review changes must consider GitLab and other supported git providers, not only GitHub. Keep provider-specific behavior behind explicit checks, and avoid GitHub-only naming for generic review concepts.
@@ -43,6 +51,5 @@ Source-control and review changes must consider GitLab and other supported git p
## GitHub CLI Usage
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
Never commit PR evidence images; attach them to the PR conversation instead (but never use gh-attach).
## Type Declarations: Prefer `.ts` Over `.d.ts`
+10 -8
View File
@@ -3,16 +3,16 @@
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="GitHub stars" /></a>
<a href="https://github.com/stablyai/orca"><img src="https://img.shields.io/github/stars/stablyai/orca?style=flat&amp;label=%E2%98%85&amp;color=08C" alt="GitHub stars" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" alt="Total downloads across all releases" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
<img src="https://img.shields.io/badge/license-MIT-08C?style=flat" alt="License: MIT" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Follow Orca on X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
</p>
<p align="center">
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
<sub><a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a> · <a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.fr.md">Français</a> · <a href="docs/readme/README.pt.md">Português</a></sub>
</p>
<p align="center">
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk)
- **Android:** [Download APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
---
@@ -238,10 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group.
- **WeChat:** Groups 1 and 2 are both full — now you can join the third one.
<img src="docs/assets/wechat-qr.png" alt="WeChat QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr-backup.jpg" alt="Backup WeChat QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr.jpg" alt="WeChat QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
@@ -261,6 +260,9 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
</p>
## Signed Builds
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
## License
Orca is free and open source under the [MIT License](LICENSE).
+131
View File
@@ -0,0 +1,131 @@
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup'
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
// chunk gained `require("electron")` (an import edge added in #7642), and the
// daemon is forked as a plain-Node process where electron cannot be required.
// Nothing in CI executes the built daemon-entry under plain Node, so the leak
// stayed invisible until an adopted old daemon died. This guard fails the
// build when any chunk reachable from a plain-Node fork entry requires
// electron, and smoke-loads daemon-entry under plain Node to prove its module
// graph still resolves.
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
// forked daemon, parcel-watcher and computer sidecars, and the CLI-run
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
] as const
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
function collectReachableChunks(
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): OutputChunk[] {
const seen = new Set<string>()
const reachable: OutputChunk[] = []
const stack = [entry.fileName]
while (stack.length > 0) {
const fileName = stack.pop() as string
if (seen.has(fileName)) {
continue
}
seen.add(fileName)
const chunk = byFileName.get(fileName)
if (!chunk) {
continue
}
reachable.push(chunk)
for (const imported of [...chunk.imports, ...chunk.dynamicImports]) {
stack.push(imported)
}
}
return reachable
}
function assertNoElectronRequire(
entryName: string,
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): void {
for (const chunk of collectReachableChunks(entry, byFileName)) {
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
throw new Error(
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
)
}
}
}
// Why: proves the whole daemon-entry graph resolves under plain Node (no
// unresolved requires). require("electron") does not throw in a dev tree with
// node_modules present, so the static scan above — not this smoke — is the
// electron regression guard; this only catches gross load failures.
function smokeLoadDaemonEntry(outputDir: string): void {
const entryPath = join(outputDir, 'daemon-entry.js')
const result = spawnSync(process.execPath, [entryPath], {
encoding: 'utf8',
timeout: 15_000
})
if (result.error) {
throw new Error(
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
`${result.error.message}`
)
}
const stderr = result.stderr ?? ''
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (!stderr.includes('Usage: daemon-entry')) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
)
}
}
export function createPlainNodeEntryGuardPlugin(): Plugin {
return {
name: 'orca-plain-node-entry-guard',
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
// every rebuild, and the guard only needs to gate produced builds.
if (this.meta.watchMode) {
return
}
const chunks = Object.values(bundle).filter(
(item): item is OutputChunk => item.type === 'chunk'
)
const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]))
const entryByName = new Map<string, OutputChunk>()
for (const chunk of chunks) {
if (chunk.isEntry && chunk.name) {
entryByName.set(chunk.name, chunk)
}
}
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName)
}
}
if (entryByName.has('daemon-entry') && options.dir) {
smokeLoadDaemonEntry(options.dir)
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
ARG BASE_IMAGE=ubuntu:24.04
FROM ${BASE_IMAGE}
ARG LIBASOUND_PACKAGE=libasound2t64
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
dbus-x11 \
jq \
libatk-bridge2.0-0 \
libatspi2.0-0 \
"${LIBASOUND_PACKAGE}" \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxss1 \
procps \
util-linux \
xauth \
xvfb \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
COPY config/docker/headless-pairing/run-appimage-case.sh /usr/local/bin/run-appimage-case
ENTRYPOINT ["/usr/local/bin/run-appimage-case"]
@@ -0,0 +1,31 @@
FROM node:24-bookworm
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
git \
libasound2 \
libatk-bridge2.0-0 \
libatspi2.0-0 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
libsecret-1-dev \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxss1 \
pkg-config \
python3 \
rpm \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable \
&& corepack prepare pnpm@10.24.0 --activate
WORKDIR /workspace
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
case_name=${1:?launch case is required}
appimage=${ORCA_TEST_APPIMAGE:-/artifacts/squashfs-root/AppRun}
timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-12}
pairing_address=${ORCA_PAIRING_ADDRESS:-127.0.0.1}
port=${ORCA_SERVE_PORT:-0}
state_dir="/tmp/orca-${case_name}"
if ((EUID == 0)); then
mkdir -p "$state_dir/config" "$state_dir/cache"
chown -R orca:orca "$state_dir"
# Why: packaged serve should exercise the same unprivileged account required by production systemd guidance.
exec runuser --user orca --preserve-environment -- "$0" "$@"
fi
mkdir -p "$state_dir/config" "$state_dir/cache"
if [[ "$appimage" == *.AppImage ]]; then
is_appimage=1
launcher=("$appimage" --appimage-extract-and-run)
else
is_appimage=0
launcher=("$appimage")
fi
app_args=("${launcher[@]}")
if [[ ${ORCA_TEST_NO_SANDBOX:-1} == 1 ]]; then
app_args+=(--no-sandbox)
fi
app_args+=(serve --port "$port" --pairing-address "$pairing_address")
if [[ ${ORCA_READY_JSON:-0} == 1 ]]; then
app_args+=(--json)
fi
if [[ ${ORCA_NO_PAIRING:-0} == 1 ]]; then
app_args+=(--no-pairing)
fi
case "$case_name" in
direct)
command=("${app_args[@]}")
;;
xvfb)
command=(xvfb-run -a "${app_args[@]}")
;;
dbus-xvfb)
command=(dbus-run-session -- xvfb-run -a "${app_args[@]}")
;;
journal)
command=(setsid --wait xvfb-run -a "${app_args[@]}")
;;
*)
echo "Unknown launch case: $case_name" >&2
exit 64
;;
esac
export HOME="$state_dir"
export XDG_CONFIG_HOME="$state_dir/config"
export XDG_CACHE_HOME="$state_dir/cache"
if [[ $is_appimage == 0 ]]; then
export APPDIR=${ORCA_TEST_APPDIR:-"$(dirname "$appimage")"}
fi
if [[ ${ORCA_KEEP_RUNNING:-0} == 1 ]]; then
exec "${command[@]}"
fi
set +e
timeout --signal=TERM --kill-after=2s "${timeout_seconds}s" "${command[@]}"
status=$?
set -e
if ((status == 124)); then
echo "STARTUP_TIMEOUT: no ready contract after ${timeout_seconds}s" >&2
fi
exit "$status"
+89 -4
View File
@@ -2,6 +2,10 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const { join, resolve } = require('node:path')
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs')
const {
assertPackagedDaemonEntryExists,
verifyPackagedDaemonEntryBoots
} = require('./scripts/verify-packaged-daemon-entry.cjs')
const {
createPackagedRuntimeNodeModuleResources,
prunePackagedRuntimeNodeModules,
@@ -14,6 +18,12 @@ const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
}
// Why: freshness detection needs immutable identity metadata from this exact
// app build, but never needs the skill package bytes or a runtime network read.
const skillFreshnessResources = {
from: 'resources/skills',
to: 'skills'
}
// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
// apps. Keeping relay assets as extraResources makes them real directories
// instead of paths hidden inside app.asar.
@@ -25,9 +35,7 @@ const relayExtraResource = {
// from package directories where pnpm's symlink farm is absent. Copy the exact
// runtime dependency closure to Resources/node_modules so bare require() calls
// do not fall through to a developer checkout's node_modules.
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
const commonExtraResources = [relayExtraResource, skillFreshnessResources]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
@@ -58,7 +66,14 @@ module.exports = {
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
// Why: authoritative guide markdown is compiled into out/cli; shipping the
// authoring sources too would duplicate content without a runtime consumer.
'!skill-guides{,/**/*}',
'!tests{,/**/*}',
// Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE);
// it is gitignored, but exclude it defensively so a stray local capture at
// package time never bloats app.asar.
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js',
@@ -68,7 +83,8 @@ module.exports = {
'!tsconfig.json',
// Why: feature-wall media is copied via extraResources so runtime can read
// it from process.resourcesPath; exclude the source copy from app.asar.
'!resources/onboarding/feature-wall/**'
'!resources/onboarding/feature-wall/**',
'!resources/skills/**'
],
// Why: the CLI entry-point lives in out/cli/ but imports shared modules
// from out/shared/ and local hook mutators from out/main/. These paths must be
@@ -130,6 +146,24 @@ module.exports = {
}
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
verifyPackagedMainRuntimeDeps(resourcesDir)
// Why: boot the packaged daemon-entry under plain Node, but only for the
// slice matching the packaging host's arch — daemon-entry.js is JS, yet it
// require()s the native (N-API) node-pty for the TARGET arch, which the host
// Node cannot load cross-arch. `Arch` enum: ia32=0, x64=1, armv7l=2,
// 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) {
verifyPackagedDaemonEntryBoots(resourcesDir)
} else {
// Why: a cross-arch slice can't be booted by the host Node, but the
// unpacked entry must still exist — its absence is a layout regression
// regardless of arch, so only the boot is skipped, not the check.
assertPackagedDaemonEntryExists(resourcesDir)
console.log(
`[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})`
)
}
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
for (const filename of readdirSync(resourcesDir)) {
@@ -143,6 +177,10 @@ module.exports = {
}
if (context.electronPlatformName === 'darwin') {
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
await signMacNotificationStatusHelper(
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
context.packager
)
}
},
win: {
@@ -154,11 +192,16 @@ module.exports = {
},
extraResources: [
...commonExtraResources,
...createPackagedRuntimeNodeModuleResources('win32'),
winSpeechNativeResource,
{
from: 'resources/win32/bin/orca.cmd',
to: 'bin/orca.cmd'
},
{
from: 'native/windows-cli-launcher/.build/orca.exe',
to: 'bin/orca.exe'
},
{
from: 'node_modules/agent-browser/bin/agent-browser-win32-x64.exe',
to: 'agent-browser-win32-x64.exe'
@@ -213,6 +256,7 @@ module.exports = {
notarize: isMacRelease,
extraResources: [
...commonExtraResources,
...createPackagedRuntimeNodeModuleResources('darwin'),
macSpeechNativeResource,
{
from: 'resources/darwin/bin/orca',
@@ -234,6 +278,15 @@ module.exports = {
},
featureWallResources
],
// Why: the notification-status helper must execute from Contents/MacOS —
// on macOS 26 UNUserNotificationCenter aborts (bundleProxyForCurrentProcess
// is nil) for executables launched out of Contents/Resources (#7929).
extraFiles: [
{
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
}
],
target: [
{
target: 'dmg',
@@ -267,6 +320,7 @@ module.exports = {
},
extraResources: [
...commonExtraResources,
...createPackagedRuntimeNodeModuleResources('linux'),
linuxSpeechNativeResource,
{
from: 'resources/linux/bin/orca-ide',
@@ -403,6 +457,37 @@ async function signMacComputerUseHelper(helperAppPath, packager) {
})
}
async function signMacNotificationStatusHelper(helperPath, packager) {
if (!existsSync(helperPath)) {
if (isMacRelease) {
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
}
return
}
const codeSigningInfo =
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
? await packager.codeSigningInfo.value
: null
const identity =
process.env.CSC_NAME ??
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
(isMacRelease ? null : '-')
if (!identity) {
throw new Error('Missing signing identity for orca-notification-status helper')
}
// Why: macOS keys notification records to the code-signing identifier; the
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
// (and any later) `codesign --force` derives the correct identifier. Sign
// before the outer Orca.app is sealed, like the computer-use helper.
const args = ['--force', '--sign', identity]
if (isMacRelease) {
args.push('--options', 'runtime', '--timestamp')
}
args.push(helperPath)
execFileSync('codesign', args, { stdio: 'inherit' })
execFileSync('codesign', ['--verify', '--strict', helperPath], { stdio: 'inherit' })
}
function codesignArgs(identity, targetPath) {
const args = ['--force', '--deep', '--sign', identity]
if (isMacRelease) {
+9 -1
View File
@@ -1 +1,9 @@
[]
[
{
"filePath": "src/renderer/src/lib/agent-background-session-test-state.ts",
"kind": "object-property:title",
"text": "Terminal 1",
"dynamic": false,
"count": 1
}
]
-1
View File
@@ -280,7 +280,6 @@ inline src/renderer/src/lib/pane-manager/pane-manager.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/lib/terminal-links.ts
inline src/renderer/src/lib/worktree-activation.test.ts
inline src/renderer/src/lib/worktree-activation.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
+10 -4
View File
@@ -31,6 +31,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'yaml',
'zod'
]
const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['windows-native-registry']
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
darwin: 'darwin-',
@@ -128,7 +129,7 @@ function isKnownOmittedServeSimDependency(packageName, fromDir) {
}
}
function collectPackagedRuntimePackages() {
function collectPackagedRuntimePackages(electronPlatformName = process.platform) {
const packages = new Map()
const visit = (packageName, fromDir = projectDir) => {
if (packageName === 'electron' || packages.has(packageName)) {
@@ -156,7 +157,12 @@ function collectPackagedRuntimePackages() {
}
}
for (const packageName of PACKAGED_RUNTIME_PACKAGE_ROOTS) {
// Why: cross-builds must select native dependencies from the artifact target, not the build host.
const packageRoots = [
...PACKAGED_RUNTIME_PACKAGE_ROOTS,
...(electronPlatformName === 'win32' ? WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS : [])
]
for (const packageName of packageRoots) {
visit(packageName)
}
@@ -183,8 +189,8 @@ function collectPackagedRuntimePackages() {
return [...packages.entries()].sort(([left], [right]) => left.localeCompare(right))
}
function createPackagedRuntimeNodeModuleResources() {
return collectPackagedRuntimePackages().map(([packageName, packageDir]) => ({
function createPackagedRuntimeNodeModuleResources(electronPlatformName = process.platform) {
return collectPackagedRuntimePackages(electronPlatformName).map(([packageName, packageDir]) => ({
from: packageDir,
to: join('node_modules', ...packageName.split('/'))
}))
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,71 @@
const { createHash } = require('node:crypto')
const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs')
const { join, resolve } = require('node:path')
const EXPECTED_NODE_PTY_VERSION = '1.1.0'
const ORIGINAL_SOURCE_SHA256 = '0d010879bb6680a0253d44363183d53e631f42972594eb6dcb1fb842c8c85e52'
const PATCHED_SOURCE_SHA256 = '84df20cfe711a88d2bef35078615c58a6ce14f39348a4aef40e852b854dcd857'
const ORIGINAL_BODY = 'var consoleProcessList = getConsoleProcessList(shellPid);'
const PATCHED_BODY = `var consoleProcessList;
try {
consoleProcessList = getConsoleProcessList(shellPid);
}
catch (_a) {
// Why: AttachConsole can fail without a Win32 console; use node-pty's timeout fallback immediately.
consoleProcessList = [shellPid];
}`
function inspectNodePtyConsoleListAgent(relayDir = process.cwd()) {
const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty')
const packageJsonPath = join(nodePtyDir, 'package.json')
const agentPath = join(nodePtyDir, 'lib', 'conpty_console_list_agent.js')
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) {
throw new Error(
`Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}`
)
}
const source = readFileSync(agentPath, 'utf8')
return { agentPath, source }
}
function assertPatchedNodePtyConsoleListAgent(relayDir = process.cwd()) {
const inspected = inspectNodePtyConsoleListAgent(relayDir)
if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) {
throw new Error('node-pty ConPTY console-list fallback is not installed')
}
}
function patchNodePtyConsoleListAgent(relayDir = process.cwd()) {
const inspected = inspectNodePtyConsoleListAgent(relayDir)
const sourceHash = sourceSha256(inspected.source)
if (sourceHash === PATCHED_SOURCE_SHA256) {
return
}
if (sourceHash !== ORIGINAL_SOURCE_SHA256) {
throw new Error('Refusing to patch unexpected node-pty console-list agent source')
}
const patchedSource = inspected.source.replace(ORIGINAL_BODY, PATCHED_BODY)
const temporaryPath = `${inspected.agentPath}.orca-patch-${process.pid}`
// Why: a terminated remote install must leave either known source version recoverable on reconnect.
try {
writeFileSync(temporaryPath, patchedSource)
renameSync(temporaryPath, inspected.agentPath)
} finally {
rmSync(temporaryPath, { force: true })
}
assertPatchedNodePtyConsoleListAgent(relayDir)
}
function sourceSha256(source) {
return createHash('sha256').update(source).digest('hex')
}
if (require.main === module) {
patchNodePtyConsoleListAgent()
}
module.exports = {
assertPatchedNodePtyConsoleListAgent,
patchNodePtyConsoleListAgent
}
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,8 @@ import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
import ts from 'typescript'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
+20 -1
View File
@@ -2,12 +2,19 @@
import { spawnSync } from 'node:child_process'
if (process.platform === 'win32') {
runNodeScript('config/scripts/build-windows-cli-launcher.mjs')
process.exit(0)
}
if (process.platform !== 'darwin') {
console.log(`[native-build] no macOS native computer build required on ${process.platform}`)
process.exit(0)
}
runPnpmScript('build:computer-macos')
runPnpmScript('build:notification-status-macos')
process.exit(0)
function runPnpmScript(scriptName) {
const npmExecPath = process.env.npm_execpath
@@ -22,5 +29,17 @@ function runPnpmScript(scriptName) {
if (result.signal) {
process.kill(process.pid, result.signal)
}
process.exit(result.status ?? (result.error ? 1 : 0))
if (result.status !== 0 || result.error) {
process.exit(result.status ?? 1)
}
}
function runNodeScript(scriptPath) {
const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' })
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.status !== 0 || result.error) {
process.exit(result.status ?? 1)
}
}
@@ -0,0 +1,97 @@
#!/usr/bin/env node
// Builds the orca-notification-status helper binary.
//
// The helper reads UNUserNotificationCenter settings for the app it ships
// inside (see native/notification-status-macos/main.swift). The target
// CFBundleIdentifier is embedded as a __TEXT,__info_plist section so every
// later `codesign --force` pass (electron-builder's signing, the dev runner's
// ad-hoc deep sign) derives the correct code identifier automatically —
// macOS keys notification records to that identifier.
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const repoRoot = path.resolve(import.meta.dirname, '../..')
const sourcePath = path.join(repoRoot, 'native', 'notification-status-macos', 'main.swift')
const defaultOutputPath = path.join(
repoRoot,
'native',
'notification-status-macos',
'.build',
'release',
'orca-notification-status'
)
if (process.platform !== 'darwin') {
process.exit(0)
}
const args = process.argv.slice(2)
const bundleId = readArg('--bundle-id') ?? 'com.stablyai.orca'
const outputPath = readArg('--output') ?? defaultOutputPath
// Why: dev launches only need the host architecture; release builds ship a
// universal binary matching the app's x64 + arm64 targets.
const singleArch = args.includes('--single-arch')
const workDir = path.join(tmpdir(), `orca-notification-status-${process.pid}`)
mkdirSync(workDir, { recursive: true })
try {
const plistPath = path.join(workDir, 'Info.plist')
writeFileSync(plistPath, embeddedInfoPlist(bundleId), 'utf8')
const triples = singleArch
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
const builtBinaries = triples.map((triple) => {
const output = path.join(workDir, `orca-notification-status-${triple}`)
execFileSync(
'swiftc',
[
'-O',
sourcePath,
'-target',
triple.replace('-apple-macosx', '-apple-macosx11.0'),
'-o',
output,
'-Xlinker',
'-sectcreate',
'-Xlinker',
'__TEXT',
'-Xlinker',
'__info_plist',
'-Xlinker',
plistPath
],
{ stdio: 'inherit' }
)
return output
})
mkdirSync(path.dirname(outputPath), { recursive: true })
if (builtBinaries.length === 1) {
execFileSync('cp', [builtBinaries[0], outputPath])
} else {
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
}
execFileSync('chmod', ['755', outputPath])
} finally {
rmSync(workDir, { recursive: true, force: true })
}
function readArg(name) {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}
function embeddedInfoPlist(identifier) {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>${identifier}</string>
<key>CFBundleName</key>
<string>orca-notification-status</string>
</dict>
</plist>
`
}
+101 -8
View File
@@ -1,22 +1,38 @@
#!/usr/bin/env node
/**
* Bundle the relay daemon into a single relay.js file per platform.
* Bundle the relay daemon and its crash-isolated watcher child per platform.
*
* The relay runs on remote hosts via `node relay.js`, so it must be a
* self-contained CommonJS bundle with no external dependencies beyond
* The relay runs on remote hosts via `node relay.js`, so both outputs use
* self-contained CommonJS bundles with no external dependencies beyond
* Node.js built-ins. Native addons (node-pty, @parcel/watcher) are
* marked external and expected to be installed on the remote or
* gracefully degraded.
*/
import { build } from 'esbuild'
import { createHash } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const __dirname = import.meta.dirname
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const MANAGED_HOOK_RUNTIME_ENTRY = join(
ROOT,
'src',
'main',
'agent-hooks',
'managed-hook-runtime.ts'
)
const JSONC_PARSER_ESM_ENTRY = join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
ROOT,
'config',
'relay-assets',
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
)
const PLATFORMS = [
'linux-x64',
@@ -42,7 +58,46 @@ for (const platform of PLATFORMS) {
outfile: join(outDir, 'relay.js'),
// Native addons cannot be bundled — they must exist on the remote host.
// The relay gracefully degrades when they are absent.
external: ['node-pty', '@parcel/watcher'],
external: ['node-pty', '@parcel/watcher', 'electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
if (platform.startsWith('win32-')) {
copyFileSync(
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
)
}
await build({
entryPoints: [WATCHER_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'relay-watcher.js'),
external: ['@parcel/watcher'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'managed-hook-runtime.js'),
// Why: jsonc-parser's default UMD build keeps relative dynamic requires
// that break after bundling; its ESM entry is equivalent and self-contained.
alias: { 'jsonc-parser': JSONC_PARSER_ESM_ENTRY },
sourcemap: false,
minify: true,
define: {
@@ -51,12 +106,50 @@ for (const platform of PLATFORMS) {
})
// Why: include a content hash so the deploy check detects code changes
// even when RELAY_VERSION hasn't been bumped (common during development).
// even when RELAY_VERSION hasn't been bumped. Hash every executable module
// so a companion-only change always deploys beside the matching relay host.
const relayContent = readFileSync(join(outDir, 'relay.js'))
const hash = createHash('sha256').update(relayContent).digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
const hash = createHash('sha256')
.update(relayContent)
.update(watcherContent)
.update(managedHookRuntimeContent)
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
if (platform.startsWith('win32-')) {
hash.update(readFileSync(join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)))
}
const contentHash = hash.digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`)
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
}
// WSL agent-hook relay: a hooks-only guest receiver launched inside WSL
// distros via wsl.exe. Pure Node built-ins (no node-pty/@parcel/watcher),
// so a single platform-independent bundle suffices; it ships inside the
// Windows app via the same out/relay extraResources mapping.
{
const wslEntry = join(ROOT, 'src', 'relay', 'wsl-agent-hook-relay.ts')
const outDir = join(ROOT, 'out', 'relay', 'wsl')
mkdirSync(outDir, { recursive: true })
await build({
entryPoints: [wslEntry],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'wsl-agent-hook-relay.js'),
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
const content = readFileSync(join(outDir, 'wsl-agent-hook-relay.js'))
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
console.log(`Built WSL hook relay → ${outDir}/wsl-agent-hook-relay.js`)
}
console.log('Relay build complete.')
@@ -0,0 +1,60 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
if (process.platform !== 'win32') {
// Why: electron-builder treats a skipped native build like success and can
// continue toward a Windows package whose declared orca.exe does not exist.
throw new Error(
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
)
}
const repoRoot = resolve(import.meta.dirname, '../..')
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
const compilerPath = findFrameworkCompiler(process.env)
if (!compilerPath) {
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
}
mkdirSync(dirname(outputPath), { recursive: true })
const result = spawnSync(
compilerPath,
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
{ cwd: repoRoot, stdio: 'inherit' }
)
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.error) {
throw result.error
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
function defaultOutputPath(projectRoot) {
return join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe')
}
function findFrameworkCompiler(env) {
const windowsDirectory = env.WINDIR ?? env.SystemRoot
if (!windowsDirectory) {
return null
}
const candidates = [
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
]
return candidates.find((candidate) => existsSync(candidate)) ?? null
}
function readArg(name) {
const index = process.argv.indexOf(name)
return index >= 0 ? process.argv[index + 1] : undefined
}
@@ -0,0 +1,92 @@
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
// keep the larger allowance scoped to the real compiler integration test.
function itWindows(name, test) {
const runner = process.platform === 'win32' ? it : it.skip
runner(name, { timeout: 15_000 }, test)
}
describe('Windows CLI launcher', () => {
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
const result = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', join(outputRoot, 'orca.exe')],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('Windows CLI launcher')
expect(result.stderr).toContain('Windows host')
} finally {
rmSync(outputRoot, { recursive: true, force: true })
}
})
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
try {
const resourcesPath = join(appRoot, 'resources')
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
mkdirSync(dirname(cliPath), { recursive: true })
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
writeFileSync(
cliPath,
`process.stdout.write(JSON.stringify({
argv: process.argv.slice(2),
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
nodeOptions: process.env.NODE_OPTIONS ?? null,
orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null
}))\n`,
'utf8'
)
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two'
const powershell = spawnSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
'& $env:ORCA_TEST_LAUNCHER orchestration send --body $env:ORCA_TEST_BODY --json'
],
{
encoding: 'utf8',
env: {
...process.env,
NODE_OPTIONS: '--no-warnings',
ORCA_TEST_BODY: body,
ORCA_TEST_LAUNCHER: launcherPath
}
}
)
expect(powershell.status, powershell.stderr).toBe(0)
expect(JSON.parse(powershell.stdout)).toEqual({
argv: ['orchestration', 'send', '--body', body, '--json'],
electronRunAsNode: '1',
nodeOptions: null,
orcaNodeOptions: '--no-warnings'
})
} finally {
rmSync(appRoot, { recursive: true, force: true })
}
})
})
@@ -18,7 +18,11 @@ if (reportPaths.length === 0) {
const BUDGETS = {
maxMedianKeyLatencyMs: 75,
maxWorstKeyLatencyMs: 300,
maxRevisitLatencyMs: 300,
maxTimerDriftMs: 150,
// Why: mirrors MAX_TIMER_DRIFT_UNDER_LOAD_MS in artificial-opencode-terminal-load.spec.ts
// so injected multi-pane redraw rows are not judged against the unloaded ceiling.
maxTimerDriftUnderLoadMs: 2_500,
maxScrollLatencyMs: 150,
maxRestoreLatencyMs: 1000,
maxRendererQueuedChars: 2 * 1024 * 1024,
@@ -26,6 +30,17 @@ const BUDGETS = {
maxRendererDroppedBacklogs: 0
}
// Why: only these annotation types assert against MAX_TIMER_DRIFT_UNDER_LOAD_MS
// in the e2e suite; other rows keep the unloaded smoke ceiling.
function isUnderLoadTimerDriftScenario(scenario) {
return (
scenario === 'opencode-same-workspace-typing' ||
scenario === 'opencode-cross-workspace-typing' ||
scenario.startsWith('opencode-scale-same-workspace-') ||
scenario.startsWith('opencode-scale-cross-workspace-')
)
}
function parseMs(value, fieldName, row, failures) {
if (value == null || value === '') {
return null
@@ -80,10 +95,18 @@ function validateRow(row) {
BUDGETS.maxWorstKeyLatencyMs,
'ms'
)
addBudgetCheck(
'revisit latency',
parseMs(row.revisit, 'revisit', row, failures),
BUDGETS.maxRevisitLatencyMs,
'ms'
)
addBudgetCheck(
'timer drift',
parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures),
BUDGETS.maxTimerDriftMs,
isUnderLoadTimerDriftScenario(row.scenario)
? BUDGETS.maxTimerDriftUnderLoadMs
: BUDGETS.maxTimerDriftMs,
'ms'
)
addBudgetCheck(
@@ -113,6 +136,14 @@ function validateRow(row) {
parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures),
BUDGETS.maxRendererDroppedBacklogs
)
// Why: parked-memory rows carry heap/view-count metrics with no latency
// budget; recognize them so memory-only scenarios pass the gate instead of
// tripping the "no recognized budget metrics" guard.
for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) {
if (parseCount(row[fieldName], fieldName, row, failures) != null) {
checkedMetricCount += 1
}
}
if (checkedMetricCount === 0) {
failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`)
}
@@ -58,6 +58,7 @@ describe('check-terminal-perf-report-budgets', () => {
'frames=180',
'median=2.9ms',
'worst=5.9ms',
'revisit=42.0ms',
'maxTimerDrift=12.1ms',
'scroll=149.9ms',
'restore=642.0ms',
@@ -82,6 +83,7 @@ describe('check-terminal-perf-report-budgets', () => {
'frames=60',
'median=76.0ms',
'worst=301.0ms',
'revisit=301.0ms',
'maxTimerDrift=151.0ms',
'scroll=151.0ms',
'restore=1001.0ms',
@@ -96,6 +98,7 @@ describe('check-terminal-perf-report-budgets', () => {
expect(result.status).toBe(1)
expect(result.stderr).toContain('median typing latency 76ms exceeded budget 75ms')
expect(result.stderr).toContain('worst typing latency 301ms exceeded budget 300ms')
expect(result.stderr).toContain('revisit latency 301ms exceeded budget 300ms')
expect(result.stderr).toContain('timer drift 151ms exceeded budget 150ms')
expect(result.stderr).toContain('scroll latency 151ms exceeded budget 150ms')
expect(result.stderr).toContain('restore latency 1001ms exceeded budget 1000ms')
@@ -104,6 +107,41 @@ describe('check-terminal-perf-report-budgets', () => {
expect(result.stderr).toContain('renderer dropped backlogs 1 exceeded budget 0')
})
// Why: covers every isUnderLoadTimerDriftScenario branch (two exact + two
// prefix matches) so a predicate regression cannot silently re-apply the
// unloaded 150ms ceiling to multi-pane redraw rows.
it.each([
'opencode-same-workspace-typing',
'opencode-cross-workspace-typing',
'opencode-scale-same-workspace-50',
'opencode-scale-cross-workspace-50'
])('applies the under-load timer-drift budget to %s', (scenario) => {
const passPath = writeReport(
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=1510.0ms'].join(
' '
),
scenario
)
const passOutput = execFileSync(process.execPath, [scriptPath, passPath], {
cwd: process.cwd(),
encoding: 'utf8'
})
expect(passOutput).toContain('Terminal perf budget check passed for 1 annotation row(s).')
})
it('fails multi-pane redraw scenarios that exceed the under-load timer-drift budget', () => {
const failPath = writeReport(
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=2501.0ms'].join(
' '
),
'opencode-cross-workspace-typing'
)
const failResult = runChecker(failPath)
expect(failResult.status).toBe(1)
expect(failResult.stderr).toContain('timer drift 2501ms exceeded budget 2500ms')
})
it('fails malformed metric values instead of treating them as absent', () => {
const reportPath = writeReport('panes=1 median=999 worst=abcms rendererQueuedChars=wat')
@@ -116,6 +154,31 @@ describe('check-terminal-perf-report-budgets', () => {
expect(result.stderr).toContain('no recognized budget metrics found')
})
it('accepts revisit-only marker rows as budgeted perf evidence', () => {
const reportPath = writeReport('panes=19 revisit=25.7ms heldAckChars=2097184')
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
cwd: process.cwd(),
encoding: 'utf8'
})
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
})
it('accepts parked-memory rows that carry only heap and view-count metrics', () => {
const reportPath = writeReport(
'panes=8 parkedTabs=8 heapUsedMB=87.8 liveTerminals=1 livePaneManagers=1',
'opencode-parked-memory'
)
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
cwd: process.cwd(),
encoding: 'utf8'
})
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
})
it('fails OpenCode annotation rows that contain no budget metrics', () => {
const reportPath = writeReport('panes=1 frames=60')
@@ -0,0 +1,244 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { lstat, readdir } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
const DEFAULT_INTERVAL_MS = 250
function entryType(stat) {
if (stat.isDirectory()) {
return 'directory'
}
if (stat.isFile()) {
return 'file'
}
if (stat.isSymbolicLink()) {
return 'symlink'
}
if (stat.isSocket()) {
return 'socket'
}
return 'other'
}
async function readEntry(absolutePath, relativePath, entries) {
const stat = await lstat(absolutePath)
const type = entryType(stat)
entries.push({
path: relativePath || '.',
type,
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
ino: stat.ino
})
// Why: primary-home symlinks may point at user data outside .codex. The
// tripwire observes the link itself without traversing its target.
if (type !== 'directory') {
return
}
const children = await readdir(absolutePath)
children.sort((left, right) => left.localeCompare(right))
for (const child of children) {
await readEntry(
path.join(absolutePath, child),
relativePath ? path.join(relativePath, child) : child,
entries
)
}
}
export async function snapshotCodexHome(codexHome) {
const resolvedHome = path.resolve(codexHome)
const entries = []
try {
await readEntry(resolvedHome, '', entries)
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error
}
entries.push({ path: '.', type: 'missing' })
}
const digest = createHash('sha256').update(JSON.stringify(entries)).digest('hex')
return { codexHome: resolvedHome, digest, entryCount: entries.length, entries }
}
export function describeCodexHomeChange(before, after) {
if (before.digest === after.digest) {
return null
}
const beforeByPath = new Map(before.entries.map((entry) => [entry.path, entry]))
const afterByPath = new Map(after.entries.map((entry) => [entry.path, entry]))
const changedPaths = [...new Set([...beforeByPath.keys(), ...afterByPath.keys()])]
.filter(
(entryPath) =>
JSON.stringify(beforeByPath.get(entryPath)) !== JSON.stringify(afterByPath.get(entryPath))
)
.sort((left, right) => left.localeCompare(right))
return {
beforeDigest: before.digest,
afterDigest: after.digest,
changedPaths
}
}
// Why: with the real-home flag ON, system-default spawn sites deliberately
// delete CODEX_HOME so native codex resolves the user's real ~/.codex — and on
// Windows the binary ignores the USERPROFILE sandbox, so its own volatile
// runtime churn (root sqlite/WAL/SHM, tmp/, log/) is designed behavior no env
// sandbox can contain. Everything else — auth.json, config.toml,
// .credentials.json, hooks.json, sessions/, any unknown path — stays a hard
// containment violation.
const DESIGNED_SYSTEM_DEFAULT_VOLATILE_PATTERN =
/^(?:[^\\/]+\.sqlite(?:-wal|-shm|-journal)?|tmp(?:[\\/].*)?|log(?:[\\/].*)?)$/i
export function classifyCodexHomeTripwireEvent(event) {
if (event.scanError || !Array.isArray(event.changedPaths)) {
return 'violation'
}
// Why: the root directory's own mtime churns whenever codex adds/removes a
// direct child (e.g. a WAL file); it carries no information beyond the
// substantive paths, so it never decides the classification by itself.
const substantive = event.changedPaths.filter((entryPath) => entryPath !== '.')
if (substantive.length === 0) {
return 'designed-system-default'
}
return substantive.every((entryPath) => DESIGNED_SYSTEM_DEFAULT_VOLATILE_PATTERN.test(entryPath))
? 'designed-system-default'
: 'violation'
}
export async function startCodexPrimaryHomeTripwire(options = {}) {
const primaryHome = path.resolve(options.primaryHome ?? os.homedir())
const codexHome = path.join(primaryHome, '.codex')
const intervalMs = Math.max(25, Number(options.intervalMs ?? DEFAULT_INTERVAL_MS))
const initialSnapshot = await snapshotCodexHome(codexHome)
let currentSnapshot = initialSnapshot
let timer = null
let scanInFlight = false
let stopped = false
const events = []
const scan = async () => {
if (stopped || scanInFlight) {
return
}
scanInFlight = true
try {
const nextSnapshot = await snapshotCodexHome(codexHome)
const change = describeCodexHomeChange(currentSnapshot, nextSnapshot)
if (change) {
const event = { detectedAt: new Date().toISOString(), ...change }
events.push(event)
currentSnapshot = nextSnapshot
await options.onChange?.(event)
}
} catch (error) {
const event = {
detectedAt: new Date().toISOString(),
scanError: error instanceof Error ? error.message : String(error),
changedPaths: []
}
events.push(event)
await options.onChange?.(event)
} finally {
scanInFlight = false
}
}
timer = setInterval(() => void scan(), intervalMs)
// Why: this safety monitor must remain active even when it is the CLI's only open handle.
// Callers explicitly stop it after their validation window closes.
return {
codexHome,
initialSnapshot,
getStatus: () => ({ clean: events.length === 0, events: [...events] }),
scan,
async stop() {
if (timer) {
clearInterval(timer)
}
while (scanInFlight) {
await new Promise((resolve) => setTimeout(resolve, 5))
}
await scan()
stopped = true
return { clean: events.length === 0, events: [...events] }
}
}
}
function parseArgs(argv) {
const options = { primaryHome: os.homedir(), intervalMs: DEFAULT_INTERVAL_MS, once: false }
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
if (arg === '--primary-home') {
const value = argv[index + 1]
if (!value) {
throw new Error('Missing value for --primary-home')
}
options.primaryHome = value
index += 1
} else if (arg === '--interval-ms') {
const value = Number(argv[index + 1])
if (!Number.isFinite(value) || value < 25) {
throw new Error('Invalid --interval-ms')
}
options.intervalMs = value
index += 1
} else if (arg === '--once') {
options.once = true
} else if (arg === '--help') {
console.log(
'Usage: node config/scripts/codex-primary-home-tripwire.mjs [--primary-home <path>] [--interval-ms <n>] [--once]'
)
process.exit(0)
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
return options
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (options.once) {
const snapshot = await snapshotCodexHome(path.join(options.primaryHome, '.codex'))
console.log(JSON.stringify({ ...snapshot, entries: undefined }, null, 2))
return
}
let failed = false
const tripwire = await startCodexPrimaryHomeTripwire({
...options,
onChange: (event) => {
failed = true
console.error('\u001b[31;1m[CODEX HOME TRIPWIRE] PRIMARY ~/.codex CHANGED\u001b[0m')
console.error(JSON.stringify(event, null, 2))
}
})
console.log(
`[CODEX HOME TRIPWIRE] Watching ${tripwire.codexHome}; press Ctrl+C to stop. No file contents are read.`
)
const stop = async () => {
const status = await tripwire.stop()
console.log(
`[CODEX HOME TRIPWIRE] ${status.clean ? 'PASS: no changes' : 'FAIL: changes detected'}`
)
process.exit(failed || !status.clean ? 2 : 0)
}
process.once('SIGINT', () => void stop())
process.once('SIGTERM', () => void stop())
}
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null
if (invokedPath === import.meta.url) {
main().catch((error) => {
console.error(error)
process.exit(1)
})
}
@@ -0,0 +1,178 @@
import { execFileSync, spawn } from 'node:child_process'
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const cleanupPaths: string[] = []
const tripwireScriptPath = path.resolve('config/scripts/codex-primary-home-tripwire.mjs')
const tripwireModuleUrl = pathToFileURL(tripwireScriptPath).href
// Why: native Windows process startup can exceed two seconds under a loaded test worker.
const STANDALONE_START_TIMEOUT_MS = 10_000
afterEach(async () => {
await Promise.all(
cleanupPaths.splice(0).map((cleanupPath) => rm(cleanupPath, { recursive: true }))
)
})
async function createPrimaryHome(): Promise<string> {
const primaryHome = await mkdtemp(path.join(os.tmpdir(), 'orca-codex-tripwire-'))
cleanupPaths.push(primaryHome)
await mkdir(path.join(primaryHome, '.codex'))
return primaryHome
}
describe('Codex primary-home tripwire', () => {
it('keeps the standalone monitor alive until it is stopped', async () => {
const primaryHome = await createPrimaryHome()
const child = spawn(process.execPath, [tripwireScriptPath, '--primary-home', primaryHome], {
stdio: ['ignore', 'pipe', 'pipe']
})
try {
await waitForStdout(child, '[CODEX HOME TRIPWIRE] Watching')
await new Promise((resolve) => setTimeout(resolve, 100))
expect(child.exitCode).toBeNull()
} finally {
child.kill()
}
})
it('detects a write without reading file contents', async () => {
const primaryHome = await createPrimaryHome()
const status = runTripwireModule<{
clean: boolean
events: { changedPaths: string[] }[]
}>(
`
import path from 'node:path'
import { writeFile } from 'node:fs/promises'
const { startCodexPrimaryHomeTripwire } = await import(process.argv[1])
const tripwire = await startCodexPrimaryHomeTripwire({
primaryHome: process.argv[2],
intervalMs: 25
})
await writeFile(path.join(process.argv[2], '.codex', 'hooks.json'), '{"secret":"not-read"}\\n')
await tripwire.scan()
console.log(JSON.stringify(await tripwire.stop()))
`,
[primaryHome]
)
expect(status.clean).toBe(false)
expect(status.events[0].changedPaths).toContain('hooks.json')
expect(JSON.stringify(status)).not.toContain('not-read')
})
// Why: ordinary Windows CI tokens cannot create file symlinks without Developer Mode.
it.skipIf(process.platform === 'win32')(
'does not follow symlinks outside the watched home',
async () => {
const primaryHome = await createPrimaryHome()
const externalRoot = await mkdtemp(path.join(os.tmpdir(), 'orca-codex-tripwire-external-'))
cleanupPaths.push(externalRoot)
const externalFile = path.join(externalRoot, 'credential.txt')
await writeFile(externalFile, 'before')
await symlink(externalFile, path.join(primaryHome, '.codex', 'linked-credential'))
const codexHome = path.join(primaryHome, '.codex')
const before = snapshotCodexHome(codexHome)
await writeFile(externalFile, 'after-with-a-different-size')
const after = snapshotCodexHome(codexHome)
expect(after.digest).toBe(before.digest)
}
)
})
function runTripwireModule<T>(source: string, args: string[]): T {
const stdout = execFileSync(
process.execPath,
['--input-type=module', '--eval', source, tripwireModuleUrl, ...args],
{ encoding: 'utf8' }
)
return JSON.parse(stdout.trim()) as T
}
function snapshotCodexHome(codexHome: string): { digest: string } {
return runTripwireModule<{ digest: string }>(
`
const { snapshotCodexHome } = await import(process.argv[1])
console.log(JSON.stringify(await snapshotCodexHome(process.argv[2])))
`,
[codexHome]
)
}
function waitForStdout(child: ReturnType<typeof spawn>, expectedText: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out waiting for ${expectedText}`)),
STANDALONE_START_TIMEOUT_MS
)
let output = ''
const stdout = child.stdout
if (!stdout) {
clearTimeout(timeout)
reject(new Error('Tripwire child stdout is unavailable'))
return
}
stdout.setEncoding('utf8')
stdout.on('data', (chunk) => {
output += chunk
if (output.includes(expectedText)) {
clearTimeout(timeout)
resolve()
}
})
child.once('exit', (code) => {
clearTimeout(timeout)
reject(new Error(`Tripwire exited early with code ${code}`))
})
})
}
describe('lane-aware tripwire event classification', () => {
it('separates designed system-default churn from containment violations', () => {
const verdicts = runTripwireModule<string[]>(
`
const { classifyCodexHomeTripwireEvent } = await import(process.argv[1])
const cases = [
{ changedPaths: ['.', 'goals_1.sqlite', 'logs_2.sqlite-wal', 'state_5.sqlite-shm', 'memories_1.sqlite-journal', 'tmp', 'tmp/arg0', 'tmp\\\\arg0', 'log', 'log/codex-tui.log'] },
{ changedPaths: ['.'] },
{ changedPaths: ['.', 'auth.json'] },
{ changedPaths: ['auth.json'] },
{ changedPaths: ['config.toml'] },
{ changedPaths: ['.credentials.json'] },
{ changedPaths: ['hooks.json'] },
{ changedPaths: ['sessions/2026/07/rollout-x.jsonl'] },
{ changedPaths: ['skills/SKILL.md'] },
{ changedPaths: ['unknown-file.bin'] },
{ changedPaths: ['goals_1.sqlite', 'auth.json'] },
{ changedPaths: ['sessions/index.sqlite-wal'] },
{ scanError: 'boom', changedPaths: [] },
{}
]
console.log(JSON.stringify(cases.map((tripwireEvent) => classifyCodexHomeTripwireEvent(tripwireEvent))))
`,
[]
)
expect(verdicts).toEqual([
'designed-system-default',
'designed-system-default',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation',
'violation'
])
})
})
@@ -0,0 +1,186 @@
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
const GRACEFUL_CLOSE_TIMEOUT_MS = 10_000
const PROCESS_EXIT_TIMEOUT_MS = 5_000
const FORCE_KILL_WAIT_MS = 2_000
function delay(ms) {
// Why: after Electron dies, this may be the standalone harness's only active
// handle; keeping it referenced ensures daemon cleanup reaches completion.
return new Promise((resolve) => setTimeout(resolve, ms))
}
function hasExited(childProcess) {
return childProcess.exitCode !== null || childProcess.signalCode !== null
}
function waitForExit(childProcess, timeoutMs) {
if (hasExited(childProcess)) {
return Promise.resolve(true)
}
return new Promise((resolve) => {
let settled = false
const finish = (exited) => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
childProcess.off('exit', onExit)
childProcess.off('close', onExit)
resolve(exited)
}
const onExit = () => finish(true)
const timeout = setTimeout(() => finish(false), timeoutMs)
childProcess.once('exit', onExit)
childProcess.once('close', onExit)
})
}
async function withTimeout(promise, timeoutMs, message) {
let timeout = null
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), timeoutMs)
})
])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}
function readPosixDescendantPids(rootPid) {
try {
const output = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8' })
const childrenByParent = new Map()
for (const line of output.split('\n')) {
const [pidText, ppidText] = line.trim().split(/\s+/)
const pid = Number(pidText)
const ppid = Number(ppidText)
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
continue
}
const children = childrenByParent.get(ppid) ?? []
children.push(pid)
childrenByParent.set(ppid, children)
}
const descendants = []
const stack = [...(childrenByParent.get(rootPid) ?? [])]
while (stack.length > 0) {
const pid = stack.pop()
if (!pid) {
continue
}
descendants.push(pid)
stack.push(...(childrenByParent.get(pid) ?? []))
}
return descendants
} catch {
return []
}
}
function killPid(pid, signal) {
try {
process.kill(pid, signal)
} catch {
/* already dead or inaccessible */
}
}
async function forceKillPidTree(pid) {
if (!pid) {
return
}
if (process.platform === 'win32') {
try {
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
/* already dead or taskkill unavailable */
}
return
}
// Why: a detached validation daemon can outlive Electron and retain access
// to the disposable credential root unless the whole tree is captured first.
const pids = [...readPosixDescendantPids(pid), pid]
for (const targetPid of [...pids].toReversed()) {
killPid(targetPid, 'SIGTERM')
}
await delay(FORCE_KILL_WAIT_MS)
for (const targetPid of [...pids].toReversed()) {
killPid(targetPid, 'SIGKILL')
}
}
export async function closeValidationElectronApp(app) {
if (!app) {
return
}
const childProcess = app.process()
try {
await withTimeout(
app.close(),
GRACEFUL_CLOSE_TIMEOUT_MS,
'Timed out closing validation Electron app'
)
if (childProcess) {
const exited = await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
if (!exited) {
await forceKillPidTree(childProcess.pid)
await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
}
}
} catch {
if (childProcess && !hasExited(childProcess)) {
await forceKillPidTree(childProcess.pid)
await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
}
}
}
function readValidationDaemonPids(userDataDir) {
const daemonDir = path.join(userDataDir, 'daemon')
if (!existsSync(daemonDir)) {
return []
}
const pids = []
for (const entry of readdirSync(daemonDir)) {
if (!entry.endsWith('.pid')) {
continue
}
try {
const raw = readFileSync(path.join(daemonDir, entry), 'utf8').trim()
const parsed = JSON.parse(raw)
if (typeof parsed.pid === 'number' && Number.isInteger(parsed.pid)) {
pids.push(parsed.pid)
}
} catch {
const pid = Number(readFileSync(path.join(daemonDir, entry), 'utf8').trim())
if (Number.isInteger(pid)) {
pids.push(pid)
}
}
}
return pids
}
export async function cleanupValidationDaemons(userDataDir) {
// Why: Orca intentionally keeps its daemon warm after app quit, but this
// harness must never leave a process holding disposable account credentials.
for (const pid of readValidationDaemonPids(userDataDir)) {
await forceKillPidTree(pid)
}
}
@@ -110,6 +110,57 @@ describe('computer-use e2e workflow', () => {
}
})
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')
)
const steps = workflow.jobs['native-smoke'].steps
const runs = steps.map((step) => step.run).filter((run) => typeof run === 'string')
const buildIndex = runs.indexOf('pnpm build:electron-vite')
const daemonSmokeIndex = runs.indexOf('node config/scripts/daemon-boot-smoke.mjs')
expect(daemonSmokeIndex, 'native-smoke must boot the built daemon').toBeGreaterThanOrEqual(0)
expect(
buildIndex,
'daemon boot smoke must run after the main bundle is built'
).toBeGreaterThanOrEqual(0)
expect(daemonSmokeIndex).toBeGreaterThan(buildIndex)
})
it('runs the Windows workspace-close daemon repro after the main build', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const steps = workflow.jobs['native-smoke'].steps
const buildIndex = steps.findIndex((step) => step.run === 'pnpm build:electron-vite')
const reproIndex = steps.findIndex(
(step) => step.run === 'node config/scripts/windows-daemon-workspace-close-repro.mjs'
)
expect(reproIndex).toBeGreaterThan(buildIndex)
expect(steps[reproIndex].if).toBe("runner.os == 'Windows'")
expect(workflow.on.pull_request.paths).toContain(
'config/scripts/windows-daemon-workspace-close-repro.mjs'
)
})
it('re-runs the native-smoke job when the daemon bundle graph changes', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const triggerPaths = workflow.on.pull_request.paths
expect(triggerPaths).toEqual(
expect.arrayContaining([
'config/scripts/daemon-boot-smoke.mjs',
'config/scripts/windows-daemon-workspace-close-repro.mjs',
'electron.vite.config.ts',
'build-plugins/**',
'src/main/daemon/**'
])
)
})
it('runs Linux computer-use e2e in the PR native-smoke job under Xvfb', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
@@ -33,4 +33,11 @@ describe('computer-use skill guidance', () => {
expect(skill).toContain('use `--restore-window` so another window does not cover')
expect(skill).toContain('trust the tree over potentially occluded pixels')
})
it('points JSON users to the public accessibility-tree field', () => {
const skill = readFileSync(skillPath, 'utf8')
expect(skill).toContain('`result.snapshot.treeText`')
expect(skill).not.toContain('`result.elements`')
})
})
+218
View File
@@ -0,0 +1,218 @@
/**
* Boots the BUILT terminal daemon (out/main/daemon-entry.js) under plain Node —
* the exact way production forks it (ELECTRON_RUN_AS_NODE = a plain-Node
* process) — and asserts it starts, serves a real PTY, and stops.
*
* Why this exists: native-smoke CI (and packaging) went green while
* v1.4.129-rc.1 shipped a daemon that exited code 1 at module load because an
* electron `require` leaked into its bundle graph. Nothing executed the built
* entry under plain Node, so the outage was invisible until an adopted old
* daemon died in the field. This runs on every PR that touches the daemon.
*
* Hard assertions (fail the job):
* - the daemon signals `{ type: 'ready' }` over IPC within the timeout, and
* - it terminates when asked (no hang / zombie).
* Best-effort (logged skip, never fails): an end-to-end `ptySpawnHealth` RPC,
* because node-pty spawn can be flaky on constrained CI runners.
*/
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import { randomUUID } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const projectDir = resolve(import.meta.dirname, '../..')
const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js')
const READY_TIMEOUT_MS = 30_000
const PTY_HEALTH_TIMEOUT_MS = 10_000
const SHUTDOWN_TIMEOUT_MS = 10_000
function log(message) {
process.stdout.write(`[daemon-boot-smoke] ${message}\n`)
}
// Why: the daemon rejects a hello whose protocol version differs, so read the
// current version from source rather than hardcoding a number that can drift.
function readProtocolVersion() {
const protocolSourcePath = 'src/main/daemon/daemon-protocol-version.ts'
const source = readFileSync(join(projectDir, protocolSourcePath), 'utf8')
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
if (!match) {
throw new Error(`could not read PROTOCOL_VERSION from ${protocolSourcePath}`)
}
return Number(match[1])
}
function makeSocketPath(userDataDir) {
// Why: Windows AF_UNIX-style IPC uses named pipes; POSIX uses a filesystem
// socket kept under the scratch userData dir so cleanup removes it.
if (process.platform === 'win32') {
return `\\\\.\\pipe\\orca-daemon-smoke-${process.pid}-${randomUUID()}`
}
return join(userDataDir, 'daemon.sock')
}
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
// Connects a single control socket, completes the hello handshake, and calls
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
// true on success, false on any failure — never throws.
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
return new Promise((resolveCheck) => {
let settled = false
let buffer = ''
const socket = connect(socketPath)
const finish = (ok, reason) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
socket.destroy()
if (!ok && reason) {
log(`PTY spawn health check skipped (best-effort): ${reason}`)
}
resolveCheck(ok)
}
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
socket.on('error', (err) => finish(false, err.message))
socket.on('connect', () => {
const token = readFileSync(tokenPath, 'utf8').trim()
socket.write(
`${JSON.stringify({
type: 'hello',
version: protocolVersion,
token,
clientId: randomUUID(),
role: 'control'
})}\n`
)
})
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8')
let newlineIdx = buffer.indexOf('\n')
while (newlineIdx !== -1) {
const line = buffer.slice(0, newlineIdx)
buffer = buffer.slice(newlineIdx + 1)
let msg
try {
msg = JSON.parse(line)
} catch {
finish(false, 'invalid response line')
return
}
if (msg.type === 'hello') {
if (!msg.ok) {
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
return
}
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
} else if (msg.id === 'health-1') {
finish(
msg.ok === true,
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
)
return
}
newlineIdx = buffer.indexOf('\n')
}
})
})
}
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
const socketPath = makeSocketPath(userDataDir)
const tokenPath = join(userDataDir, 'daemon.token')
const protocolVersion = readProtocolVersion()
log(`forking ${entryPath} under plain Node (${process.execPath})`)
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
// CI, and this is exactly the runtime where a leaked `require("electron")`
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
})
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk)
})
const cleanup = () => {
if (child.exitCode === null && child.signalCode === null && child.pid) {
try {
child.kill('SIGKILL')
} catch {
// already gone
}
}
rmSync(userDataDir, { recursive: true, force: true })
}
try {
await new Promise((resolveReady, rejectReady) => {
const timer = setTimeout(() => {
rejectReady(
new Error(
`daemon did not signal 'ready' within ${READY_TIMEOUT_MS}ms.\nstderr:\n${stderr}`
)
)
}, READY_TIMEOUT_MS)
child.on('message', (msg) => {
if (msg && typeof msg === 'object' && msg.type === 'ready') {
clearTimeout(timer)
resolveReady()
}
})
child.on('error', (err) => {
clearTimeout(timer)
rejectReady(new Error(`daemon fork errored: ${err.message}\nstderr:\n${stderr}`))
})
child.on('exit', (code, signal) => {
clearTimeout(timer)
rejectReady(
new Error(
`daemon exited before 'ready' (code=${code}, signal=${signal}).\nstderr:\n${stderr}`
)
)
})
})
log('daemon signaled ready')
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
if (ptyHealthy) {
log('ptySpawnHealth OK — daemon spawned a real PTY end-to-end')
}
await new Promise((resolveExit, rejectExit) => {
const timer = setTimeout(() => {
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
}, SHUTDOWN_TIMEOUT_MS)
child.on('exit', (code, signal) => {
clearTimeout(timer)
log(`daemon exited after signal (code=${code}, signal=${signal})`)
resolveExit()
})
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
// Windows has no POSIX signal delivery, so Node maps this to process
// termination. Either way the hard assertion is "it stops, no hang".
child.kill('SIGTERM')
})
log('PASS: daemon booted, served, and shut down under plain Node')
} finally {
cleanup()
}
}
main().catch((error) => {
process.stderr.write(`[daemon-boot-smoke] FAIL: ${error.message}\n`)
process.exitCode = 1
})
@@ -28,7 +28,10 @@ describe('electron-builder config', () => {
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
'!skill-guides{,/**/*}',
'!resources/skills/**',
'!tests{,/**/*}',
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js'
@@ -37,6 +40,12 @@ describe('electron-builder config', () => {
})
it('keeps runtime resources available through extraResources', () => {
for (const platform of ['mac', 'linux', 'win']) {
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
from: 'resources/skills',
to: 'skills'
})
}
expect(electronBuilderConfig.mac.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -58,11 +67,31 @@ describe('electron-builder config', () => {
expect.objectContaining({
from: 'native/computer-use-windows/runtime.ps1',
to: 'computer-use-windows/runtime.ps1'
}),
expect.objectContaining({
from: 'native/windows-cli-launcher/.build/orca.exe',
to: 'bin/orca.exe'
})
])
)
})
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
expect(electronBuilderConfig.mac.extraFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
})
])
)
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
expect.arrayContaining([expect.objectContaining({ to: 'orca-notification-status' })])
)
})
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
@@ -337,6 +366,15 @@ describe('electron-builder config', () => {
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
// Why: afterPack now fails hard when the unpacked daemon entry is
// missing, so the fixture must carry one like a real package layout.
const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
await mkdir(unpackedMainDir, { recursive: true })
await writeFile(
join(unpackedMainDir, 'daemon-entry.js'),
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n',
'utf8'
)
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
@@ -9,6 +9,12 @@ function electronBuilderNativeRebuild(context) {
function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
const args = buildNativeRebuildArgs(context)
if (readPlatformName(context?.platform) === 'win32') {
runner(process.execPath, ['config/scripts/build-windows-cli-launcher.mjs'], {
cwd: projectDir,
stdio: 'inherit'
})
}
runner(process.execPath, args, {
cwd: projectDir,
stdio: 'inherit'
@@ -42,6 +42,31 @@ describe('electron-builder native rebuild hook', () => {
])
})
it('builds the native CLI launcher before packaging Windows resources', () => {
const calls = []
const result = runElectronBuilderNativeRebuild(
{
platform: { nodeName: 'win32' },
arch: 'x64'
},
(...args) => calls.push(args)
)
expect(result).toBe(false)
expect(calls).toEqual([
[
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs'],
expect.objectContaining({ stdio: 'inherit' })
],
[
process.execPath,
['config/scripts/rebuild-native-deps.mjs', '--platform=win32', '--arch=x64', '--force'],
expect.objectContaining({ stdio: 'inherit' })
]
])
})
it('rejects incomplete electron-builder contexts', () => {
expect(() => buildNativeRebuildArgs({ arch: 'x64' })).toThrow(/platform/)
expect(() => buildNativeRebuildArgs({ platform: { nodeName: 'linux' } })).toThrow(/arch/)
+32 -6
View File
@@ -11,7 +11,11 @@ const scriptPath = import.meta.filename
const projectDir = resolve(import.meta.dirname, '../..')
const runtime = readRuntimeArg()
const NATIVE_MODULES = ['node-pty']
const NATIVE_MODULES = [
'node-pty',
...(process.platform === 'win32' ? ['windows-native-registry'] : [])
]
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
const CHILD_CHECK_FLAG = '--check-only'
if (process.argv.includes(CHILD_CHECK_FLAG)) {
@@ -240,6 +244,12 @@ function collectNativeModuleFailures() {
}
function loadNativeModule(moduleName) {
if (moduleName === 'windows-native-registry') {
const registry = require(moduleName)
// Why: the package defers loading its .node addon until the first registry call.
registry.getRegistryKey(registry.HK.CU, 'Environment')
return
}
if (moduleName === 'node-pty') {
loadNodePtyNativeModule()
return
@@ -256,13 +266,27 @@ function loadNodePtyNativeModule() {
// Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
const native = loadNativeModule(nativeName)
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native.dir)) {
assertNodePtyWindowsConptyRuntime(native?.dir)
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) {
throw new Error(
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
)
}
}
function assertNodePtyWindowsConptyRuntime(nativeDir) {
if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) {
return
}
const runtimeDir = resolve(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty')
const missingFile = NODE_PTY_CONPTY_RUNTIME_FILES.find(
(filename) => !existsSync(resolve(runtimeDir, filename))
)
if (missingFile) {
throw new Error(`node-pty ConPTY runtime file is missing: ${resolve(runtimeDir, missingFile)}`)
}
}
function getNodePtyNativeModuleName() {
if (process.platform !== 'win32') {
return 'pty'
@@ -279,10 +303,12 @@ function getPatchedNodePtyRebuildReason() {
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
// patch only lands in the source-built build/Release artifacts.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const missingArtifact = [
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')
].find((artifactPath) => !existsSync(artifactPath))
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
return null
+54 -3
View File
@@ -117,6 +117,40 @@ describe('ensure-native-runtime', () => {
}
}
)
it.skipIf(process.platform === 'win32')(
'keeps the fast path when the platform-specific patched artifacts exist',
() => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeLoadableNativeModules(projectDir, { nativeDir: '../build/Release/' })
writeNodePtyPatchFile(projectDir)
writePatchedNodePtyBuildArtifacts(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
}
)
})
function mkTempProject() {
@@ -159,9 +193,10 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
}
`
)
writeFakeWindowsRegistry(projectDir)
}
function writeLoadableNativeModules(projectDir) {
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
@@ -173,12 +208,26 @@ const { appendFileSync, existsSync } = require('node:fs')
exports.loadNativeModule = function loadNativeModule(nativeName) {
const rebuilt = existsSync(process.env.ORCA_NATIVE_TEST_MARKER)
const dir = rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/'
const dir = ${JSON.stringify(nativeDir)} ??
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
return { dir, module: {} }
}
`
)
writeFakeWindowsRegistry(projectDir)
}
function writeFakeWindowsRegistry(projectDir) {
if (process.platform !== 'win32') {
return
}
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
mkdirSync(registryDir, { recursive: true })
writeFileSync(
join(registryDir, 'index.js'),
'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
)
}
function writeNodePtyPatchFile(projectDir) {
@@ -190,7 +239,9 @@ function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
writeFileSync(join(buildDir, 'pty.node'), '')
writeFileSync(join(buildDir, 'spawn-helper'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
}
}
function writeFakePnpm(binDir) {
@@ -0,0 +1,268 @@
import { constants } from 'node:fs'
import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { parse } from 'yaml'
const SCRIPT_DIR = import.meta.dirname
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
const CANONICAL_GUIDE_NAMES = [
'computer-use',
'linear-tickets',
'orca-cli',
'orca-emulator',
'orca-emulator-android',
'orca-linear',
'orca-per-workspace-env',
'orchestration'
]
// Why: old discovery stubs can outlive a rename indefinitely, so aliases are
// a compatibility ledger: add entries for renames, but never remove them.
const GUIDE_ALIASES = {
'computer-use': [],
'linear-tickets': [],
'orca-cli': [],
'orca-emulator': [],
'orca-emulator-android': [],
'orca-linear': [],
'orca-per-workspace-env': [],
orchestration: []
}
// Why: a stubbed topic ships a hybrid discovery stub as its installable projection while
// `orca skills get <topic>` still serves the full version-matched guide from the binary.
// Migrating a topic here is effectively one-way — earlier fat installs rely on the stub
// landing to converge — so entries are added as skills convert, never removed. The stub
// body lives in skill-stubs/<topic>.md; the projection reuses the guide's own frontmatter.
const STUB_TOPICS = ['orca-cli']
function normalizeMarkdown(markdown) {
return markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
function parseFrontmatter(markdown, sourcePath) {
const normalized = normalizeMarkdown(markdown)
const match = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(normalized)
if (!match) {
throw new Error(`Guide source has no YAML frontmatter: ${sourcePath}`)
}
let values
try {
values = parse(match[1])
} catch (error) {
throw new Error(
`Guide source has invalid YAML frontmatter: ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`
)
}
if (
!values ||
typeof values !== 'object' ||
typeof values.name !== 'string' ||
typeof values.description !== 'string'
) {
throw new Error(`Guide source must declare name and description: ${sourcePath}`)
}
return {
name: values.name,
description: values.description.replace(/\s+/g, ' ').trim()
}
}
function frontmatterBlock(markdown, sourcePath) {
const normalized = normalizeMarkdown(markdown)
const match = /^---[ \t]*\n[\s\S]*?\n---[ \t]*\n/.exec(normalized)
if (!match) {
throw new Error(`Guide source has no YAML frontmatter block: ${sourcePath}`)
}
return match[0]
}
// Why: the stub's routing frontmatter (name + description) must stay byte-identical to the
// guide's — it is the unchanged discovery surface — so we reuse the guide's own block and
// replace only the body. Body normalized to LF with exactly one trailing newline.
function composeStubProjection(guideMarkdown, stubBody, sourcePath) {
const block = frontmatterBlock(guideMarkdown, sourcePath)
const body = normalizeMarkdown(stubBody).replace(/^\n+/, '').replace(/\n*$/, '\n')
return `${block}\n${body}`
}
function constantName(name) {
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
}
function serializeEmbeddedModule(guides) {
const markdownConstants = guides
.map(
(guide) =>
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
)
.join('\n\n')
const guideEntries = guides
.map((guide) => {
const markdownConstant = constantName(guide.name)
return [
' {',
` name: ${JSON.stringify(guide.name)},`,
` description: ${JSON.stringify(guide.description)},`,
` markdown: ${markdownConstant},`,
` fullMarkdown: ${markdownConstant},`,
` aliases: ${JSON.stringify(guide.aliases)}`,
' }'
].join('\n')
})
.join(',\n')
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n}\n\n${markdownConstants}\n\n// Why: no current guide has bundled reference documents, so --full is byte-identical for now.\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
}
function assertAliasContract(guides) {
const canonicalNames = new Set(guides.map((guide) => guide.name))
const seenAliases = new Set()
for (const guide of guides) {
for (const alias of guide.aliases) {
if (canonicalNames.has(alias)) {
throw new Error(`Guide alias collides with canonical name: ${alias}`)
}
if (seenAliases.has(alias)) {
throw new Error(`Guide alias is assigned more than once: ${alias}`)
}
seenAliases.add(alias)
}
}
}
async function assertStubSourcesMatchTopics(repoRoot) {
const stubRoot = path.join(repoRoot, 'skill-stubs')
let names = []
try {
names = (await readdir(stubRoot, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => entry.name.slice(0, -3))
} catch (error) {
// Why: a repo state with no stubbed topics yet has no skill-stubs directory at all.
if (error.code !== 'ENOENT') {
throw error
}
}
const found = names.sort((left, right) => left.localeCompare(right, 'en'))
const expected = [...STUB_TOPICS].sort((left, right) => left.localeCompare(right, 'en'))
if (JSON.stringify(found) !== JSON.stringify(expected)) {
throw new Error(
`skill-stubs sources must match STUB_TOPICS.\nExpected: ${expected.join(', ') || '(none)'}\nFound: ${found.join(', ') || '(none)'}`
)
}
for (const name of STUB_TOPICS) {
if (!CANONICAL_GUIDE_NAMES.includes(name)) {
throw new Error(`Stub topic is not a canonical guide: ${name}`)
}
}
}
async function buildArtifacts(repoRoot = REPO_ROOT) {
const guideRoot = path.join(repoRoot, 'skill-guides')
const sourceFiles = (await readdir(guideRoot, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => entry.name.slice(0, -3))
.sort((left, right) => left.localeCompare(right, 'en'))
const expectedNames = [...CANONICAL_GUIDE_NAMES].sort((left, right) =>
left.localeCompare(right, 'en')
)
if (JSON.stringify(sourceFiles) !== JSON.stringify(expectedNames)) {
throw new Error(
`Guide sources must match the canonical topic list.\nExpected: ${expectedNames.join(', ')}\nFound: ${sourceFiles.join(', ')}`
)
}
await assertStubSourcesMatchTopics(repoRoot)
const stubTopics = new Set(STUB_TOPICS)
const guides = []
const projections = []
for (const name of expectedNames) {
const sourcePath = path.join(guideRoot, `${name}.md`)
// Why: Git may render text with native EOLs despite repository policy; the
// embedded guide and generated projection must have one platform-neutral identity.
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
const frontmatter = parseFrontmatter(markdown, path.relative(repoRoot, sourcePath))
if (frontmatter.name !== name) {
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
}
const aliases = GUIDE_ALIASES[name]
// Why: the embedded table always carries the full guide (served by `skills get`);
// only the installable projection thins to a stub once a topic is in STUB_TOPICS.
guides.push({ name, description: frontmatter.description, markdown, aliases })
const stubPath = path.join(repoRoot, 'skill-stubs', `${name}.md`)
const content = stubTopics.has(name)
? composeStubProjection(markdown, await readFile(stubPath, 'utf8'), `skill-stubs/${name}.md`)
: markdown
projections.push({
path: path.join(repoRoot, 'skills', name, 'SKILL.md'),
content
})
}
assertAliasContract(guides)
return [
{
path: path.join(repoRoot, 'src', 'cli', 'bundled-skill-guides.ts'),
content: serializeEmbeddedModule(guides)
},
...projections
]
}
async function writeArtifacts(artifacts) {
for (const artifact of artifacts) {
await mkdir(path.dirname(artifact.path), { recursive: true })
await writeFile(artifact.path, artifact.content, 'utf8')
}
}
async function verifyArtifacts(artifacts, repoRoot = REPO_ROOT) {
const stale = []
for (const artifact of artifacts) {
try {
await access(artifact.path, constants.R_OK)
if ((await readFile(artifact.path, 'utf8')) !== artifact.content) {
stale.push(artifact.path)
}
} catch {
stale.push(artifact.path)
}
}
if (stale.length > 0) {
throw new Error(
`Generated bundled skill guides are stale:\n${stale
.map((filePath) => path.relative(repoRoot, filePath))
.join('\n')}\nRun node config/scripts/generate-bundled-skill-guides.mjs --write.`
)
}
}
async function main() {
const artifacts = await buildArtifacts()
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
export {
CANONICAL_GUIDE_NAMES,
GUIDE_ALIASES,
STUB_TOPICS,
assertAliasContract,
buildArtifacts,
composeStubProjection,
frontmatterBlock,
normalizeMarkdown,
parseFrontmatter,
serializeEmbeddedModule,
verifyArtifacts,
writeArtifacts
}
@@ -0,0 +1,180 @@
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
import {
CANONICAL_GUIDE_NAMES,
GUIDE_ALIASES,
STUB_TOPICS,
assertAliasContract,
buildArtifacts,
frontmatterBlock,
normalizeMarkdown,
parseFrontmatter,
verifyArtifacts,
writeArtifacts
} from './generate-bundled-skill-guides.mjs'
const projectDir = path.resolve(import.meta.dirname, '..', '..')
const temporaryDirectories = []
async function createFixture() {
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
temporaryDirectories.push(root)
await Promise.all([
cp(path.join(projectDir, 'skill-guides'), path.join(root, 'skill-guides'), {
recursive: true
}),
cp(path.join(projectDir, 'skill-stubs'), path.join(root, 'skill-stubs'), {
recursive: true
}),
cp(path.join(projectDir, 'skills'), path.join(root, 'skills'), { recursive: true }),
mkdir(path.join(root, 'src', 'cli'), { recursive: true })
])
return root
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('bundled skill guide generator', () => {
it('keeps every fat (non-stub) projection byte-identical to its authoritative source', async () => {
for (const name of CANONICAL_GUIDE_NAMES) {
if (STUB_TOPICS.includes(name)) {
continue
}
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`))
const projection = await readFile(path.join(projectDir, 'skills', name, 'SKILL.md'))
expect(projection, name).toEqual(source)
}
})
it('projects stub topics as hybrid discovery stubs that reuse the guide frontmatter', async () => {
expect(STUB_TOPICS.length).toBeGreaterThan(0)
for (const name of STUB_TOPICS) {
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`), 'utf8')
const projection = await readFile(path.join(projectDir, 'skills', name, 'SKILL.md'), 'utf8')
// The routing frontmatter is the unchanged discovery surface.
expect(projection.startsWith(frontmatterBlock(source, `${name}.md`))).toBe(true)
// The stub is a thin hybrid pointer, not the full guide.
expect(projection).not.toEqual(source)
expect(projection.length).toBeLessThan(source.length)
expect(projection).toContain('discovery stub')
expect(projection).toContain(`skills get ${name}`)
}
})
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
)
for (const guide of BUNDLED_SKILL_GUIDES) {
const source = await readFile(
path.join(projectDir, 'skill-guides', `${guide.name}.md`),
'utf8'
)
const frontmatter = parseFrontmatter(source, `${guide.name}.md`)
expect(guide.description).toBe(frontmatter.description)
expect(guide.markdown).toBe(source)
expect(guide.fullMarkdown).toBe(source)
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
}
})
it('keeps CLI guide examples safe across shells and Linux command names', async () => {
for (const name of ['orca-cli', 'computer-use', 'orca-emulator', 'orca-emulator-android']) {
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`), 'utf8')
expect(source).toContain('ORCA_CLI_COMMAND')
expect(source).toContain('orca-dev')
expect(source).toContain('orca-ide')
expect(source).toContain('PowerShell')
expect(source).toContain('cmd.exe')
expect(source).toMatch(/^ORCA .+--json$/mu)
// Why: bare command lines can launch GNOME Orca, while shell variables make
// the same guide unusable from PowerShell and cmd.exe.
expect(source).not.toMatch(/^orca /mu)
expect(source).not.toMatch(/\$ORCA(?:_|\b)/u)
}
})
it('builds deterministic artifacts and verifies the checked-in outputs', async () => {
const first = await buildArtifacts(projectDir)
const second = await buildArtifacts(projectDir)
expect(second).toEqual(first)
await expect(verifyArtifacts(first, projectDir)).resolves.toBeUndefined()
})
it('generates platform-identical output from CRLF guide sources', async () => {
const expected = await buildArtifacts(projectDir)
const root = await createFixture()
for (const name of CANONICAL_GUIDE_NAMES) {
const sourcePath = path.join(root, 'skill-guides', `${name}.md`)
const source = await readFile(sourcePath, 'utf8')
await writeFile(sourcePath, source.replaceAll('\n', '\r\n'))
}
for (const name of STUB_TOPICS) {
const stubPath = path.join(root, 'skill-stubs', `${name}.md`)
const stubSource = await readFile(stubPath, 'utf8')
await writeFile(stubPath, stubSource.replaceAll('\n', '\r\n'))
}
const actual = await buildArtifacts(root)
expect(actual.map((artifact) => artifact.content)).toEqual(
expected.map((artifact) => artifact.content)
)
})
it('pins guide sources, projections, and embedded output to LF in Git', async () => {
const attributes = await readFile(path.join(projectDir, '.gitattributes'), 'utf8')
expect(normalizeMarkdown(attributes)).toContain('/skill-guides/*.md text eol=lf\n')
expect(normalizeMarkdown(attributes)).toContain('/skill-stubs/*.md text eol=lf\n')
expect(normalizeMarkdown(attributes)).toContain('/skills/*/SKILL.md text eol=lf\n')
expect(normalizeMarkdown(attributes)).toContain(
'/src/cli/bundled-skill-guides.ts text eol=lf\n'
)
})
it('reports stale outputs and write mode repairs all projections', async () => {
const root = await createFixture()
const artifacts = await buildArtifacts(root)
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow(
'src/cli/bundled-skill-guides.ts'
)
await writeArtifacts(artifacts)
await expect(verifyArtifacts(artifacts, root)).resolves.toBeUndefined()
await writeFile(path.join(root, 'skills', 'computer-use', 'SKILL.md'), 'stale\n')
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow('skills/computer-use/SKILL.md')
})
it('rejects mismatched source names and ambiguous aliases', async () => {
const root = await createFixture()
await writeFile(
path.join(root, 'skill-guides', 'computer-use.md'),
'---\nname: wrong\ndescription: present\n---\n'
)
await expect(buildArtifacts(root)).rejects.toThrow('declares mismatched name wrong')
expect(() =>
assertAliasContract([
{ name: 'first', aliases: ['legacy'] },
{ name: 'second', aliases: ['legacy'] }
])
).toThrow('assigned more than once')
expect(() =>
assertAliasContract([
{ name: 'first', aliases: ['second'] },
{ name: 'second', aliases: [] }
])
).toThrow('collides with canonical name')
})
})
@@ -0,0 +1,590 @@
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { constants } from 'node:fs'
import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { isDeepStrictEqual } from 'node:util'
// Why: the three artifacts version independently — bumping one shape must not
// rewrite the others or bypass the registry's schema-gated append-only guard.
const CURRENT_MANIFEST_SCHEMA_VERSION = 2
const SNAPSHOT_REGISTRY_SCHEMA_VERSION = 1
const RELEASE_MAPPING_SCHEMA_VERSION = 1
const SCRIPT_DIR = import.meta.dirname
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
const SKILLS_ROOT = path.join(REPO_ROOT, 'skills')
const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills')
const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json')
const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json')
const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json')
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function compareCodeUnits(left, right) {
return left === right ? 0 : left < right ? -1 : 1
}
function gitObjectSha(kind, bytes) {
return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest()
}
function normalizeText(bytes) {
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
}
function classifyFile(bytes) {
if (bytes.includes(0)) {
return 'binary'
}
try {
normalizeText(bytes)
return 'text'
} catch {
return 'binary'
}
}
function assertSafeRelativePath(relativePath) {
if (
path.isAbsolute(relativePath) ||
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`)
) {
throw new Error(`Unsafe skill package path: ${relativePath}`)
}
}
function describeFile(manifestPath, bytes, executable) {
const classification = classifyFile(bytes)
const exactSha256 = sha256(bytes)
const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null
return {
path: manifestPath,
size: bytes.length,
executable,
classification,
exactSha256,
textNormalizedSha256,
identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256,
gitBlobSha: gitObjectSha('blob', bytes).toString('hex')
}
}
function gitTreeSha(entries) {
const root = { directories: new Map(), files: [] }
for (const entry of entries) {
const parts = entry.path.split('/')
const filename = parts.pop()
let directory = root
for (const part of parts) {
let child = directory.directories.get(part)
if (!child) {
child = { directories: new Map(), files: [] }
directory.directories.set(part, child)
}
directory = child
}
directory.files.push({ filename, ...entry })
}
function hashDirectory(directory) {
const children = [
...[...directory.directories].map(([name, child]) => ({
mode: '40000',
name,
hash: hashDirectory(child)
})),
...directory.files.map((file) => ({
mode: file.executable ? '100755' : '100644',
name: file.filename,
hash: Buffer.from(file.gitBlobSha, 'hex')
}))
].sort((left, right) => {
const leftName = left.mode === '40000' ? `${left.name}/` : left.name
const rightName = right.mode === '40000' ? `${right.name}/` : right.name
return Buffer.from(leftName).compare(Buffer.from(rightName))
})
const body = Buffer.concat(
children.map(({ mode, name, hash }) =>
Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash])
)
)
return gitObjectSha('tree', body)
}
return hashDirectory(root).toString('hex')
}
async function collectPackageFiles(packageRoot) {
const files = []
const caseFoldedPaths = new Map()
async function visit(directory) {
const entries = await readdir(directory, { withFileTypes: true })
// Why: build-time Node and packaged Electron may ship different ICU data;
// package identity order must use the same locale-independent comparison.
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name)
const relativePath = path.relative(packageRoot, absolutePath)
assertSafeRelativePath(relativePath)
const manifestPath = relativePath.split(path.sep).join('/')
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(foldedPath)
if (collision && collision !== manifestPath) {
throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const fileStat = await lstat(absolutePath)
if (fileStat.isSymbolicLink()) {
throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`)
}
if (fileStat.isDirectory()) {
await visit(absolutePath)
continue
}
if (!fileStat.isFile()) {
throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`)
}
// Why: Windows observation cannot see execute bits, so an executable file in
// a shipped skill would misclassify every pristine Windows install as unrecognized.
if ((fileStat.mode & 0o111) !== 0) {
throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`)
}
files.push(describeFile(manifestPath, await readFile(absolutePath), false))
}
}
await visit(packageRoot)
return sortManifestFiles(files)
}
function collectGitSkillTreeEntries(treeSha) {
const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha])
.toString('utf8')
.split('\0')
.filter(Boolean)
const packages = new Map()
for (const line of output) {
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line)
if (!match) {
throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`)
}
const [, mode, type, objectSha, sourcePath] = match
const separator = sourcePath.indexOf('/')
if (separator <= 0 || separator === sourcePath.length - 1) {
throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`)
}
const name = sourcePath.slice(0, separator)
const manifestPath = sourcePath.slice(separator + 1)
const entries = packages.get(name) ?? []
entries.push({ mode, type, objectSha, manifestPath })
packages.set(name, entries)
}
return packages
}
function readGitBlobs(objectShas) {
const uniqueShas = [...new Set(objectShas)]
if (uniqueShas.length === 0) {
return new Map()
}
// Why: released history spans hundreds of tags. Batch mode avoids a Git
// subprocess per historical file while remaining available on Git 2.25.
const output = execFileSync('git', ['cat-file', '--batch'], {
input: `${uniqueShas.join('\n')}\n`,
maxBuffer: 64 * 1024 * 1024
})
const blobs = new Map()
let offset = 0
for (const requestedSha of uniqueShas) {
const headerEnd = output.indexOf(10, offset)
if (headerEnd < 0) {
throw new Error(`Missing git cat-file header for ${requestedSha}`)
}
const header = output.subarray(offset, headerEnd).toString('utf8')
const match = /^([a-f0-9]+) blob (\d+)$/.exec(header)
if (!match || match[1] !== requestedSha) {
throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`)
}
const size = Number(match[2])
const contentStart = headerEnd + 1
const contentEnd = contentStart + size
if (contentEnd >= output.length || output[contentEnd] !== 10) {
throw new Error(`Truncated git blob for ${requestedSha}`)
}
blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd)))
offset = contentEnd + 1
}
return blobs
}
function collectGitPackageFiles(treeSha, name, entries, blobs) {
const caseFoldedPaths = new Map()
const files = entries.map(({ mode, type, objectSha, manifestPath }) => {
if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) {
throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`)
}
assertSafeRelativePath(manifestPath)
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(foldedPath)
if (collision && collision !== manifestPath) {
throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const bytes = blobs.get(objectSha)
if (!bytes) {
throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`)
}
return describeFile(manifestPath, bytes, mode === '100755')
})
// Why: git ls-tree emits git byte-order, not the canonical walk order.
return sortManifestFiles(files)
}
// Why: snapshot matching compares files by array index, so every producer —
// working-tree walk, git history, and runtime observation — must emit one
// canonical order. This mirrors the sorted depth-first filesystem walk.
function compareManifestPaths(left, right) {
const leftParts = left.split('/')
const rightParts = right.split('/')
const shared = Math.min(leftParts.length, rightParts.length)
for (let index = 0; index < shared; index += 1) {
const order = compareCodeUnits(leftParts[index], rightParts[index])
if (order !== 0) {
return order
}
}
return leftParts.length - rightParts.length
}
function sortManifestFiles(files) {
return [...files].sort((left, right) => compareManifestPaths(left.path, right.path))
}
function packageDigest(files) {
return sha256(
Buffer.from(
JSON.stringify(
files.map((file) => ({
path: file.path,
executable: file.executable,
classification: file.classification,
identitySha256: file.identitySha256
}))
),
'utf8'
)
)
}
function releaseTags() {
return execFileSync(
'git',
['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'],
{ encoding: 'utf8' }
)
.split('\n')
.filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag))
}
function skillsTreeShasAtRefs(refs) {
if (refs.length === 0) {
return []
}
const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], {
input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`,
encoding: 'utf8'
})
const lines = output.trimEnd().split('\n')
if (lines.length !== refs.length) {
throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`)
}
return lines.map((line, index) => {
if (line.endsWith(' missing')) {
return null
}
const match = /^([a-f0-9]+) tree$/.exec(line)
if (!match) {
throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`)
}
return match[1]
})
}
function buildReleasedHistory() {
const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} }
const mapping = { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] }
const tags = releaseTags()
const treeShas = skillsTreeShasAtRefs(tags)
const distinctTreeShas = [...new Set(treeShas.filter(Boolean))]
const packagesByTree = new Map(
distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)])
)
const blobs = readGitBlobs(
[...packagesByTree.values()].flatMap((packages) =>
[...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha))
)
)
let previousSkillsTreeSha = null
for (const [index, tag] of tags.entries()) {
const skillsTreeSha = treeShas[index]
if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) {
continue
}
previousSkillsTreeSha = skillsTreeSha
const revisions = {}
const packages = packagesByTree.get(skillsTreeSha)
if (!packages) {
throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`)
}
for (const name of [...packages.keys()].sort(compareCodeUnits)) {
const entries = packages.get(name)
const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs)
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
continue
}
const digest = packageDigest(filesWithGitHashes)
const snapshots = registry.skills[name] ?? []
const latest = snapshots.at(-1)
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshots.push({
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
})
registry.skills[name] = snapshots
}
revisions[name] = snapshots.at(-1).releaseRevision
}
if (Object.keys(revisions).length > 0) {
mapping.releases.push({ appVersion: tag.slice(1), skills: revisions })
}
}
return { registry, mapping }
}
// Why: the artifacts must be pure functions of skills/ bytes and release-tag
// history. Stamping the app version made every release cut invalidate the
// committed output on all open branches and drag skill CI onto unrelated PRs.
async function buildArtifacts() {
const { registry, mapping } = buildReleasedHistory()
const releasedSnapshotCounts = Object.fromEntries(
Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length])
)
const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(compareCodeUnits)
const currentSkills = []
for (const name of skillDirectories) {
const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name))
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
throw new Error(`Skill package ${name} has no top-level SKILL.md`)
}
const digest = packageDigest(filesWithGitHashes)
const snapshots = registry.skills[name] ?? []
const latest = snapshots.at(-1)
let snapshot = latest
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshot = {
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
}
snapshots.push(snapshot)
registry.skills[name] = snapshots
}
currentSkills.push({
name,
sourcePath: `skills/${name}`,
...snapshot
})
}
return {
currentManifest: {
schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION,
skills: currentSkills
},
snapshotRegistry: registry,
releaseMapping: mapping,
releasedSnapshotCounts
}
}
// Why: released snapshots are the detection ground truth for existing installs,
// so a generation-logic change must not rewrite them silently. Only the one
// unreleased working-tree append per skill may change between runs.
function releasedSnapshotCountsFromMapping(releaseMapping) {
if (!releaseMapping || releaseMapping.schemaVersion !== RELEASE_MAPPING_SCHEMA_VERSION) {
return null
}
const counts = {}
for (const release of releaseMapping.releases ?? []) {
for (const [name, revision] of Object.entries(release.skills ?? {})) {
counts[name] = Math.max(counts[name] ?? 0, revision)
}
}
return counts
}
function assertReleasedHistoryPreserved(committedRegistry, artifacts, committedReleaseMapping) {
if (!committedRegistry || committedRegistry.schemaVersion !== SNAPSHOT_REGISTRY_SCHEMA_VERSION) {
return
}
const committedReleasedCounts = releasedSnapshotCountsFromMapping(committedReleaseMapping)
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0
const regenerated = artifacts.snapshotRegistry.skills[name] ?? []
const minimumReleasedCount =
committedReleasedCounts?.[name] ?? Math.max(0, committedSnapshots.length - 1)
if (releasedCount < minimumReleasedCount) {
throw new Error(
`Released snapshot history is incomplete for ${name}. ` +
'Fetch all release tags before regenerating skill artifacts.'
)
}
const protectedCount = committedReleasedCounts
? (committedReleasedCounts[name] ?? 0)
: Math.min(committedSnapshots.length, releasedCount)
for (let index = 0; index < protectedCount; index += 1) {
const committed = committedSnapshots[index]
const rebuilt = regenerated[index]
if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) {
throw new Error(
`Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` +
'Released snapshots are append-only; a deliberate identity migration must update this check.'
)
}
}
}
}
async function readCommittedRegistry() {
try {
return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8'))
} catch {
return null
}
}
async function readCommittedReleaseMapping() {
try {
return JSON.parse(await readFile(RELEASE_MAPPING_PATH, 'utf8'))
} catch {
return null
}
}
function serialized(value) {
return `${JSON.stringify(value, null, 2)}\n`
}
async function writeArtifacts(artifacts) {
await mkdir(OUTPUT_ROOT, { recursive: true })
await Promise.all([
writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)),
writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)),
writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping))
])
}
// Why: cutting a release tag adds a trailing mapping row on every checkout at
// once, before any branch can regenerate. A trailing row whose revisions all
// equal the current manifest is provably redundant until the next real
// regeneration (installs of those bytes classify as current and are labeled by
// the running build), so verify must not fail branches over it.
function isToleratedReleaseMappingPrefix(committedText, artifacts) {
let committed
try {
committed = JSON.parse(committedText)
} catch {
return false
}
const derived = artifacts.releaseMapping
const committedCount = Array.isArray(committed?.releases) ? committed.releases.length : -1
if (committedCount < 0 || committedCount >= derived.releases.length) {
return false
}
const prefix = {
schemaVersion: derived.schemaVersion,
releases: derived.releases.slice(0, committedCount)
}
if (committedText !== serialized(prefix)) {
return false
}
const currentRevisions = Object.fromEntries(
artifacts.currentManifest.skills.map((skill) => [skill.name, skill.releaseRevision])
)
return derived.releases
.slice(committedCount)
.every((release) => isDeepStrictEqual(release.skills, currentRevisions))
}
async function verifyArtifacts(artifacts) {
const expected = [
[CURRENT_MANIFEST_PATH, artifacts.currentManifest, null],
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry, null],
[RELEASE_MAPPING_PATH, artifacts.releaseMapping, isToleratedReleaseMappingPrefix]
]
const stale = []
for (const [filePath, value, tolerated] of expected) {
try {
await access(filePath, constants.R_OK)
const committedText = await readFile(filePath, 'utf8')
if (committedText !== serialized(value) && !tolerated?.(committedText, artifacts)) {
stale.push(filePath)
}
} catch {
stale.push(filePath)
}
}
if (stale.length > 0) {
throw new Error(
`Generated skill artifacts are stale:\n${stale
.map((filePath) => path.relative(REPO_ROOT, filePath))
.join('\n')}\nRun pnpm generate:skill-bundle-manifest.`
)
}
}
async function main() {
const artifacts = await buildArtifacts()
assertReleasedHistoryPreserved(
await readCommittedRegistry(),
artifacts,
await readCommittedReleaseMapping()
)
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
export {
assertReleasedHistoryPreserved,
buildArtifacts,
buildReleasedHistory,
classifyFile,
collectPackageFiles,
describeFile,
gitTreeSha,
isToleratedReleaseMappingPrefix,
normalizeText,
packageDigest,
sortManifestFiles,
verifyArtifacts,
writeArtifacts
}
@@ -0,0 +1,280 @@
import { execFileSync } from 'node:child_process'
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertReleasedHistoryPreserved,
classifyFile,
collectPackageFiles,
describeFile,
gitTreeSha,
isToleratedReleaseMappingPrefix,
normalizeText,
packageDigest,
sortManifestFiles
} from './generate-skill-bundle-manifest.mjs'
const temporaryDirectories = []
async function createPackage() {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-'))
temporaryDirectories.push(directory)
return directory
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('skill bundle manifest generator', () => {
it('folds platform line endings for text identity', () => {
const lf = Buffer.from('first\nsecond\n')
const crlf = Buffer.from('first\r\nsecond\r\n')
expect(classifyFile(lf)).toBe('text')
expect(normalizeText(crlf)).toEqual(lf)
})
it('classifies null-containing and invalid UTF-8 content as binary', () => {
expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary')
expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary')
})
it('uses normalized text identity but exact executable identity', () => {
const skillFile = describeFile('SKILL.md', Buffer.from('line one\r\nline two\r\n'), false)
const executable = describeFile('run.sh', Buffer.from('#!/bin/sh\r\necho ok\r\n'), true)
expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256)
expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256)
expect(executable.exactSha256).not.toBe(executable.textNormalizedSha256)
expect(executable.identitySha256).toBe(executable.exactSha256)
expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/)
})
it('orders git-history files identically to the filesystem walk', async () => {
const packageRoot = await createPackage()
await mkdir(path.join(packageRoot, 'sub'))
for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) {
await writeFile(path.join(packageRoot, name), `${name}\n`)
}
const walked = await collectPackageFiles(packageRoot)
// Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based
// snapshot matching requires history and observation to share one order.
const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) =>
walked.find((file) => file.path === manifestPath)
)
expect(sortManifestFiles(gitOrdered)).toEqual(walked)
expect(packageDigest(sortManifestFiles(gitOrdered))).toBe(packageDigest(walked))
expect(walked.map((file) => file.path)).toEqual([
'Zebra.md',
'apple.md',
'sub/inner.txt',
'sub.md'
])
})
it('rejects rewrites of released snapshots and allows floating-tail replacement', () => {
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
const artifacts = {
releasedSnapshotCounts: { 'orca-cli': 2 },
snapshotRegistry: {
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] }
}
}
expect(() =>
assertReleasedHistoryPreserved(
{ schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } },
artifacts
)
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] }
},
artifacts
)
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] }
},
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }]
}
},
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')]
}
},
{ ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } }
)
).toThrow('Released snapshot history is incomplete for orca-cli')
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
})
it('protects only revisions named by the committed release mapping', () => {
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
const committedRegistry = {
schemaVersion: 1,
skills: {
'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'unreleased-tail')]
}
}
const artifacts = {
releasedSnapshotCounts: { 'linear-tickets': 2 },
snapshotRegistry: {
schemaVersion: 1,
skills: { 'linear-tickets': [snapshot(1, 'released'), snapshot(2, 'new-release')] }
}
}
expect(() =>
assertReleasedHistoryPreserved(committedRegistry, artifacts, {
schemaVersion: 1,
releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 1 } }]
})
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(committedRegistry, artifacts, {
schemaVersion: 1,
releases: [{ appVersion: '1.0.0', skills: { 'linear-tickets': 2 } }]
})
).toThrow('Released snapshot history changed for linear-tickets at revision 2')
})
it('tolerates only redundant trailing release-mapping rows', () => {
const serialized = (value) => `${JSON.stringify(value, null, 2)}\n`
const rows = [
{ appVersion: '1.0.0', skills: { 'orca-cli': 1 } },
{ appVersion: '1.1.0', skills: { 'orca-cli': 2 } }
]
const artifacts = {
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 2 }] },
releaseMapping: { schemaVersion: 1, releases: rows }
}
const committedPrefix = serialized({ schemaVersion: 1, releases: [rows[0]] })
// A just-cut tag whose bytes equal the working tree may lag in the mapping.
expect(isToleratedReleaseMappingPrefix(committedPrefix, artifacts)).toBe(true)
// The committed file matching the derived mapping is byte-equality's job, not tolerance.
expect(isToleratedReleaseMappingPrefix(serialized(artifacts.releaseMapping), artifacts)).toBe(
false
)
// A trailing row for bytes the committed artifacts do not describe is a real gap.
expect(
isToleratedReleaseMappingPrefix(committedPrefix, {
...artifacts,
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 3 }] }
})
).toBe(false)
expect(
isToleratedReleaseMappingPrefix(committedPrefix, {
...artifacts,
currentManifest: {
skills: [
{ name: 'orca-cli', releaseRevision: 2 },
{ name: 'orca-linear', releaseRevision: 1 }
]
}
})
).toBe(false)
// Rewritten earlier rows never pass, with or without trailing rows.
expect(
isToleratedReleaseMappingPrefix(
serialized({
schemaVersion: 1,
releases: [{ appVersion: '0.9.0', skills: { 'orca-cli': 1 } }]
}),
artifacts
)
).toBe(false)
expect(isToleratedReleaseMappingPrefix('not json', artifacts)).toBe(false)
expect(isToleratedReleaseMappingPrefix(serialized({ schemaVersion: 1 }), artifacts)).toBe(false)
})
it.runIf(process.platform !== 'win32')(
'rejects executable files in shipped skill packages',
async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n')
await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n')
await chmod(path.join(packageRoot, 'run.sh'), 0o755)
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
'Executable file is not allowed in a shipped skill: run.sh'
)
}
)
it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
await writeFile(path.join(packageRoot, 'Readme.md'), 'one')
await writeFile(path.join(packageRoot, 'README.md'), 'two')
await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths')
})
it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
await symlink('SKILL.md', path.join(packageRoot, 'linked.md'))
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
'Symlink is not allowed in a shipped skill'
)
})
it('computes the same Git tree identity as Git', async () => {
const packageRoot = path.resolve('skills', 'orca-cli')
const files = await collectPackageFiles(packageRoot)
const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], {
encoding: 'utf8'
})
.trim()
.split(/\s+/)[2]
expect(gitTreeSha(files)).toBe(expected)
})
it('matches Git when a directory and file share a name prefix', async () => {
const packageRoot = await createPackage()
await mkdir(path.join(packageRoot, 'sub'))
await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n')
await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n')
const files = await collectPackageFiles(packageRoot)
execFileSync('git', ['init', '--quiet'], { cwd: packageRoot })
execFileSync('git', ['add', '-A'], { cwd: packageRoot })
const expected = execFileSync('git', ['write-tree'], {
cwd: packageRoot,
encoding: 'utf8'
}).trim()
expect(gitTreeSha(files)).toBe(expected)
})
})
@@ -0,0 +1,483 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import {
budgetFailures,
collectTerminalPerfRows,
compareScenarios,
escapeHtml,
formatLargeValue,
formatMs,
readJsonReport,
scenarioTitle
} from './terminal-perf-report-rows.mjs'
import { basename, dirname } from 'node:path'
const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html'
// Why: every tracked metric is lower-is-better, so delta coloring and the
// regression table share one direction rule.
const MS_METRICS = [
{ key: 'medianMs', label: 'Typing median', chart: true },
{ key: 'worstMs', label: 'Typing worst', chart: true },
{ key: 'scrollMs', label: 'Active scroll', chart: true },
{ key: 'restoreMs', label: 'Restore', chart: true },
{ key: 'revisitMs', label: 'Revisit marker', chart: true },
{ key: 'maxTimerDriftMs', label: 'Timer drift', chart: false }
]
const COUNT_METRICS = [
{ key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' },
{ key: 'mainPeakInFlightChars', label: 'Main in-flight chars' },
{ key: 'mainPeakPendingChars', label: 'Main pending chars' },
{ key: 'hiddenSkippedChars', label: 'Hidden skipped chars' },
{ key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' },
// Why: parked-memory scenarios are table-only — heap/view counts have no
// ms trend story, so they stay out of the charts.
{ key: 'heapUsedMB', label: 'Renderer JS heap (MB)' },
{ key: 'liveTerminals', label: 'Live xterm instances' },
{ key: 'livePaneManagers', label: 'Live pane managers' }
]
const SERIES_COLORS = {
medianMs: '#2563eb',
worstMs: '#dc2626',
scrollMs: '#d97706',
restoreMs: '#7c3aed',
revisitMs: '#0d9488'
}
const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/
export function parseHtmlReportArgs(argv, env = process.env) {
const args = [...argv]
if (args[0] === '--') {
args.shift()
}
const inputs = []
let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--output' || arg === '-o') {
const next = args[index + 1]
if (!next || next.startsWith('-')) {
throw new Error(`${arg} requires a path`)
}
outputPath = next
index += 1
continue
}
if (arg.startsWith('--output=')) {
outputPath = arg.slice('--output='.length)
continue
}
const labeled = arg.match(LABELED_INPUT_RE)
if (labeled) {
inputs.push({ label: labeled[1], path: labeled[2] })
} else {
inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg })
}
}
if (inputs.length === 0) {
throw new Error(
'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]<playwright-json>... --output <report.html>'
)
}
return { inputs, outputPath }
}
// ── Trend data ────────────────────────────────────────────────────────────
function buildMatrix(revisions) {
const scenarios = new Map()
for (const revision of revisions) {
for (const row of revision.rows) {
if (!scenarios.has(row.scenario)) {
scenarios.set(row.scenario, new Map())
}
scenarios.get(row.scenario).set(revision.label, row)
}
}
const orderedScenarios = [...scenarios.keys()].sort(compareScenarios)
return { scenarios, orderedScenarios }
}
function niceCeil(value) {
if (value <= 0) {
return 1
}
const magnitude = 10 ** Math.floor(Math.log10(value))
for (const step of [1, 2, 2.5, 5, 10]) {
if (value <= step * magnitude) {
return step * magnitude
}
}
return 10 * magnitude
}
// ── Rendering ─────────────────────────────────────────────────────────────
function renderTrendChart({ scenario, byRevision, revisions, title }) {
const metrics = MS_METRICS.filter(
(metric) =>
metric.chart &&
revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null)
)
if (metrics.length === 0) {
return ''
}
const width = 560
const height = 230
const pad = { left: 52, right: 14, top: 30, bottom: 38 }
const plotW = width - pad.left - pad.right
const plotH = height - pad.top - pad.bottom
const maxValue = Math.max(
1,
...metrics.flatMap((metric) =>
revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0)
)
)
const yMax = niceCeil(maxValue * 1.15)
const xFor = (index) =>
pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1))
const yFor = (value) => pad.top + plotH - (plotH * value) / yMax
const parts = []
parts.push(
`<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(title)}" class="trend-chart">`
)
parts.push(`<text x="${pad.left}" y="16" class="chart-title">${escapeHtml(title)}</text>`)
// Horizontal gridlines + y labels
const ticks = 4
for (let tick = 0; tick <= ticks; tick += 1) {
const value = (yMax * tick) / ticks
const y = yFor(value)
parts.push(
`<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" class="gridline"/>`
)
parts.push(
`<text x="${pad.left - 6}" y="${y + 3}" class="axis-label" text-anchor="end">${value % 1 === 0 ? value : value.toFixed(1)}</text>`
)
}
// X labels
revisions.forEach((revision, index) => {
parts.push(
`<text x="${xFor(index)}" y="${height - pad.bottom + 16}" class="axis-label" text-anchor="middle">${escapeHtml(revision.label)}</text>`
)
})
// Series
for (const metric of metrics) {
const color = SERIES_COLORS[metric.key] ?? '#475569'
const points = revisions
.map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] }))
.filter((point) => point.value != null)
if (points.length === 0) {
continue
}
const path = points
.map(
(point, order) =>
`${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}`
)
.join(' ')
parts.push(`<path d="${path}" fill="none" stroke="${color}" stroke-width="2"/>`)
for (const point of points) {
const x = xFor(point.index)
const y = yFor(point.value)
parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${color}"/>`)
parts.push(
`<text x="${x.toFixed(1)}" y="${(y - 7).toFixed(1)}" class="point-label" text-anchor="middle" fill="${color}">${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}</text>`
)
}
}
parts.push('</svg>')
const legend = metrics
.map((metric) => {
const color = SERIES_COLORS[metric.key] ?? '#475569'
return `<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(metric.label)}</span>`
})
.join('')
return `<figure class="chart-card" data-scenario="${escapeHtml(scenario)}">${parts.join('')}<figcaption class="legend">${legend} <span class="legend-unit">ms — lower is better</span></figcaption></figure>`
}
function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) {
if (baseline == null || latest == null) {
return '<td class="delta">—</td>'
}
const diff = latest - baseline
const pct = baseline === 0 ? null : (diff / baseline) * 100
let cls = 'neutral'
if (zeroBudget) {
cls = latest > 0 ? 'worse' : 'better'
} else if (pct != null && Math.abs(pct) >= 5) {
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
} else if (baseline === 0 && diff !== 0) {
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
}
const pctLabel =
pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%`
const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}`
return `<td class="delta ${cls}">${escapeHtml(pctLabel)} <span class="delta-abs">(${escapeHtml(diffLabel)})</span></td>`
}
function renderScenarioTable({ scenario, byRevision, revisions, title }) {
const metricRows = []
const allMetrics = [...MS_METRICS, ...COUNT_METRICS]
for (const metric of allMetrics) {
const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key])
if (values.every((value) => value == null)) {
continue
}
const isMs = MS_METRICS.includes(metric)
const format = isMs ? formatMs : formatLargeValue
const cells = values
.map((value) => `<td>${value == null ? '—' : escapeHtml(format(value))}</td>`)
.join('')
const baseline = values.find((value) => value != null)
const latest = values.toReversed().find((value) => value != null)
metricRows.push(
`<tr><th scope="row">${escapeHtml(metric.label)}</th>${cells}${deltaCell(baseline, latest, {
zeroBudget: metric.key === 'rendererDroppedBacklogs'
})}</tr>`
)
}
if (metricRows.length === 0) {
return ''
}
const headers = revisions.map((revision) => `<th>${escapeHtml(revision.label)}</th>`).join('')
return `<section class="scenario-block">
<h3>${escapeHtml(title)} <span class="scenario-id">${escapeHtml(scenario)}</span></h3>
<table class="trend-table">
<thead><tr><th>Metric</th>${headers}<th>Δ first → last</th></tr></thead>
<tbody>${metricRows.join('')}</tbody>
</table>
</section>`
}
function renderHeadline(revisions, matrix) {
if (revisions.length < 2) {
return ''
}
const first = revisions[0]
const last = revisions.at(-1)
const cards = []
for (const scenario of matrix.orderedScenarios) {
const byRevision = matrix.scenarios.get(scenario)
const baseRow = byRevision.get(first.label)
const lastRow = byRevision.get(last.label)
if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) {
continue
}
const diff = lastRow.medianMs - baseRow.medianMs
const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100
const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse'
cards.push(`<div class="card ${cls}">
<div class="card-title">${escapeHtml(scenarioTitle(scenario, lastRow))}</div>
<div class="card-value">${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}</div>
<div class="card-sub">typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)</div>
</div>`)
}
if (cards.length === 0) {
return ''
}
return `<section><h2>Baseline vs latest</h2><div class="cards">${cards.join('')}</div></section>`
}
function renderBudgets(latestRevision) {
const failures = []
for (const row of latestRevision.rows) {
for (const failure of budgetFailures(row)) {
failures.push(`${row.scenario}: ${failure}`)
}
}
const status =
failures.length === 0 ? '<span class="pass">Pass</span>' : '<span class="fail">Fail</span>'
const failureList =
failures.length === 0
? ''
: `<ul>${failures.map((failure) => `<li>${escapeHtml(failure)}</li>`).join('')}</ul>`
return `<section><h2>Budget status — ${escapeHtml(latestRevision.label)}</h2>
<p>${latestRevision.rows.length} scenario rows checked: ${status}</p>${failureList}</section>`
}
function renderInputsMeta(revisions) {
const items = revisions
.map((revision) => {
const stats = revision.stats
const statsLabel = stats
? ` — ${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky`
: ''
const failNote =
stats && stats.unexpected > 0
? ' <span class="meta-warn">(failed assertions at this revision; metrics still recorded)</span>'
: ''
return `<li><strong>${escapeHtml(revision.label)}</strong> — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}</li>`
})
.join('')
return `<ol class="inputs">${items}</ol>`
}
function renderRawDetails(revisions) {
return revisions
.map((revision) => {
const rows = revision.rows
.map(
(row) =>
`<tr><td>${escapeHtml(row.scenario)}</td><td>${row.panes ?? '—'}</td><td>${escapeHtml(formatMs(row.medianMs))}</td><td>${escapeHtml(formatMs(row.worstMs))}</td><td>${escapeHtml(formatMs(row.scrollMs))}</td><td>${escapeHtml(formatMs(row.restoreMs))}</td><td>${escapeHtml(formatMs(row.revisitMs))}</td><td>${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}</td><td>${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}</td><td>${row.rendererDroppedBacklogs ?? '—'}</td></tr>`
)
.join('')
return `<details><summary>Raw rows — ${escapeHtml(revision.label)}</summary>
<table class="trend-table">
<thead><tr><th>Scenario</th><th>Panes</th><th>Median</th><th>Worst</th><th>Scroll</th><th>Restore</th><th>Revisit</th><th>Renderer peak</th><th>Hidden skipped</th><th>Drops</th></tr></thead>
<tbody>${rows}</tbody></table></details>`
})
.join('')
}
const PAGE_CSS = `
:root { color-scheme: light; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; }
h1 { font-size: 24px; margin-bottom: 4px; }
h2 { font-size: 18px; margin: 28px 0 10px; }
h3 { font-size: 15px; margin: 18px 0 6px; }
.meta { color: #64748b; font-size: 13px; }
.inputs { font-size: 13px; color: #334155; padding-left: 20px; }
.meta-warn { color: #b45309; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; }
.card.better { border-left-color: #16a34a; }
.card.worse { border-left-color: #dc2626; }
.card.neutral { border-left-color: #94a3b8; }
.card-title { font-size: 12px; color: #64748b; }
.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; }
.card-sub { font-size: 11px; color: #94a3b8; }
.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; }
.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; }
.trend-chart { width: 100%; height: auto; }
.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; }
.gridline { stroke: #e2e8f0; stroke-width: 1; }
.axis-label { font-size: 10px; fill: #64748b; }
.point-label { font-size: 10px; font-weight: 600; }
.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.legend-unit { color: #94a3b8; margin-left: auto; }
.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; }
.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; }
table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; }
table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; }
table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; }
table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; }
td.delta.better { color: #15803d; font-weight: 600; }
td.delta.worse { color: #b91c1c; font-weight: 600; }
td.delta.neutral { color: #64748b; }
.delta-abs { font-weight: 400; color: #94a3b8; }
.pass { color: #15803d; font-weight: 700; }
.fail { color: #b91c1c; font-weight: 700; }
details { margin: 8px 0; }
summary { cursor: pointer; font-size: 13px; color: #334155; }
`
function renderHtml({ generatedAt, revisions }) {
const matrix = buildMatrix(revisions)
const charts =
revisions.length >= 2
? matrix.orderedScenarios
.map((scenario) => {
const byRevision = matrix.scenarios.get(scenario)
const anyRow = [...byRevision.values()][0]
return renderTrendChart({
scenario,
byRevision,
revisions,
title: scenarioTitle(scenario, anyRow)
})
})
.filter(Boolean)
.join('')
: ''
const tables = matrix.orderedScenarios
.map((scenario) => {
const byRevision = matrix.scenarios.get(scenario)
const anyRow = [...byRevision.values()][0]
return renderScenarioTable({
scenario,
byRevision,
revisions,
title: scenarioTitle(scenario, anyRow)
})
})
.filter(Boolean)
.join('')
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Terminal Performance Over Time</title>
<style>${PAGE_CSS}</style>
</head>
<body>
<h1>Terminal Performance Over Time</h1>
<p class="meta">Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.</p>
${renderInputsMeta(revisions)}
${renderHeadline(revisions, matrix)}
${charts ? `<section><h2>Trends across revisions</h2><div class="charts">${charts}</div></section>` : ''}
<section><h2>Metric detail by scenario</h2>${tables}</section>
${renderBudgets(revisions.at(-1))}
<section><h2>Raw data</h2>${renderRawDetails(revisions)}</section>
</body>
</html>
`
}
export function generateTerminalPerfHtmlReport({
inputs,
inputPaths,
outputPath,
now = new Date()
}) {
// Why: older callers (the scale report gate) pass bare inputPaths.
const resolvedInputs =
inputs ??
(inputPaths ?? []).map((path) => ({
label: basename(path).replace(/\.json$/i, ''),
path
}))
const revisions = resolvedInputs.map(({ label, path }) => {
const report = readJsonReport(path)
return {
label,
path,
stats: report.stats ?? null,
rows: collectTerminalPerfRows(report, label)
}
})
const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0)
if (totalRows === 0) {
throw new Error('No opencode terminal perf annotations found in the provided reports')
}
const html = renderHtml({ generatedAt: now.toISOString(), revisions })
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, html)
const latestFailures = revisions
.at(-1)
.rows.reduce((sum, row) => sum + budgetFailures(row).length, 0)
return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures }
}
const isMain = process.argv[1] && import.meta.filename === process.argv[1]
if (isMain) {
try {
const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2))
const result = generateTerminalPerfHtmlReport({ inputs, outputPath })
console.log(
`Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).`
)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
@@ -0,0 +1,272 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
generateTerminalPerfHtmlReport,
parseHtmlReportArgs
} from './generate-terminal-perf-html-report.mjs'
const tempDirs = []
function makeTempDir() {
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-html-'))
tempDirs.push(dir)
return dir
}
function writeReport(
annotationDescription,
annotationType = 'opencode-scale-same-workspace-25',
reportName = 'report.json'
) {
const dir = makeTempDir()
const reportPath = join(dir, reportName)
writeFileSync(
reportPath,
JSON.stringify({
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: annotationType,
description: annotationDescription
},
{
type: 'browser-unrelated',
description: 'median=999.0ms'
}
]
}
]
}
]
}
]
})
)
return reportPath
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('generate-terminal-perf-html-report', () => {
it('parses labeled and bare input paths plus output flags', () => {
expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({
inputs: [
{ label: 'a', path: 'a.json' },
{ label: 'b', path: 'b.json' }
],
outputPath: 'out.html'
})
expect(parseHtmlReportArgs(['main=runs/0-main.json', '#5038 final=runs/4-final.json'])).toEqual(
{
inputs: [
{ label: 'main', path: 'runs/0-main.json' },
{ label: '#5038 final', path: 'runs/4-final.json' }
],
outputPath: 'test-results/terminal-perf-impact-report.html'
}
)
expect(
parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' })
).toEqual({
inputs: [{ label: 'a', path: 'a.json' }],
outputPath: 'env.html'
})
expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path')
expect(() => parseHtmlReportArgs([])).toThrow('Usage:')
})
it('writes a single-run report with scenario tables and budget status', () => {
const reportPath = writeReport(
[
'panes=25',
'frames=60',
'median=12.4ms',
'worst=44.8ms',
'revisit=28.6ms',
'scroll=61.0ms',
'restore=320.0ms',
'maxTimerDrift=8.0ms',
'rendererPeakQueuedChars=2048',
'mainPeakInFlightChars=4096',
'heldAckChars=1024',
'hiddenSkippedChars=512',
'rendererDroppedBacklogs=0'
].join(' ')
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({
inputPaths: [reportPath],
outputPath,
now: new Date('2026-06-09T10:00:00.000Z')
})
const html = readFileSync(outputPath, 'utf8')
expect(result).toEqual({ budgetFailureCount: 0, outputPath, rowCount: 1 })
expect(html).toContain('<!doctype html>')
expect(html).toContain('Terminal Performance Over Time')
expect(html).toContain('2026-06-09T10:00:00.000Z')
expect(html).toContain('Same workspace panes — 25 panes')
expect(html).toContain('opencode-scale-same-workspace-25')
expect(html).toContain('28.6ms')
expect(html).toContain('Pass')
// Why: one run has no over-time story; the trend section must not render.
expect(html).not.toContain('Trends across revisions')
expect(html).not.toContain('browser-unrelated')
})
it('renders parked-memory heap and live view counts as table metrics', () => {
const reportPath = writeReport(
'panes=8 parkedTabs=8 heapUsedMB=142.5 liveTerminals=1 livePaneManagers=1',
'opencode-parked-memory'
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
const html = readFileSync(outputPath, 'utf8')
// Why: heapUsedMB has no budget — a memory row alone must not fail gates.
expect(result.budgetFailureCount).toBe(0)
expect(html).toContain('Parked hidden terminal memory — 8 panes')
expect(html).toContain('Renderer JS heap (MB)')
expect(html).toContain('142.5')
expect(html).toContain('Live xterm instances')
expect(html).toContain('Live pane managers')
})
it('marks over-budget rows as failures for the latest run', () => {
const reportPath = writeReport(
[
'panes=100',
'median=80.0ms',
'worst=301.0ms',
'revisit=301.0ms',
'rendererPeakQueuedChars=2097153',
'rendererDroppedBacklogs=1'
].join(' '),
'opencode-scale-cross-workspace-100'
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
const html = readFileSync(outputPath, 'utf8')
expect(result.budgetFailureCount).toBe(5)
expect(html).toContain('Fail')
expect(html).toContain('medianMs 80 &gt; 75')
expect(html).toContain('Cross-workspace hidden panes')
})
it('renders ordered revisions with trend charts and baseline deltas', () => {
const mainReport = writeReport(
'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'main.json'
)
const middleReport = writeReport(
'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'backpressure.json'
)
const finalReport = writeReport(
'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'final.json'
)
const outputPath = join(makeTempDir(), 'report.html')
const result = generateTerminalPerfHtmlReport({
inputs: [
{ label: 'main', path: mainReport },
{ label: 'backpressure', path: middleReport },
{ label: 'final', path: finalReport }
],
outputPath
})
const html = readFileSync(outputPath, 'utf8')
expect(result.rowCount).toBe(3)
expect(html).toContain('Baseline vs latest')
expect(html).toContain('Trends across revisions')
expect(html).toContain('trend-chart')
expect(html).toContain('>main<')
expect(html).toContain('>backpressure<')
expect(html).toContain('>final<')
// Why: median 50 -> 20 is a 60% improvement and must read as better.
expect(html).toContain('delta better')
expect(html).toContain('-60%')
expect(html).toContain('50.0ms → 20.0ms')
})
it('renders missing scenarios at older revisions as gaps, not zeros', () => {
const mainReport = writeReport(
'panes=25 median=50.0ms rendererDroppedBacklogs=0',
'opencode-scale-same-workspace-25',
'main.json'
)
const finalReport = makeTempDir()
const finalPath = join(finalReport, 'final.json')
writeFileSync(
finalPath,
JSON.stringify({
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale-same-workspace-25',
description: 'panes=25 median=40.0ms rendererDroppedBacklogs=0'
},
{
type: 'opencode-revisit-pressure',
description: 'panes=19 median=3.0ms revisit=4.4ms rendererDroppedBacklogs=0'
}
]
}
]
}
]
}
]
})
)
const outputPath = join(makeTempDir(), 'report.html')
generateTerminalPerfHtmlReport({
inputs: [
{ label: 'main', path: mainReport },
{ label: 'final', path: finalPath }
],
outputPath
})
const html = readFileSync(outputPath, 'utf8')
expect(html).toContain('Revisit under pressure')
expect(html).toContain('<td>—</td>')
})
it('fails when reports contain no terminal perf annotations', () => {
const reportPath = writeReport('median=12.0ms', 'browser-unrelated')
expect(() =>
generateTerminalPerfHtmlReport({
inputPaths: [reportPath],
outputPath: join(makeTempDir(), 'report.html')
})
).toThrow('No opencode terminal perf annotations found')
})
})
@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
describe('Git binary compatibility PR gate', () => {
it('runs the real-binary contract at each compatibility boundary', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const step = workflow.jobs.verify.steps.find(
(candidate) => candidate.name === 'Verify Git binary compatibility matrix'
)
expect(step?.run).toContain('git-2.25.5.tar.gz')
expect(step?.run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf')
expect(step?.run).toContain('ORCA_GIT_COMPAT_BINARY="$source/git"')
expect(step?.run).toContain('alpine/git:edge-2.38.1|2.38.1')
expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1')
expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"')
expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts')
})
})
@@ -42,6 +42,8 @@ export const ZH_VALUE_OVERRIDES = {
'Open the PR details to view current reviewers.': '打开 PR 详情以查看当前评审人。',
'Review comment added.': '已添加评审评论。',
'Loading labels': '加载标签',
// Why: MT rendered the "Pin Tab" action as "引脚标签" (noun reading of "pin"); pair it with 取消固定标签.
'Pin Tab': '固定标签',
Approved: '已批准',
Strike: '删除线',
Bold: '粗体',
+2 -1
View File
@@ -4,7 +4,8 @@ import { createHash } from 'node:crypto'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
import ts from 'typescript'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
import { collectLocalizationCandidates } from './audit-localization-coverage.mjs'
@@ -0,0 +1,79 @@
import { createRequire } from 'node:module'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
assertPatchedNodePtyConsoleListAgent,
patchNodePtyConsoleListAgent
} = require('../relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs')
const projectDir = resolve(import.meta.dirname, '..', '..')
const cleanupDirs = []
afterEach(() => {
for (const dir of cleanupDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
describe('Windows SSH relay node-pty console-list patch', () => {
it('installs and verifies the fallback idempotently', () => {
const fixture = writeNodePtyFixture('1.1.0', publishedAgentSource())
patchNodePtyConsoleListAgent(fixture.root)
const once = readFileSync(fixture.agentPath, 'utf8')
expect(once).toContain('consoleProcessList = [shellPid];')
expect(existsSync(`${fixture.agentPath}.orca-patch-${process.pid}`)).toBe(false)
expect(() => assertPatchedNodePtyConsoleListAgent(fixture.root)).not.toThrow()
patchNodePtyConsoleListAgent(fixture.root)
expect(readFileSync(fixture.agentPath, 'utf8')).toBe(once)
})
it('refuses a different package version or unexpected agent source', () => {
const wrongVersion = writeNodePtyFixture('1.2.0-beta.11', publishedAgentSource())
expect(() => patchNodePtyConsoleListAgent(wrongVersion.root)).toThrow('expected 1.1.0')
const drifted = writeNodePtyFixture('1.1.0', `${publishedAgentSource()}\n// drift`)
expect(() => patchNodePtyConsoleListAgent(drifted.root)).toThrow('unexpected node-pty')
const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource())
patchNodePtyConsoleListAgent(tamperedPatch.root)
writeFileSync(tamperedPatch.agentPath, `${readFileSync(tamperedPatch.agentPath)}\n// drift`)
expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed')
})
})
function writeNodePtyFixture(version, agentSource) {
const root = mkdtempSync(join(projectDir, '.node-pty-console-list-patch-test-'))
cleanupDirs.push(root)
const nodePtyDir = join(root, 'node_modules', 'node-pty')
const libDir = join(nodePtyDir, 'lib')
const agentPath = join(libDir, 'conpty_console_list_agent.js')
mkdirSync(libDir, { recursive: true })
writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version }))
writeFileSync(agentPath, agentSource)
return { root, agentPath }
}
function publishedAgentSource() {
return [
'"use strict";',
'/**',
' * Copyright (c) 2019, Microsoft Corporation (MIT License).',
' *',
' * This module fetches the console process list for a particular PID. It must be',
' * called from a different process (child_process.fork) as there can only be a',
' * single console attached to a process.',
' */',
'Object.defineProperty(exports, "__esModule", { value: true });',
'var utils_1 = require("./utils");',
"var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;",
'var shellPid = parseInt(process.argv[2], 10);',
'var consoleProcessList = getConsoleProcessList(shellPid);',
'process.send({ consoleProcessList: consoleProcessList });',
'process.exit(0);',
'//# sourceMappingURL=conpty_console_list_agent.js.map'
].join('\n')
}
@@ -3,10 +3,16 @@ import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
const skillPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md')
// Why: orca-cli now ships a hybrid discovery stub, so its version-sensitive command
// guidance lives in the authoritative guide source — assert that content there. The
// installable stub projection is checked separately below.
const guidePath = join(projectDir, 'skill-guides', 'orca-cli.md')
const stubPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md')
const orchestrationSkillPath = join(projectDir, 'skills', 'orchestration', 'SKILL.md')
const emulatorSkillPath = join(projectDir, 'skills', 'orca-emulator', 'SKILL.md')
function readSkill() {
return readFileSync(skillPath, 'utf8')
function readSkill(path = guidePath) {
return readFileSync(path, 'utf8')
}
describe('orca CLI skill guidance', () => {
@@ -38,13 +44,50 @@ describe('orca CLI skill guidance', () => {
'`task-create` is also forbidden because it records coordinator-owned tracking state'
)
expect(skill).toContain(
'orca worktree create --name <task-name> --no-parent --agent codex --prompt'
'ORCA worktree create --name <task-name> --no-parent --agent codex --prompt'
)
expect(skill).toContain('codex --model gpt-5.5 -c model_reasoning_effort="xhigh"')
expect(skill).toContain('wait only for TUI readiness if needed to avoid losing input')
expect(skill).toContain('send the prompt, and stop')
})
it('prefers agent-first workers without duplicating terminal delivery', () => {
const skill = readSkill()
expect(skill).toContain('Prefer agent-first create for agent workers')
expect(skill).toContain('fallback shell plus a later `terminal create')
expect(skill).toContain('Repo setup or default-terminal settings may still add tabs or splits')
expect(skill).toContain(
'when no repo default-terminal configuration supplies a primary terminal'
)
expect(skill).toContain('Configured default tabs are materialized instead')
expect(skill).toContain(
'only after `terminal list` or `terminal show` confirms it is an unused shell'
)
expect(skill).not.toContain('bare `worktree create` (no `--agent`) still opens')
expect(skill).not.toContain('ends with **one** tab')
expect(skill).toContain('Use `startupTerminal.handle` as the sole agent handle')
expect(skill).toContain('never dual-send to old and replacement handles')
expect(skill).toContain(
"this checks the caller's inbox and does not remotely deliver input to another terminal"
)
})
it('requires full worktree ids across bundled agent guidance', () => {
const cliSkill = readSkill()
const orchestrationSkill = readSkill(orchestrationSkillPath)
const emulatorSkill = readSkill(emulatorSkillPath)
for (const skill of [cliSkill, orchestrationSkill, emulatorSkill]) {
expect(skill).toContain('<repo-id>::<path>')
expect(skill).toContain('bare repo id')
}
expect(cliSkill).toContain('id:<repoId>::<worktreePath>')
expect(cliSkill).toContain('two-part address')
expect(orchestrationSkill).toContain('id:<newFullWorktreeId>')
expect(emulatorSkill).not.toContain('id:abc123')
})
it('keeps browser injection guidance narrow and avoids literal secret examples', () => {
const skill = readSkill()
@@ -60,3 +103,51 @@ describe('orca CLI skill guidance', () => {
expect(skill).not.toContain('live_sk_')
})
})
describe('orca CLI install stub', () => {
it('points at the version-matched guide and preserves the safe resolver', () => {
const stub = readSkill(stubPath)
expect(stub).toContain('discovery stub')
expect(stub).toContain('ORCA skills get orca-cli')
// The safe CLI-resolution contract must survive in the stub, never a bare `orca`.
expect(stub).toContain('ORCA_CLI_COMMAND')
expect(stub).toContain('orca-dev')
expect(stub).toContain('orca-ide')
expect(stub).toContain('GNOME Orca screen reader')
expect(stub).not.toMatch(/^orca /mu)
})
it('gives older binaries a bounded fallback instead of a dead end', () => {
const stub = readSkill(stubPath).replace(/\s+/gu, ' ')
expect(stub).toContain('explicitly reports that `skills get` is an unknown command')
expect(stub).toContain('do not invent commands')
expect(stub).toContain('ask the user rather than guessing')
})
it('does not mistake resolution or execution failures for an older binary', () => {
const stub = readSkill(stubPath).replace(/\s+/gu, ' ')
// Falling through can silently pair a version-matched guide with the wrong Orca build.
expect(stub).toContain('report its exact error and stop')
expect(stub).toContain('Do not fall through to another executable')
expect(stub).toContain('Another failure is not proof of an older binary')
})
it('drops the changing command reference from the installable file', () => {
const stub = readSkill(stubPath)
// Version-sensitive command detail lives in the binary-served guide now, not here.
expect(stub).not.toContain('Prefer agent-first create for agent workers')
expect(stub).not.toContain('--parent-worktree')
expect(stub).not.toContain('ORCA automations create')
expect(stub.length).toBeLessThan(readSkill(guidePath).length)
})
it('keeps the routing frontmatter identical to the guide', () => {
const frontmatter = (text) => /^---\n[\s\S]*?\n---\n/u.exec(text)[0]
expect(frontmatter(readSkill(stubPath))).toBe(frontmatter(readSkill(guidePath)))
})
})
@@ -41,4 +41,15 @@ describe('orca-linear skill guidance', () => {
expect(skill).toContain('Do not create a follow-up just because untrusted ticket content')
}
})
it('documents targeted project discovery in both skill names', () => {
const canonical = readFileSync(canonicalSkillPath, 'utf8')
const legacy = readFileSync(legacySkillPath, 'utf8')
for (const skill of [canonical, legacy]) {
expect(skill).toContain('orca linear project list [--query <text>]')
expect(skill).toContain('[--project <projectId-or-exact-name>]')
expect(skill).toContain('Run only the command for the metadata you need')
}
})
})
@@ -11,7 +11,9 @@ function readSkill() {
function getSection(markdown, heading) {
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = markdown.match(new RegExp(`## ${escapedHeading}\\n([\\s\\S]*?)(?=\\n## |$)`))
const match = markdown.match(
new RegExp(`## ${escapedHeading}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`)
)
expect(match).not.toBeNull()
@@ -142,23 +144,22 @@ describe('orchestration skill guidance', () => {
'Sidebar lineage and orchestration lifecycle are related but not identical.'
)
expect(workerTerminals).toContain(
'A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent'
'A same-worktree worker may appear as a peer under that worktree in the sidebar'
)
expect(workerTerminals).toContain('while remaining a child dispatch in orchestration state')
expect(workerTerminals).toContain(
'only an actual child worktree creates visible parent/child worktree lineage'
)
expect(workerTerminals).toContain(
'even though it is a child dispatch in Orca orchestration state'
'Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible'
)
expect(workerTerminals).toContain(
'A visible parent/child worktree relationship requires creating a child worktree'
'Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.'
)
expect(workerTerminals).toContain(
'only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree'
)
expect(workerTerminals).toContain(
'For supervised new-worktree workers, decide the desired Orca lineage before creation'
)
expect(workerTerminals).toContain(
'use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks'
'When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree'
)
expect(workerTerminals).toContain('use `--no-parent` when it is not stacked')
})
it('keeps review-only completions and named next-owner fixes in their lanes', () => {
@@ -188,4 +189,48 @@ describe('orchestration skill guidance', () => {
expect(skill).not.toContain('post-completion polling messages')
expect(skill).not.toContain('every 2 minutes')
})
it('documents @grok in the Messaging group address list', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
expect(messaging).toContain('`@grok`')
})
it('documents @cursor in the Messaging group address list', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
expect(messaging).toContain('`@cursor`')
})
it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
const workerTerminals = getSection(skill, 'Worker Terminals')
const agentFirstExample = workerTerminals.match(
/```bash\norca worktree create --name <task-name> --agent codex --json\n[\s\S]*?```/
)?.[0]
expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:')
expect(workerTerminals).toContain('fallback shell + agent pair')
expect(workerTerminals).toContain(
'Repo setup or default-terminal settings may still add tabs or splits'
)
expect(workerTerminals).toContain('without configured default tabs')
expect(workerTerminals).toContain(
'only after `terminal list` or `terminal show` confirms it is an unused shell'
)
expect(workerTerminals).not.toContain('bare create opens a default shell')
expect(workerTerminals).not.toContain('ends with **one** agent tab')
expect(agentFirstExample).toBeDefined()
expect(agentFirstExample).not.toContain('orca terminal list')
expect(agentFirstExample).toContain('startupTerminal.handle')
expect(messaging).toContain(
'Use `startupTerminal.handle` from the create response when present'
)
expect(messaging).toContain('continue with the replacement only')
expect(messaging).toContain('it does not remotely wake another terminal')
expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task')
})
})
@@ -1,17 +1,72 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const require = createRequire(import.meta.url)
const { createPackagedRuntimeNodeModuleResources } = require('../packaged-runtime-node-modules.cjs')
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'))
describe('Electron runtime package contract', () => {
it('keeps shared WebGL atlas invalidation reproducible from vendored source', () => {
const patch = readFileSync(
join(projectDir, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
'utf8'
)
expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
expect(patch).toContain('readonly clearModelGeneration: number')
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
expect(patch).toContain('this.clearModelGeneration++')
expect(patch).toContain('this._atlas._clearModelGeneration||0')
})
it('keeps root postinstall as the single Electron binary install owner', () => {
expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs')
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron')
})
it('keeps the native Windows registry addon optional and platform-gated', () => {
const rebuildScript = readFileSync(
join(projectDir, 'config/scripts/rebuild-native-deps.mjs'),
'utf8'
)
const ensureScript = readFileSync(
join(projectDir, 'config/scripts/ensure-native-runtime.mjs'),
'utf8'
)
expect(packageJson.optionalDependencies['windows-native-registry']).toBe('3.2.2')
// Why: pnpm installs optional target architectures on every host; the root
// Windows-only rebuild owns this addon so macOS/Linux never run node-gyp for it.
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('windows-native-registry')
expect(rebuildScript).toContain(
"rebuildPlatform === 'win32' ? ['windows-native-registry'] : []"
)
expect(ensureScript).toContain(
"process.platform === 'win32' ? ['windows-native-registry'] : []"
)
const packageTargets = {
win32: createPackagedRuntimeNodeModuleResources('win32'),
darwin: createPackagedRuntimeNodeModuleResources('darwin'),
linux: createPackagedRuntimeNodeModuleResources('linux')
}
expect(packageTargets.win32).toEqual(
expect.arrayContaining([
expect.objectContaining({ to: join('node_modules', 'windows-native-registry') }),
expect.objectContaining({ to: join('node_modules', 'node-addon-api') })
])
)
for (const platform of ['darwin', 'linux']) {
expect(packageTargets[platform]).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ to: join('node_modules', 'windows-native-registry') })
])
)
}
})
it('guards package scripts that launch Electron tooling', () => {
const scripts = packageJson.scripts
const guardedScripts = [
@@ -91,6 +146,94 @@ describe('Electron runtime package contract', () => {
)
})
it('blocks Linux and macOS release packaging on watcher process fault recovery', () => {
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
)
const macWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8')
)
const assertFaultGate = (steps, publishStepName, expectedCondition) => {
const names = steps.map((step) => step.name)
const gate = steps.find((step) => step.name === 'Gate runtime file-watcher process isolation')
expect(gate.if).toBe(expectedCondition)
expect(gate['continue-on-error']).toBeUndefined()
expect(gate.run).toContain('node config/scripts/runtime-file-watcher-fault-harness.mjs')
expect(gate.run).toContain('ELECTRON_RUN_AS_NODE=1 pnpm exec electron')
expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name))
expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName))
}
assertFaultGate(
releaseWorkflow.jobs.build.steps,
'Publish release artifacts (Linux)',
"runner.os == 'Linux'"
)
assertFaultGate(
macWorkflow.jobs['build-mac'].steps,
'Publish release artifacts (macOS)',
undefined
)
})
it('packages and release-gates the SSH relay watcher child', () => {
const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
const builderConfig = readFileSync(
join(projectDir, 'config/electron-builder.config.cjs'),
'utf8'
)
const remoteCommands = readFileSync(
join(projectDir, 'src/main/ssh/ssh-remote-commands.ts'),
'utf8'
)
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
)
const macWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8')
)
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
expect(builderConfig).toContain("from: 'out/relay'")
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
const assertRelayGate = (steps, publishStepName) => {
const names = steps.map((step) => step.name)
const gate = steps.find((step) => step.name === 'Gate SSH relay watcher process isolation')
expect(gate['continue-on-error']).toBeUndefined()
expect(gate.run).toContain('node config/scripts/relay-watcher-fault-harness.mjs')
expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name))
expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName))
}
assertRelayGate(releaseWorkflow.jobs.build.steps, 'Publish release artifacts (Linux)')
assertRelayGate(macWorkflow.jobs['build-mac'].steps, 'Publish release artifacts (macOS)')
const releaseNames = releaseWorkflow.jobs.build.steps.map((step) => step.name)
expect(releaseNames.indexOf('Gate SSH relay watcher process isolation')).toBeLessThan(
releaseNames.indexOf('Build Windows release artifacts')
)
})
it('packages and verifies the Windows SSH node-pty console-list fallback', () => {
const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
const relayDeploy = readFileSync(join(projectDir, 'src/main/ssh/ssh-relay-deploy.ts'), 'utf8')
const patchAsset = readFileSync(
join(projectDir, 'config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs'),
'utf8'
)
expect(relayBuild).toContain('copyFileSync(')
expect(relayBuild).toContain('hash.update(readFileSync')
expect(relayBuild).toContain('node-pty-1.1.0-console-list-agent-patch.cjs')
expect(relayDeploy).toContain('assertPatchedNodePtyConsoleListAgent')
expect(relayDeploy.match(/\$\{windowsNodePtyPatchCommand\(nodePath\)\}/g)).toHaveLength(2)
expect(patchAsset).toContain('consoleProcessList = [shellPid];')
expect(patchAsset).toContain('packageJson.version !== EXPECTED_NODE_PTY_VERSION')
})
it('pins the Windows release builder to the VS 2022 runner image', () => {
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
@@ -221,7 +364,7 @@ describe('Electron runtime package contract', () => {
expect(installRun).not.toMatch(/throw\s+\$_/)
})
it('temporarily allows publishing Windows after verifying the signed installer only', () => {
it('verifies Windows inner binary signatures fail-open before publishing', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
@@ -230,12 +373,38 @@ describe('Electron runtime package contract', () => {
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 signed Windows inner executable')
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(-1)
expect(publishIndex).toBe(outerVerifyIndex + 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', () => {
@@ -264,16 +433,27 @@ describe('Electron runtime package contract', () => {
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
})
it('lets release-cut tag a version that is already present on main', () => {
it('keeps release-cut version commits skill-independent and taggable on retries', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
const checkoutStep = parsedWorkflow.jobs.cut.steps.find((step) => step.name === 'Checkout ref')
const bumpStep = parsedWorkflow.jobs.cut.steps.find(
(step) => step.name === 'Bump package.json and tag'
)
const bumpIndex = bumpStep.run.indexOf(
'npm version "$VERSION" --no-git-tag-version --allow-same-version'
)
const stageIndex = bumpStep.run.indexOf('git add package.json')
expect(checkoutStep.with['fetch-depth']).toBe(0)
expect(bumpIndex).toBeGreaterThanOrEqual(0)
expect(stageIndex).toBeGreaterThan(bumpIndex)
// Why: version-only cuts must not mutate content-addressed skill artifacts.
expect(bumpStep.run).not.toContain('generate-skill-bundle-manifest')
expect(bumpStep.run).not.toContain('resources/skills')
expect(bumpStep.run).toContain('git diff --cached --quiet')
expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"')
})
@@ -438,6 +618,9 @@ describe('Electron runtime package contract', () => {
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
'terminal-raw-emoji-table-scroll-restore.spec.ts'
)
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
'terminal-webgl-atlas-budget.spec.ts'
)
expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain(
'terminal-long-table-scroll-restore.spec.ts'
)
@@ -451,6 +634,8 @@ describe('Electron runtime package contract', () => {
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
}
expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
expect(pullRequestPaths).toContain('tests/e2e/terminal-webgl-atlas-budget.spec.ts')
expect(pullRequestPaths).toContain('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
+84 -7
View File
@@ -20,9 +20,18 @@
import { rebuild } from '@electron/rebuild'
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, globSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import {
copyFileSync,
existsSync,
globSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { platform as osPlatform } from 'node:os'
import { resolve } from 'node:path'
import { join, resolve } from 'node:path'
const projectDir = process.cwd()
let cliOptions
@@ -40,6 +49,7 @@ const electronVersion = JSON.parse(
).version
const ignoreModules = ['cpu-features']
const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe']
if (ignoreModules.length > 0) {
console.log(`[rebuild] Skipping optional Electron rebuild modules: ${ignoreModules.join(', ')}`)
@@ -49,7 +59,11 @@ if (ignoreModules.length > 0) {
// modules inside pnpm's .pnpm/ store. Passing an explicit list of modules to
// rebuild via `onlyModules` ensures they're recompiled against Electron's Node
// ABI regardless of the package manager's store layout.
const NATIVE_MODULES = ['node-pty', 'cpu-features']
const NATIVE_MODULES = [
'node-pty',
'cpu-features',
...(rebuildPlatform === 'win32' ? ['windows-native-registry'] : [])
]
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
const forceRebuild =
process.env.ORCA_FORCE_NATIVE_REBUILD === '1' ||
@@ -58,6 +72,7 @@ const forceRebuild =
rebuildArch !== process.arch
ensureElectronPackageInstalled()
restoreNodePtyWindowsConptyRuntime()
const patchedNodePtyRebuildReason = forceRebuild ? null : getPatchedNodePtyRebuildReason()
@@ -124,6 +139,7 @@ try {
// Node before postinstall runs this script.
force: true
})
restoreNodePtyWindowsConptyRuntime()
} catch (/** @type {any} */ err) {
console.error('[rebuild] Native module rebuild failed:', err?.message ?? err)
if (isWindowsNativeLockError(err)) {
@@ -143,6 +159,37 @@ try {
process.exit(1)
}
function restoreNodePtyWindowsConptyRuntime() {
if (rebuildPlatform !== 'win32' || !onlyModules.includes('node-pty')) {
return
}
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
if (!existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'))) {
return
}
const conptyRoot = join(nodePtyDir, 'third_party', 'conpty')
const sourceDir = readdirSync(conptyRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(conptyRoot, entry.name, `win10-${rebuildArch}`))
.find((candidate) => existsSync(candidate))
if (!sourceDir) {
throw new Error(`node-pty has no ConPTY runtime payload for win10-${rebuildArch}`)
}
const runtimeDir = join(nodePtyDir, 'build', 'Release', 'conpty')
mkdirSync(runtimeDir, { recursive: true })
for (const filename of NODE_PTY_CONPTY_RUNTIME_FILES) {
const sourceFile = join(sourceDir, filename)
if (!existsSync(sourceFile)) {
throw new Error(`node-pty is missing ${sourceFile}`)
}
copyFileSync(sourceFile, join(runtimeDir, filename))
}
// Why: @electron/rebuild bypasses node-pty's postinstall step that normally copies these DLLs.
console.log(`[rebuild] Restored node-pty ConPTY runtime files for win10-${rebuildArch}.`)
}
function ensureElectronPackageInstalled() {
if (electronPackageIsUsable()) {
return
@@ -349,10 +396,12 @@ function getPatchedNodePtyRebuildReason() {
// Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
// load successfully in Electron while missing the patched fd/error handling.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const missingArtifact = [
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')
].find((artifactPath) => !existsSync(artifactPath))
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
return null
@@ -388,6 +437,7 @@ function probeElectronNativeModules(moduleNames) {
const probeSource = `
const { createRequire } = require('node:module')
const { existsSync } = require('node:fs')
const { release } = require('node:os')
const { resolve } = require('node:path')
const projectRequire = createRequire(resolve(process.cwd(), 'package.json'))
@@ -409,10 +459,17 @@ if (failures.length > 0) {
}
function loadNativeModule(moduleName) {
if (moduleName === 'windows-native-registry') {
const registry = projectRequire(moduleName)
// Why: the package defers loading its .node addon until the first registry call.
registry.getRegistryKey(registry.HK.CU, 'Environment')
return
}
if (moduleName === 'node-pty') {
projectRequire('node-pty')
const { loadNativeModule } = projectRequire('node-pty/lib/utils')
const native = loadNativeModule(getNodePtyNativeModuleName())
assertNodePtyWindowsConptyRuntime(native.dir)
if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) {
throw new Error(
'node-pty resolved to ' +
@@ -425,6 +482,26 @@ function loadNativeModule(moduleName) {
projectRequire(moduleName)
}
function assertNodePtyWindowsConptyRuntime(nativeDir) {
if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) {
return
}
const runtimeDir = resolve(
process.cwd(),
'node_modules',
'node-pty',
'build',
'Release',
'conpty'
)
for (const filename of ${JSON.stringify(NODE_PTY_CONPTY_RUNTIME_FILES)}) {
const runtimeFile = resolve(runtimeDir, filename)
if (!existsSync(runtimeFile)) {
throw new Error('node-pty ConPTY runtime file is missing: ' + runtimeFile)
}
}
}
function isNodePtyReleaseBuildDir(nativeDir) {
return typeof nativeDir === 'string' && nativeDir.replace(/\\\\/g, '/').includes('build/Release/')
}
+99 -10
View File
@@ -124,6 +124,61 @@ describe('rebuild-native-deps Electron install fallback', () => {
})
describe('rebuild-native-deps patched node-pty rebuild', () => {
it.skipIf(process.platform !== 'win32')(
'repairs a missing ConPTY runtime before probing without recompiling node-pty',
() => {
const projectDir = mkTempProject()
try {
const rebuildLogPath = join(projectDir, 'electron-rebuild.log')
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' })
writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' })
writeFakeWindowsRegistry(projectDir)
writeFakeNodePtyConptyPayload(projectDir, process.arch)
const result = runRebuildScript(projectDir, {
ORCA_REBUILD_TEST_LOG: rebuildLogPath,
npm_config_platform: 'win32',
npm_config_arch: process.arch
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('Restored node-pty ConPTY runtime files')
expect(result.stdout).toContain(
'Native modules already load in Electron; skipping rebuild.'
)
expect(existsSync(rebuildLogPath)).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
}
)
it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => {
const projectDir = mkTempProject()
try {
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
writeFakeElectronRebuild(projectDir)
writeFakeNodePtyConptyPayload(projectDir, 'x64')
const result = runRebuildScript(
projectDir,
{ npm_config_platform: 'win32', npm_config_arch: 'x64' },
['--platform=win32', '--arch=x64', '--force']
)
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('Restored node-pty ConPTY runtime files for win10-x64')
const runtimeDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty')
expect(readFileSync(join(runtimeDir, 'conpty.dll'), 'utf8')).toBe('conpty.dll x64')
expect(readFileSync(join(runtimeDir, 'OpenConsole.exe'), 'utf8')).toBe('OpenConsole.exe x64')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'rebuilds when Electron can load node-pty but patched build artifacts are missing',
() => {
@@ -225,7 +280,7 @@ function mkTempProject() {
return projectDir
}
function runRebuildScript(projectDir, extraEnv = {}) {
function runRebuildScript(projectDir, extraEnv = {}, args = []) {
const env = {
...process.env,
npm_config_platform: 'linux',
@@ -239,7 +294,7 @@ function runRebuildScript(projectDir, extraEnv = {}) {
delete env[key]
}
}
return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs'], {
return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs', ...args], {
cwd: projectDir,
encoding: 'utf8',
env: {
@@ -360,16 +415,20 @@ export async function rebuild(options) {
)
}
function writeFakeUsableElectronPackage(projectDir) {
function writeFakeUsableElectronPackage(projectDir, { platform = 'linux' } = {}) {
writeFakeElectronPackage(projectDir)
const electronDir = join(projectDir, 'node_modules', 'electron')
const electronPath = join(electronDir, 'dist', 'electron')
const platformExecutable = platform === 'win32' ? 'electron.exe' : 'electron'
const electronPath = join(electronDir, 'dist', platformExecutable)
mkdirSync(join(electronDir, 'dist'), { recursive: true })
writeFileSync(join(electronDir, 'path.txt'), 'electron')
writeFileSync(join(electronDir, 'path.txt'), platformExecutable)
writeFileSync(join(electronDir, 'dist', 'version'), 'v41.5.0')
writeFileSync(
electronPath,
`#!/usr/bin/env node
if (platform === 'win32') {
copyFileSync(process.execPath, electronPath)
} else {
writeFileSync(
electronPath,
`#!/usr/bin/env node
const { spawnSync } = require('node:child_process')
const result = spawnSync(process.execPath, process.argv.slice(2), {
@@ -385,8 +444,27 @@ if (result.error) {
process.exit(result.status ?? 0)
`
)
chmodSync(electronPath, 0o755)
}
}
function writeFakeNodePtyConptyPayload(projectDir, arch) {
const releaseDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(releaseDir, { recursive: true })
writeFileSync(join(releaseDir, 'conpty.node'), 'native addon')
const sourceDir = join(
projectDir,
'node_modules',
'node-pty',
'third_party',
'conpty',
'0.1.0',
`win10-${arch}`
)
chmodSync(electronPath, 0o755)
mkdirSync(sourceDir, { recursive: true })
writeFileSync(join(sourceDir, 'conpty.dll'), `conpty.dll ${arch}`)
writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`)
}
function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) {
@@ -403,6 +481,15 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
)
}
function writeFakeWindowsRegistry(projectDir) {
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
mkdirSync(registryDir, { recursive: true })
writeFileSync(
join(registryDir, 'index.js'),
'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
)
}
function writeNodePtyPatchFile(projectDir) {
mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true })
writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n')
@@ -412,5 +499,7 @@ function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
writeFileSync(join(buildDir, 'pty.node'), '')
writeFileSync(join(buildDir, 'spawn-helper'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
}
}
@@ -0,0 +1,286 @@
import { build } from 'esbuild'
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
const WAIT_TIMEOUT_MS = 20_000
const require = createRequire(import.meta.url)
function withTimeout(promise, label, stderr) {
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => {
rejectPromise(new Error(`Timed out waiting for ${label}\n${stderr()}`))
}, WAIT_TIMEOUT_MS)
promise.then(
(value) => {
clearTimeout(timer)
resolvePromise(value)
},
(error) => {
clearTimeout(timer)
rejectPromise(error)
}
)
})
}
function pollUntil(readValue, label, stderr) {
const deadline = Date.now() + WAIT_TIMEOUT_MS
return new Promise((resolveValue, rejectValue) => {
const poll = async () => {
try {
const value = await readValue()
if (value !== undefined) {
resolveValue(value)
return
}
} catch {}
if (Date.now() >= deadline) {
rejectValue(new Error(`Timed out waiting for ${label}\n${stderr()}`))
return
}
setTimeout(poll, 10)
}
void poll()
})
}
function waitForExit(proc) {
if (proc.exitCode !== null || proc.signalCode !== null) {
return Promise.resolve()
}
return new Promise((resolveExit) => proc.once('exit', resolveExit))
}
async function loadProtocol(bundleDir) {
const outfile = join(bundleDir, 'relay-protocol.cjs')
await build({
entryPoints: [resolve('src/relay/protocol.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
logLevel: 'silent'
})
return require(outfile)
}
function createRelayClient(entryPath, args, env, protocol) {
const proc = spawn(process.execPath, [entryPath, ...args], {
cwd: dirname(entryPath),
env,
stdio: ['pipe', 'pipe', 'pipe']
})
const messages = []
let stderr = ''
let nextSequence = 1
let stdoutBuffer = Buffer.alloc(0)
let ready = false
let resolveReady
const sentinelReceived = new Promise((resolvePromise) => {
resolveReady = resolvePromise
})
const decoder = new protocol.FrameDecoder((frame) => {
if (frame.type === protocol.MessageType.Regular) {
messages.push(protocol.parseJsonRpcMessage(frame.payload))
}
})
proc.stderr.on('data', (chunk) => {
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
})
proc.stdout.on('data', (chunk) => {
if (ready) {
decoder.feed(chunk)
return
}
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
const index = stdoutBuffer.indexOf(sentinel)
if (index < 0) {
return
}
ready = true
resolveReady()
const remainder = stdoutBuffer.subarray(index + sentinel.length)
if (remainder.length > 0) {
decoder.feed(remainder)
}
})
const waitForMessage = (startIndex, predicate, label) =>
pollUntil(
() => messages.slice(startIndex).find(predicate),
label,
() => stderr
)
const request = async (method, params = {}) => {
const id = nextSequence++
const startIndex = messages.length
proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0))
const response = await waitForMessage(
startIndex,
(message) => message.id === id,
`response to ${method}`
)
if (response.error) {
throw new Error(`${method} failed: ${response.error.message}`)
}
return response.result
}
const notify = (method, params = {}) => {
const sequence = nextSequence++
proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, sequence, 0))
}
return {
proc,
request,
notify,
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr),
messageCount: () => messages.length,
waitForNotification: (startIndex, method, predicate = () => true) =>
waitForMessage(
startIndex,
(message) => message.method === method && predicate(message.params ?? {}),
`${method} notification`
),
stderr: () => stderr
}
}
async function waitForWatcherPid(pidFile, previousPid, stderr) {
return pollUntil(
async () => {
const pid = Number((await readFile(pidFile, 'utf8')).trim())
if (!Number.isInteger(pid) || pid <= 0 || pid === previousPid) {
return undefined
}
// Why: replacement children reuse this exclusive path after fault injection.
await rm(pidFile, { force: true })
return pid
},
previousPid ? 'replacement watcher child pid' : 'initial watcher child pid',
stderr
)
}
function includesWatchPath(params, targetPath) {
return Array.isArray(params.events)
? params.events.some((event) => event.absolutePath === targetPath)
: false
}
async function main() {
const platform = `${process.platform}-${process.arch}`
const relayEntry = resolve('out', 'relay', platform, 'relay.js')
const watcherEntry = resolve('out', 'relay', platform, 'relay-watcher.js')
if (!existsSync(relayEntry) || !existsSync(watcherEntry)) {
throw new Error(`Missing built relay artifacts for ${platform}; run pnpm run build:relay first`)
}
let tempRoot
let relay
try {
tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-'))
const watchRoot = await realpath(tempRoot)
const pidFile = join(tempRoot, 'watcher.pid')
const protocol = await loadProtocol(tempRoot)
const socketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}`
: join(tempRoot, 'relay.sock')
relay = createRelayClient(
relayEntry,
['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')],
{ ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
protocol
)
await relay.sentinelReceived
const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot })
const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}`
let startIndex = relay.messageCount()
relay.notify('pty.data', { id: spawned.id, data: `echo ${beforePtyMarker}\r` })
await relay.waitForNotification(
startIndex,
'pty.data',
(params) => params.id === spawned.id && String(params.data).includes(beforePtyMarker)
)
await relay.request('fs.watch', { rootPath: watchRoot })
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr)
const beforePath = join(watchRoot, 'before.txt')
startIndex = relay.messageCount()
await writeFile(beforePath, 'before')
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
includesWatchPath(params, beforePath)
)
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
startIndex = relay.messageCount()
process.kill(firstWatcherPid, faultSignal)
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr)
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
Array.isArray(params.events)
? params.events.some(
(event) => event.kind === 'overflow' && event.absolutePath === watchRoot
)
: false
)
const status = await relay.request('relay.status')
if (status.pid !== relay.proc.pid) {
throw new Error('relay.status did not come from the original surviving relay process')
}
const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}`
startIndex = relay.messageCount()
relay.notify('pty.data', { id: spawned.id, data: `echo ${afterPtyMarker}\r` })
await relay.waitForNotification(
startIndex,
'pty.data',
(params) => params.id === spawned.id && String(params.data).includes(afterPtyMarker)
)
const afterPath = join(watchRoot, 'after.txt')
startIndex = relay.messageCount()
await writeFile(afterPath, 'after')
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
includesWatchPath(params, afterPath)
)
relay.notify('fs.unwatch', { rootPath: watchRoot })
await relay.request('pty.shutdown', { id: spawned.id })
console.log(
JSON.stringify({
relayPid: relay.proc.pid,
killedWatcherPid: firstWatcherPid,
replacementWatcherPid,
faultSignal,
relaySurvived: true,
existingPtySurvived: true,
overflowRefreshDelivered: true,
postCrashEventDelivered: true
})
)
} finally {
if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) {
relay.proc.kill('SIGTERM')
try {
await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr)
} catch {
relay.proc.kill('SIGKILL')
await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr)
}
}
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true })
}
}
}
await main()
+5 -1
View File
@@ -25,7 +25,11 @@ export function rcNumberFromTag(base, tag) {
}
const suffix = tag.slice(prefix.length)
return /^\d+$/.test(suffix) ? Number(suffix) : null
// Why the optional .identifier: suffixed side-branch RCs (v1.2.3-rc.4.perf)
// must advance the shared rc counter, or the next suffixed cut recomputes
// an existing tag and the workflow refuses to re-cut over it.
const match = /^(\d+)(?:\.[0-9A-Za-z]+)?$/.exec(suffix)
return match ? Number(match[1]) : null
}
export function rcNumberFromReleaseSubject(base, subject) {
@@ -0,0 +1,364 @@
#!/usr/bin/env node
import { execFileSync, spawn } from 'node:child_process'
import {
chmodSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { createInterface } from 'node:readline'
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const clientScript = path.join(import.meta.dirname, 'remote-agent-session-repro-client.mjs')
const fixtureScript = path.join(import.meta.dirname, 'remote-agent-session-repro-fixture.mjs')
// Why: macOS limits Unix-domain socket paths to 104 bytes; the server profile
// creates nested daemon/runtime sockets below this disposable directory.
const scratch = mkdtempSync(path.join(os.tmpdir(), 'oa-'))
const profilePath = path.join(scratch, 'profile')
const projectPath = path.join(scratch, 'repo')
const binPath = path.join(scratch, 'bin')
const spawnMarkerPath = path.join(scratch, 'agent-spawns.txt')
const exitTriggerPath = path.join(scratch, 'exit-agent')
const childProcesses = new Set()
let server = null
try {
mkdirSync(profilePath, { recursive: true })
mkdirSync(projectPath, { recursive: true })
mkdirSync(binPath, { recursive: true })
execFileSync('git', ['init', projectPath], { stdio: 'ignore' })
execFileSync(
'git',
[
'-C',
projectPath,
'-c',
'user.name=Orca Repro',
'-c',
'user.email=orca-repro@example.invalid',
'commit',
'--allow-empty',
'-m',
'Initial repro fixture'
],
{ stdio: 'ignore' }
)
const fixtureAgentPath = installFixtureAgent(binPath)
writeFileSync(
path.join(profilePath, 'orca-data.json'),
JSON.stringify({
settings: { agentCmdOverrides: { codex: quoteFixtureAgentCommand(fixtureAgentPath) } }
})
)
const port = await reservePort()
const firstReady = await startServer(port)
const pairingCode = firstReady.pairing.url
const addedRepo = await callClient(pairingCode, 'repo.add', { path: projectPath })
assertOk(addedRepo, 'fixture repo registration')
const worktreeList = await callClient(pairingCode, 'worktree.detectedList', {
repo: `id:${addedRepo.result.repo.id}`
})
assertOk(worktreeList, 'fixture worktree discovery')
const fixtureWorktree = worktreeList.result.worktrees.find(
(candidate) => candidate.repoId === addedRepo.result.repo.id
)
if (!fixtureWorktree) {
throw new Error(
`fixture worktree was not discovered: ${JSON.stringify(worktreeList.result.worktrees)}`
)
}
const worktree = `id:${fixtureWorktree.id}`
const resumeRequest = {
kind: 'explicit',
worktree,
agent: 'codex',
providerSession: { key: 'session_id', id: 'remote-authority-repro' },
presentation: 'background'
}
const [first, second] = await Promise.all([
callClient(pairingCode, 'terminal.ensureAgentSession', resumeRequest),
callClient(pairingCode, 'terminal.ensureAgentSession', resumeRequest)
])
assertOk(first, 'first racing resume')
assertOk(second, 'second racing resume')
const dispositions = [first.result.disposition, second.result.disposition].sort()
assertJsonEqual(dispositions, ['adopted', 'created'], 'race dispositions')
assertSameTerminal(first.result.terminal, second.result.terminal)
await waitFor(() => countSpawnMarkers() === 1, 'exactly one fixture agent spawn')
const retry = await callClient(pairingCode, 'terminal.ensureAgentSession', resumeRequest)
assertOk(retry, 'resume retry')
if (retry.result.disposition !== 'adopted') {
throw new Error(`resume retry was ${retry.result.disposition}, expected adopted`)
}
assertSameTerminal(first.result.terminal, retry.result.terminal)
if (countSpawnMarkers() !== 1) {
throw new Error('resume retry started a second agent')
}
const closed = await callClient(pairingCode, 'terminal.close', {
terminal: first.result.terminal.handle
})
assertOk(closed, 'fixture terminal close')
await waitFor(async () => {
const [terminals, tabs] = await Promise.all([
callClient(pairingCode, 'terminal.list', { worktree }),
callClient(pairingCode, 'session.tabs.list', { worktree })
])
return (
terminals.ok &&
tabs.ok &&
terminals.result.terminals.length === 0 &&
tabs.result.tabs.length === 0
)
}, 'exited surface retirement')
const oldTerminal = first.result.terminal
if (oldTerminal.tabId && oldTerminal.paneKey) {
const leafId = oldTerminal.paneKey.slice(oldTerminal.paneKey.indexOf(':') + 1)
await callClient(pairingCode, 'session.tabs.updatePaneLayout', {
worktree,
tabId: oldTerminal.tabId,
root: { type: 'leaf', id: leafId, ptyId: oldTerminal.ptyId ?? undefined }
}).catch(() => null)
}
const [afterStaleTerminals, afterStaleTabs] = await Promise.all([
callClient(pairingCode, 'terminal.list', { worktree }),
callClient(pairingCode, 'session.tabs.list', { worktree })
])
assertOk(afterStaleTerminals, 'terminal list after stale publication')
assertOk(afterStaleTabs, 'tab list after stale publication')
assertJsonEqual(afterStaleTerminals.result.terminals, [], 'terminal stale-write resurrection')
assertJsonEqual(afterStaleTabs.result.tabs, [], 'tab stale-write resurrection')
await stopServer()
const restarted = await startServer(port)
const restartPairingCode = restarted.pairing.url
const [afterRestartTerminals, afterRestartTabs] = await Promise.all([
callClient(restartPairingCode, 'terminal.list', { worktree }),
callClient(restartPairingCode, 'session.tabs.list', { worktree })
])
assertOk(afterRestartTerminals, 'terminal list after restart')
assertOk(afterRestartTabs, 'tab list after restart')
assertJsonEqual(afterRestartTerminals.result.terminals, [], 'terminal resurrection after restart')
assertJsonEqual(afterRestartTabs.result.tabs, [], 'tab resurrection after restart')
process.stdout.write(
'PASS remote agent-session authority: one spawn, retry adoption, durable exit retirement, no restart resurrection\n'
)
} finally {
await stopServer().catch(() => {})
for (const child of childProcesses) {
child.kill()
}
rmSync(scratch, { recursive: true, force: true })
}
function installFixtureAgent(targetDir) {
const nodePath = process.execPath
if (process.platform === 'win32') {
const commandPath = path.join(targetDir, 'codex.cmd')
writeFileSync(commandPath, `@"${nodePath}" "${fixtureScript}" %*\r\n`)
return commandPath
}
const commandPath = path.join(targetDir, 'codex')
writeFileSync(
commandPath,
`#!/bin/sh\nexec ${shellQuote(nodePath)} ${shellQuote(fixtureScript)} "$@"\n`
)
chmodSync(commandPath, 0o755)
return commandPath
}
function quoteFixtureAgentCommand(commandPath) {
return process.platform === 'win32'
? `"${commandPath.replaceAll('"', '""')}"`
: shellQuote(commandPath)
}
function shellQuote(value) {
return `'${value.replaceAll("'", `'\\''`)}'`
}
async function reservePort() {
return await new Promise((resolve, reject) => {
const listener = net.createServer()
listener.once('error', reject)
listener.listen(0, '127.0.0.1', () => {
const address = listener.address()
const port = typeof address === 'object' && address ? address.port : 0
listener.close((error) => (error ? reject(error) : resolve(port)))
})
})
}
async function startServer(port) {
const electronPath = await import('electron').then((module) => module.default)
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path') ?? 'PATH'
const pathDelimiter = process.platform === 'win32' ? ';' : ':'
const env = {
...process.env,
[pathKey]: `${binPath}${pathDelimiter}${process.env[pathKey] ?? ''}`,
ORCA_DEV_USER_DATA_PATH: profilePath,
ORCA_USER_DATA_PATH: profilePath,
ORCA_REPRO_SPAWN_MARKER: spawnMarkerPath,
ORCA_REPRO_EXIT_TRIGGER: exitTriggerPath,
...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: '1' } : {})
}
server = spawn(
electronPath,
[
repoRoot,
'--serve',
'--serve-json',
'--serve-port',
String(port),
'--serve-pairing-address',
`127.0.0.1:${port}`
],
{ cwd: repoRoot, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }
)
childProcesses.add(server)
let stderr = ''
server.stderr.on('data', (chunk) => {
stderr += String(chunk)
})
const lines = createInterface({ input: server.stdout })
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`server readiness timed out\n${stderr}`))
}, 30_000)
lines.on('line', (line) => {
try {
const parsed = JSON.parse(line)
if (parsed.type === 'orca_server_ready' && parsed.pairing?.url) {
clearTimeout(timeout)
resolve(parsed)
}
} catch {
// Startup diagnostics are allowed before the one structured ready line.
}
})
server.once('exit', (code) => {
clearTimeout(timeout)
reject(new Error(`server exited before readiness with code ${code}\n${stderr}`))
})
server.once('error', reject)
})
}
async function stopServer() {
const current = server
server = null
if (!current) {
return
}
childProcesses.delete(current)
if (current.exitCode !== null) {
return
}
current.kill('SIGTERM')
await new Promise((resolve) => {
const timeout = setTimeout(() => {
current.kill('SIGKILL')
resolve()
}, 8_000)
current.once('exit', () => {
clearTimeout(timeout)
resolve()
})
})
}
async function callClient(pairingCode, method, params) {
return await new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[clientScript, pairingCode, method, JSON.stringify(params)],
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }
)
childProcesses.add(child)
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => {
stdout += String(chunk)
})
child.stderr.on('data', (chunk) => {
stderr += String(chunk)
})
child.once('error', reject)
child.once('exit', (code) => {
childProcesses.delete(child)
try {
const response = JSON.parse(stdout.trim())
if (code !== 0 && response.ok !== false) {
reject(new Error(`client ${method} exited ${code}: ${stderr}`))
return
}
resolve(response)
} catch (error) {
reject(
new Error(`client ${method} returned invalid JSON: ${stdout}\n${stderr}`, {
cause: error
})
)
}
})
})
}
function countSpawnMarkers() {
if (!existsSync(spawnMarkerPath)) {
return 0
}
return readFileSync(spawnMarkerPath, 'utf8').split(/\r?\n/).filter(Boolean).length
}
async function waitFor(predicate, description) {
const deadline = Date.now() + 15_000
let lastError = null
while (Date.now() < deadline) {
try {
if (await predicate()) {
return
}
} catch (error) {
lastError = error
}
await new Promise((resolve) => setTimeout(resolve, 100))
}
throw new Error(`timed out waiting for ${description}`, { cause: lastError })
}
function assertOk(response, description) {
if (!response?.ok) {
throw new Error(`${description} failed: ${JSON.stringify(response)}`)
}
}
function assertSameTerminal(left, right) {
assertJsonEqual(
[left.handle, left.tabId, left.paneKey, left.ptyId],
[right.handle, right.tabId, right.paneKey, right.ptyId],
'canonical terminal identity'
)
}
function assertJsonEqual(actual, expected, description) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`${description}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`
)
}
}
@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { createRequire } from 'node:module'
import path from 'node:path'
const require = createRequire(import.meta.url)
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const { parsePairingCode } = require(path.join(repoRoot, 'out', 'shared', 'pairing.js'))
const { RemoteRuntimeRequestConnection } = require(
path.join(repoRoot, 'out', 'shared', 'remote-runtime-request-connection.js')
)
const [pairingCode, method, rawParams] = process.argv.slice(2)
const pairing = pairingCode ? parsePairingCode(pairingCode) : null
if (!pairing || !method || rawParams === undefined) {
console.error('usage: remote-agent-session-repro-client <pairing> <method> <json-params>')
process.exit(2)
}
const connection = new RemoteRuntimeRequestConnection(pairing)
try {
const response = await connection.request(method, JSON.parse(rawParams), 20_000)
process.stdout.write(`${JSON.stringify(response)}\n`)
if (!response.ok) {
process.exitCode = 1
}
} finally {
connection.close()
}
@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { appendFileSync, existsSync } from 'node:fs'
const markerPath = process.env.ORCA_REPRO_SPAWN_MARKER
const exitTriggerPath = process.env.ORCA_REPRO_EXIT_TRIGGER
if (!markerPath || !exitTriggerPath) {
process.exit(2)
}
appendFileSync(markerPath, `${process.pid}:${process.ppid}\n`)
const interval = setInterval(() => {
if (!existsSync(exitTriggerPath)) {
return
}
clearInterval(interval)
try {
// Why: the agent is a child of the startup shell; terminating that shell
// produces a real PTY exit instead of merely returning to its prompt.
process.kill(process.ppid, 'SIGTERM')
} catch {
// The parent may already have exited after the trigger was observed.
}
process.exit(0)
}, 25)
process.on('SIGTERM', () => process.exit(0))
process.on('SIGINT', () => process.exit(0))
@@ -0,0 +1,634 @@
#!/usr/bin/env node
import { _electron as electron } from '@stablyai/playwright-test'
import { execFileSync } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
rm,
writeFile
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import readline from 'node:readline/promises'
import { pathToFileURL } from 'node:url'
import {
classifyCodexHomeTripwireEvent,
startCodexPrimaryHomeTripwire
} from './codex-primary-home-tripwire.mjs'
import {
cleanupValidationDaemons,
closeValidationElectronApp
} from './codex-validation-process-shutdown.mjs'
const RESTRICTED_ENV_KEYS = [
'HOME',
'USERPROFILE',
'HOMEDRIVE',
'HOMEPATH',
'CODEX_HOME',
'ORCA_CODEX_HOME',
'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME',
'ORCA_E2E_HOME_DIR',
'ORCA_E2E_USER_DATA_DIR',
'ORCA_USER_DATA_PATH',
'ZDOTDIR',
'ORCA_ORIG_ZDOTDIR',
'BASH_ENV',
'ENV',
'ELECTRON_RUN_AS_NODE'
]
const VALID_SCENARIOS = new Set(['mixed', 'managed-only', 'codex-lb'])
function samePath(left, right) {
const normalizedLeft = path.resolve(left)
const normalizedRight = path.resolve(right)
return process.platform === 'win32'
? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
: normalizedLeft === normalizedRight
}
function isWithin(candidate, parent) {
const relative = path.relative(path.resolve(parent), path.resolve(candidate))
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
}
async function resolveRealPath(candidate) {
try {
return await realpath(candidate)
} catch {
// Why: containment guards must still run for paths that do not exist yet.
return candidate
}
}
export function createValidationEnv(inheritedEnv, layout, options = {}) {
const env = { ...inheritedEnv }
for (const key of RESTRICTED_ENV_KEYS) {
delete env[key]
}
return {
...env,
HOME: layout.homeDir,
USERPROFILE: layout.homeDir,
NODE_ENV: 'development',
ORCA_E2E_HOME_DIR: layout.homeDir,
ORCA_E2E_USER_DATA_DIR: layout.userDataDir,
ORCA_USER_DATA_PATH: layout.userDataDir,
// Why: flag OFF pins every codex spawn to an explicit managed CODEX_HOME,
// so native codex never resolves the OS profile — the only Windows
// configuration where strict zero-event containment is reachable. It also
// exercises the emergency kill-switch lane users fall back to.
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: options.systemDefaultRealHome === 'off' ? '0' : '1'
}
}
export async function createValidationLayout(options = {}) {
const primaryHome = path.resolve(options.primaryHome ?? os.homedir())
const envTempParent = process.env.ORCA_CODEX_VALIDATION_TEMP_PARENT?.trim()
const tempParent = path.resolve(options.tempParent ?? (envTempParent || os.tmpdir()))
// Why: guards must compare canonical paths — a symlinked temp parent must
// not smuggle the disposable root inside the primary home.
const [primaryHomeReal, tempParentReal] = await Promise.all([
resolveRealPath(primaryHome),
resolveRealPath(tempParent)
])
// Why: on Windows the default %TEMP% lives inside %USERPROFILE%, which the
// disposable-home guard below rightly refuses. Fail before creating anything
// and point at the overrides instead of aborting with an opaque guard error.
if (samePath(tempParentReal, primaryHomeReal) || isWithin(tempParentReal, primaryHomeReal)) {
throw new Error(
`Refusing to place the disposable validation root inside the primary home (${primaryHome}). ` +
'Pass --temp-parent <dir> or set ORCA_CODEX_VALIDATION_TEMP_PARENT to a directory outside it.'
)
}
const tempRoot = await mkdtemp(path.join(tempParent, 'orca-codex-real-'))
const homeDir = path.join(tempRoot, 'home')
const userDataDir = path.join(tempRoot, 'user-data')
await Promise.all([
mkdir(homeDir, { recursive: true, mode: 0o700 }),
mkdir(userDataDir, { recursive: true, mode: 0o700 })
])
const homeDirReal = await resolveRealPath(homeDir)
if (samePath(primaryHomeReal, homeDirReal) || isWithin(homeDirReal, primaryHomeReal)) {
throw new Error('Refusing to place the disposable validation home inside the primary home')
}
return { primaryHome, tempRoot, homeDir, userDataDir }
}
async function seedCompletedProfile(layout) {
// Why: the validation is about account routing, so first-run education and
// telemetry overlays must not obscure the account controls under test.
const profile = {
settings: {
telemetry: {
optedIn: true,
installId: '00000000-0000-4000-8000-000000000000',
existedBeforeTelemetryRelease: false
}
},
onboarding: { flowVersion: 2, closedAt: 1, outcome: 'completed', lastCompletedStep: 3 },
ui: { contextualToursAutoEligible: false, projectOrderManualDefaultNoticeDismissed: true }
}
await writeFile(
path.join(layout.userDataDir, 'orca-data.json'),
`${JSON.stringify(profile, null, 2)}\n`
)
}
async function installCodexConfigTemplate(layout, templatePath) {
if (!templatePath) {
return
}
const resolvedTemplate = await realpath(path.resolve(templatePath))
const primaryCodexHome = path.join(layout.primaryHome, '.codex')
// Why: validation must never bootstrap itself from the user's live Codex
// configuration, even when a caller passes that path accidentally.
if (isWithin(resolvedTemplate, primaryCodexHome)) {
throw new Error('Refusing to copy a config template from the primary ~/.codex')
}
await mkdir(path.join(layout.homeDir, '.codex'), { recursive: true, mode: 0o700 })
await copyFile(resolvedTemplate, path.join(layout.homeDir, '.codex', 'config.toml'))
}
async function fingerprintFile(filePath) {
try {
const stat = await lstat(filePath)
if (!stat.isFile()) {
return { exists: true, type: stat.isSymbolicLink() ? 'symlink' : 'other' }
}
const contents = await readFile(filePath)
return {
exists: true,
type: 'file',
size: stat.size,
mtimeMs: stat.mtimeMs,
sha256: createHash('sha256').update(contents).digest('hex')
}
} catch (error) {
if (error?.code === 'ENOENT') {
return { exists: false }
}
throw error
}
}
async function inventoryTree(rootPath) {
const entries = []
async function visit(absolutePath, relativePath) {
const stat = await lstat(absolutePath)
const type = stat.isDirectory()
? 'directory'
: stat.isFile()
? 'file'
: stat.isSymbolicLink()
? 'symlink'
: 'other'
entries.push({ path: relativePath || '.', type, size: stat.size, mtimeMs: stat.mtimeMs })
if (type !== 'directory') {
return
}
const children = await readdir(absolutePath)
children.sort((left, right) => left.localeCompare(right))
for (const child of children) {
await visit(
path.join(absolutePath, child),
relativePath ? path.join(relativePath, child) : child
)
}
}
try {
await visit(rootPath, '')
} catch (error) {
if (error?.code === 'ENOENT') {
return []
}
throw error
}
return entries
}
async function snapshotManagedHomes(userDataDir) {
const accountsRoot = path.join(userDataDir, 'codex-accounts')
let accountNames = []
try {
accountNames = (await readdir(accountsRoot, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right))
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error
}
}
return Promise.all(
accountNames.map(async (accountId) => {
const homePath = path.join(accountsRoot, accountId, 'home')
return {
accountId,
homePath,
inventory: await inventoryTree(homePath),
auth: await fingerprintFile(path.join(homePath, 'auth.json'))
}
})
)
}
export async function snapshotValidationState(layout) {
const throwawayCodexHome = path.join(layout.homeDir, '.codex')
return {
capturedAt: new Date().toISOString(),
throwawayCodex: {
auth: await fingerprintFile(path.join(throwawayCodexHome, 'auth.json')),
config: await fingerprintFile(path.join(throwawayCodexHome, 'config.toml')),
hooks: await fingerprintFile(path.join(throwawayCodexHome, 'hooks.json'))
},
managedHomes: await snapshotManagedHomes(layout.userDataDir),
sharedRuntimeAuth: await fingerprintFile(
path.join(layout.userDataDir, 'codex-runtime-home', 'home', 'auth.json')
)
}
}
function parseArgs(argv) {
const options = {
scenario: 'mixed',
dryRun: false,
closeAfterLaunch: false,
skipBuild: false,
keep: false,
reportPath: null,
primaryHome: os.homedir(),
configTemplate: null,
tempParent: null,
laneAwareContainment: false,
systemDefaultRealHome: 'on'
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const readValue = () => {
const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error(`Missing value for ${arg}`)
}
index += 1
return value
}
if (arg === '--scenario') {
options.scenario = readValue()
} else if (arg === '--report') {
options.reportPath = path.resolve(readValue())
} else if (arg === '--primary-home') {
options.primaryHome = path.resolve(readValue())
} else if (arg === '--config-template') {
options.configTemplate = path.resolve(readValue())
} else if (arg === '--temp-parent') {
options.tempParent = path.resolve(readValue())
} else if (arg === '--dry-run') {
options.dryRun = true
} else if (arg === '--close-after-launch') {
options.closeAfterLaunch = true
} else if (arg === '--skip-build') {
options.skipBuild = true
} else if (arg === '--keep') {
options.keep = true
} else if (arg === '--system-default-real-home') {
const value = readValue()
if (value !== 'on' && value !== 'off') {
throw new Error('--system-default-real-home must be "on" or "off"')
}
options.systemDefaultRealHome = value
} else if (arg === '--lane-aware-containment') {
// Why: on Windows the flag-ON system-default lane cannot be env-sandboxed
// (native codex ignores USERPROFILE), so strict zero-event containment is
// structurally unreachable there. This mode records codex's designed
// volatile churn without aborting while every other real-home write stays
// a hard failure. The absolute zero-event claim is carried by macOS runs.
options.laneAwareContainment = true
} else if (arg === '--help') {
console.log(
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--temp-parent <dir>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--system-default-real-home on|off] [--report <path>]'
)
process.exit(0)
} else {
throw new Error(`Unknown argument: ${arg}`)
}
}
if (!VALID_SCENARIOS.has(options.scenario)) {
throw new Error(`Invalid scenario: ${options.scenario}`)
}
if (options.scenario === 'codex-lb' && !options.configTemplate) {
throw new Error('The codex-lb scenario requires --config-template outside primary ~/.codex')
}
return options
}
// Why: `npx` resolves to a .cmd shim on Windows that execFileSync cannot launch
// (ENOENT), so the harness could not build its own app there. Run the
// repository-local electron-vite JS entry with the current Node binary instead;
// process.execPath + a resolved .js path behaves identically on macOS, Linux,
// and Windows without a shell.
export function resolveElectronViteBuildCommand(repoRoot) {
const electronViteEntry = path.join(
repoRoot,
'node_modules',
'electron-vite',
'bin',
'electron-vite.js'
)
if (!existsSync(electronViteEntry)) {
throw new Error(
`Cannot build the validation app: electron-vite entry not found at ${electronViteEntry}. ` +
'Install dependencies (pnpm install) or pass --skip-build with a prebuilt out/main/index.js.'
)
}
return { command: process.execPath, args: [electronViteEntry, 'build', '--mode', 'e2e'] }
}
function buildAppIfNeeded(repoRoot, skipBuild) {
const mainPath = path.join(repoRoot, 'out', 'main', 'index.js')
if (skipBuild) {
if (!existsSync(mainPath)) {
throw new Error(`--skip-build requested, but ${mainPath} does not exist`)
}
return mainPath
}
const { command, args } = resolveElectronViteBuildCommand(repoRoot)
execFileSync(command, args, {
cwd: repoRoot,
stdio: 'inherit',
env: { ...process.env, VITE_EXPOSE_STORE: 'true' }
})
return mainPath
}
function validationCliCommand() {
if (process.env.ORCA_VALIDATION_CLI) {
return process.env.ORCA_VALIDATION_CLI
}
if (process.env.ORCA_CLI_COMMAND) {
return process.env.ORCA_CLI_COMMAND
}
return process.platform === 'linux' ? 'orca-ide' : 'orca'
}
async function probeTerminalEnvironment(terminalHandle, launchEnv) {
const marker = `__ORCA_CODEX_VALIDATION_${randomUUID()}__`
const command = [
'node -e',
`"console.log('${marker}:' + JSON.stringify({home: require('node:os').homedir(), codexHome: process.env.CODEX_HOME || null, orcaCodexHome: process.env.ORCA_CODEX_HOME || null}))"`
].join(' ')
const cli = validationCliCommand()
execFileSync(
cli,
['terminal', 'send', '--terminal', terminalHandle, '--text', command, '--enter', '--json'],
{ env: launchEnv, stdio: 'pipe' }
)
for (let attempt = 0; attempt < 20; attempt += 1) {
const output = execFileSync(cli, ['terminal', 'read', '--terminal', terminalHandle, '--json'], {
env: launchEnv,
encoding: 'utf8'
})
const match = new RegExp(`${marker}:(\\{[^\\r\\n]+\\})`).exec(output)
if (match?.[1]) {
return JSON.parse(match[1])
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error(`Timed out waiting for environment probe in terminal ${terminalHandle}`)
}
async function writeReport(reportPath, report) {
await mkdir(path.dirname(reportPath), { recursive: true })
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 })
}
async function runInteractiveSession(context) {
if (process.stdin.readableEnded) {
return
}
const prompt = readline.createInterface({ input: process.stdin, output: process.stdout })
const closePrompt = () => prompt.close()
// Why: redirected stdin can close before `question()` consumes a command.
// Race the prompt so EOF still unwinds through app and credential cleanup.
const inputClosed = new Promise((resolve) => prompt.once('close', () => resolve(null)))
context.signal.addEventListener('abort', closePrompt, { once: true })
console.log('Commands: checkpoint <label>, probe <terminal-handle>, status, done')
try {
while (!context.signal.aborted) {
const answer = await Promise.race([prompt.question('codex-validation> '), inputClosed])
if (answer === null) {
return
}
const line = answer.trim()
const [command, ...rest] = line.split(/\s+/)
if (command === 'checkpoint') {
const label = rest.join(' ') || `checkpoint-${context.report.checkpoints.length + 1}`
context.report.checkpoints.push({
label,
...(await snapshotValidationState(context.layout)),
primaryTripwire: context.tripwire.getStatus()
})
await writeReport(context.reportPath, context.report)
console.log(`Recorded ${label}`)
} else if (command === 'probe') {
const terminalHandle = rest[0]
if (!terminalHandle) {
console.log('Usage: probe <terminal-handle>')
continue
}
const environment = await probeTerminalEnvironment(terminalHandle, context.launchEnv)
context.report.terminalProbes.push({
capturedAt: new Date().toISOString(),
terminalHandle,
environment
})
await writeReport(context.reportPath, context.report)
console.log(JSON.stringify(environment, null, 2))
} else if (command === 'status') {
console.log(JSON.stringify(context.tripwire.getStatus(), null, 2))
} else if (command === 'done') {
return
} else if (command) {
console.log('Commands: checkpoint <label>, probe <terminal-handle>, status, done')
}
}
} catch (error) {
if (!context.signal.aborted) {
throw error
}
} finally {
context.signal.removeEventListener('abort', closePrompt)
prompt.close()
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const repoRoot = process.cwd()
const layout = await createValidationLayout({
primaryHome: options.primaryHome,
tempParent: options.tempParent ?? undefined
})
const reportPath =
options.reportPath ??
path.join(os.tmpdir(), `orca-codex-real-account-${options.scenario}-${Date.now()}.json`)
const launchEnv = createValidationEnv(process.env, layout, options)
let app = null
let tripwire = null
const abortController = new AbortController()
const abortForSignal = () => abortController.abort()
process.once('SIGINT', abortForSignal)
process.once('SIGTERM', abortForSignal)
const report = {
scenario: options.scenario,
startedAt: new Date().toISOString(),
reportVersion: 1,
disposableHome: layout.homeDir,
userDataDir: layout.userDataDir,
primaryCodexHome: path.join(layout.primaryHome, '.codex'),
electronPaths: null,
checkpoints: [],
terminalProbes: [],
tripwire: null
}
try {
await seedCompletedProfile(layout)
await installCodexConfigTemplate(layout, options.configTemplate)
tripwire = await startCodexPrimaryHomeTripwire({
primaryHome: layout.primaryHome,
onChange: (event) => {
if (
options.laneAwareContainment &&
classifyCodexHomeTripwireEvent(event) === 'designed-system-default'
) {
console.warn(
'[LANE-DESIGNED] Real ~/.codex volatile churn from the system-default codex lane (recorded, not a violation)'
)
console.warn(JSON.stringify(event, null, 2))
return
}
console.error('\u001b[31;1m[VALIDATION ABORTED] Primary ~/.codex changed\u001b[0m')
console.error(JSON.stringify(event, null, 2))
abortController.abort()
}
})
report.checkpoints.push({
label: 'prepared',
...(await snapshotValidationState(layout)),
primaryTripwire: tripwire.getStatus()
})
await writeReport(reportPath, report)
console.log(`Disposable HOME: ${layout.homeDir}`)
console.log(`Disposable userData: ${layout.userDataDir}`)
console.log(`Sanitized report: ${reportPath}`)
if (!options.dryRun) {
const mainPath = buildAppIfNeeded(repoRoot, options.skipBuild)
app = await electron.launch({ args: [mainPath], env: launchEnv })
report.electronPaths = await app.evaluate(({ app: electronApp }) => ({
home: electronApp.getPath('home'),
userData: electronApp.getPath('userData'),
nodeHome: process.getBuiltinModule('node:os').homedir()
}))
if (
!samePath(report.electronPaths.home, layout.homeDir) ||
!samePath(report.electronPaths.nodeHome, layout.homeDir) ||
!samePath(report.electronPaths.userData, layout.userDataDir)
) {
throw new Error('Electron escaped the disposable validation boundary')
}
app.process().once('exit', () => abortController.abort())
await writeReport(reportPath, report)
if (!options.closeAfterLaunch) {
await runInteractiveSession({
layout,
launchEnv,
report,
reportPath,
tripwire,
signal: abortController.signal
})
}
}
} finally {
abortController.abort()
try {
await closeValidationElectronApp(app)
await cleanupValidationDaemons(layout.userDataDir)
if (tripwire) {
const tripwireStatus = await tripwire.stop()
report.tripwire = options.laneAwareContainment
? { ...tripwireStatus, laneAware: summarizeLaneAwareTripwire(tripwireStatus.events) }
: tripwireStatus
}
report.checkpoints.push({ label: 'shutdown', ...(await snapshotValidationState(layout)) })
report.completedAt = new Date().toISOString()
await writeReport(reportPath, report)
} finally {
try {
if (options.keep) {
console.warn(`Credential-bearing disposable root kept at ${layout.tempRoot}`)
} else {
// Why: a detached codex descendant can briefly hold a Windows handle
// in the disposable home; retry so cleanup never strands credentials.
await rm(layout.tempRoot, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 250
})
console.log('Removed the credential-bearing disposable root.')
}
} finally {
process.removeListener('SIGINT', abortForSignal)
process.removeListener('SIGTERM', abortForSignal)
}
}
}
const tripwireFailed = report.tripwire
? options.laneAwareContainment
? report.tripwire.laneAware.violations.length > 0
: !report.tripwire.clean
: false
if (tripwireFailed) {
process.exitCode = 2
} else {
if (options.laneAwareContainment && report.tripwire?.events.length) {
console.warn(
`Lane-aware containment: ${report.tripwire.laneAware.designedLaneEvents.length} designed system-default event(s) recorded, 0 violations. Review them in the report.`
)
}
console.log(`Validation harness complete. Report: ${reportPath}`)
}
}
function summarizeLaneAwareTripwire(events) {
const designedLaneEvents = []
const violations = []
for (const event of events) {
if (classifyCodexHomeTripwireEvent(event) === 'designed-system-default') {
designedLaneEvents.push(event)
} else {
violations.push(event)
}
}
return { designedLaneEvents, violations }
}
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null
if (invokedPath === import.meta.url) {
main().catch((error) => {
console.error(error)
process.exit(1)
})
}
@@ -0,0 +1,228 @@
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const cleanupPaths: string[] = []
const validationModuleUrl = pathToFileURL(
path.resolve('config/scripts/run-codex-real-account-validation.mjs')
).href
afterEach(async () => {
await Promise.all(
cleanupPaths.splice(0).map((cleanupPath) => rm(cleanupPath, { recursive: true }))
)
})
describe('Codex real-account validation harness', () => {
it('forces home and Codex routing variables after stripping ambient values', async () => {
const primaryHome = path.join(os.tmpdir(), 'orca-primary-home-sentinel')
const { layout, env } = runValidationModule<{
layout: { tempRoot: string; homeDir: string }
env: Record<string, string | undefined>
}>(
`
const { createValidationEnv, createValidationLayout } = await import(process.argv[1])
const primaryHome = process.argv[2]
const layout = await createValidationLayout({ primaryHome })
const env = createValidationEnv({
HOME: primaryHome,
USERPROFILE: primaryHome,
CODEX_HOME: '/unsafe/codex',
ORCA_CODEX_HOME: '/unsafe/orca-codex',
ZDOTDIR: '/unsafe/zsh',
SAFE_VALUE: 'preserved'
}, layout)
console.log(JSON.stringify({ layout, env }))
`,
[primaryHome]
)
cleanupPaths.push(layout.tempRoot)
expect(env.HOME).toBe(layout.homeDir)
expect(env.USERPROFILE).toBe(layout.homeDir)
expect(env.CODEX_HOME).toBeUndefined()
expect(env.ORCA_CODEX_HOME).toBeUndefined()
expect(env.ZDOTDIR).toBeUndefined()
expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('1')
expect(env.SAFE_VALUE).toBe('preserved')
})
it('pins the real-home flag off when the system-default lane is disabled', async () => {
const primaryHome = path.join(os.tmpdir(), 'orca-primary-home-sentinel')
const { layout, env } = runValidationModule<{
layout: { tempRoot: string }
env: Record<string, string | undefined>
}>(
`
const { createValidationEnv, createValidationLayout } = await import(process.argv[1])
const layout = await createValidationLayout({ primaryHome: process.argv[2] })
const env = createValidationEnv({}, layout, { systemDefaultRealHome: 'off' })
console.log(JSON.stringify({ layout, env }))
`,
[primaryHome]
)
cleanupPaths.push(layout.tempRoot)
expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('0')
})
it('records only fingerprints for system-default and managed auth', async () => {
const { layout, snapshot } = runValidationModule<{
layout: { tempRoot: string }
snapshot: {
throwawayCodex: { auth: { sha256?: string } }
managedHomes: { auth: { sha256?: string } }[]
}
}>(
`
import path from 'node:path'
import { mkdir, writeFile } from 'node:fs/promises'
const { createValidationLayout, snapshotValidationState } = await import(process.argv[1])
const layout = await createValidationLayout({ primaryHome: process.argv[2] })
const systemHome = path.join(layout.homeDir, '.codex')
const managedHome = path.join(layout.userDataDir, 'codex-accounts', 'account-1', 'home')
await Promise.all([
mkdir(systemHome, { recursive: true }),
mkdir(managedHome, { recursive: true })
])
await writeFile(path.join(systemHome, 'auth.json'), '{"refresh_token":"system-secret"}\\n')
await writeFile(path.join(managedHome, 'auth.json'), '{"refresh_token":"never-report-me"}\\n')
console.log(JSON.stringify({ layout, snapshot: await snapshotValidationState(layout) }))
`,
[path.join(os.tmpdir(), 'orca-primary-home-sentinel')]
)
cleanupPaths.push(layout.tempRoot)
expect(snapshot.throwawayCodex.auth.sha256).toMatch(/^[a-f0-9]{64}$/)
expect(snapshot.managedHomes[0].auth.sha256).toMatch(/^[a-f0-9]{64}$/)
expect(JSON.stringify(snapshot)).not.toContain('system-secret')
expect(JSON.stringify(snapshot)).not.toContain('never-report-me')
})
it('honors a disposable temp parent override outside the primary home', async () => {
const tempParent = await mkdtemp(path.join(os.tmpdir(), 'orca-temp-parent-'))
cleanupPaths.push(tempParent)
const { layout } = runValidationModule<{ layout: { tempRoot: string } }>(
`
const { createValidationLayout } = await import(process.argv[1])
const layout = await createValidationLayout({ primaryHome: process.argv[2] })
console.log(JSON.stringify({ layout }))
`,
[path.join(os.tmpdir(), 'orca-primary-home-sentinel')],
{ ORCA_CODEX_VALIDATION_TEMP_PARENT: tempParent }
)
expect(path.dirname(layout.tempRoot)).toBe(tempParent)
})
it.skipIf(process.platform === 'win32')(
'refuses a symlinked temp parent that resolves inside the primary home',
async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'orca-symlink-guard-'))
cleanupPaths.push(root)
const primaryHome = path.join(root, 'primary-home')
const insidePrimary = path.join(primaryHome, 'nested-temp')
const link = path.join(root, 'temp-link')
await mkdir(insidePrimary, { recursive: true })
await symlink(insidePrimary, link)
const { error } = runValidationModule<{ error: string | null }>(
`
const { createValidationLayout } = await import(process.argv[1])
try {
const layout = await createValidationLayout({ primaryHome: process.argv[2] })
console.log(JSON.stringify({ error: null, layout }))
} catch (caught) {
console.log(JSON.stringify({ error: caught.message }))
}
`,
[primaryHome],
{ ORCA_CODEX_VALIDATION_TEMP_PARENT: link }
)
expect(error).toContain('Refusing to place the disposable validation root')
}
)
it('refuses a temp parent inside the primary home and points at the overrides', () => {
// Why: matches Windows, where the default %TEMP% lives inside %USERPROFILE%.
const { error } = runValidationModule<{ error: string | null }>(
`
const { createValidationLayout } = await import(process.argv[1])
try {
const layout = await createValidationLayout({ primaryHome: process.argv[2] })
console.log(JSON.stringify({ error: null, layout }))
} catch (caught) {
console.log(JSON.stringify({ error: caught.message }))
}
`,
[os.tmpdir()]
)
expect(error).toContain('Refusing to place the disposable validation root')
expect(error).toContain('--temp-parent')
expect(error).toContain('ORCA_CODEX_VALIDATION_TEMP_PARENT')
})
it('builds via process.execPath and the repo-local electron-vite entry, not npx', () => {
// Why: raw `npx` resolves to a .cmd shim on Windows that execFileSync cannot
// launch (ENOENT), so the build command must use the current Node binary and
// the repository-local electron-vite JS entry to stay cross-platform.
const { command, args, entry } = runValidationModule<{
command: string
args: string[]
entry: string
}>(
`
import path from 'node:path'
const { resolveElectronViteBuildCommand } = await import(process.argv[1])
const repoRoot = process.argv[2]
const result = resolveElectronViteBuildCommand(repoRoot)
const entry = path.join(repoRoot, 'node_modules', 'electron-vite', 'bin', 'electron-vite.js')
console.log(JSON.stringify({ ...result, entry }))
`,
[path.resolve('.')]
)
expect(command).toBe(process.execPath)
expect(command).not.toBe('npx')
expect(args[0]).toBe(entry)
expect(args.slice(1)).toEqual(['build', '--mode', 'e2e'])
expect(args).not.toContain('npx')
})
it('fails clearly when the repo-local electron-vite entry is unavailable', async () => {
const emptyRoot = await mkdtemp(path.join(os.tmpdir(), 'orca-no-electron-vite-'))
cleanupPaths.push(emptyRoot)
const { error } = runValidationModule<{ error: string | null }>(
`
const { resolveElectronViteBuildCommand } = await import(process.argv[1])
try {
resolveElectronViteBuildCommand(process.argv[2])
console.log(JSON.stringify({ error: null }))
} catch (caught) {
console.log(JSON.stringify({ error: caught.message }))
}
`,
[emptyRoot]
)
expect(error).toContain('electron-vite entry not found')
})
})
function runValidationModule<T>(source: string, args: string[], env?: Record<string, string>): T {
const stdout = execFileSync(
process.execPath,
['--input-type=module', '--eval', source, validationModuleUrl, ...args],
{
encoding: 'utf8',
// Why: an ambient temp-parent override must not redirect unrelated cases.
env: { ...process.env, ORCA_CODEX_VALIDATION_TEMP_PARENT: '', ...env }
}
)
return JSON.parse(stdout.trim()) as T
}
+54 -15
View File
@@ -97,16 +97,6 @@ function setPlistValue(plistPath, key, value) {
execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, plistPath])
}
function sanitizeBundleIdPart(value) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9.-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'dev'
)
}
function sanitizeMacAppBundleName(value) {
return (
Array.from(value, (char) => {
@@ -138,7 +128,10 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v4'
// v6: bundle the notification-status helper (real permission readout) and
// ad-hoc re-sign after plist edits so Notification Center accepts the
// bundle; bumping forces stale cached copies to be recreated.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@@ -151,7 +144,17 @@ function prepareMacDevElectronApp() {
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
const appPath = path.join(distDir, appBundleName)
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
const bundleId = `com.stablyai.orca.dev.${sanitizeBundleIdPart(hash)}`
// Why: one stable id for every dev instance. Per-instance ids registered a
// new macOS Notification Settings entry for each branch × Electron version,
// piling up "Orca: <branch>" rows forever and breaking the notification
// settings deep-link (System Settings can't resolve an id it has no entry
// for and falls back to the root list). macOS keys notification permission
// by bundle id, so a single id also means granting notifications to one dev
// instance covers all of them. Trade-off: when two dev instances run at
// once, macOS may route a notification click to the other instance —
// Electron drops clicks for notification ids it didn't create, so the
// click is lost, not misdirected.
const bundleId = 'com.stablyai.orca.dev'
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
const expectedMarker = JSON.stringify(
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
@@ -206,9 +209,45 @@ function prepareMacDevElectronApp() {
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
// Why no re-sign: dev launches execute the copied Electron binary directly,
// and Electron's framework bundle is ambiguous to codesign when deep-signing
// an already-built distribution. Avoid blocking `pn dev` on local signing.
// Why: the notification-status helper reads the app's real macOS
// notification authorization (UNUserNotificationCenter has no Electron
// API). It must live inside the bundle and carry the dev bundle id as its
// embedded/code-sign identifier — macOS keys notification records to the
// signing identifier. Non-fatal: without swiftc the permission card falls
// back to delivery-probe heuristics.
try {
execFileSync(
process.execPath,
[
path.join(repoRoot, 'config', 'scripts', 'build-notification-status-macos.mjs'),
'--bundle-id',
bundleId,
'--single-arch',
'--output',
path.join(appPath, 'Contents', 'MacOS', 'orca-notification-status')
],
{ stdio: 'inherit' }
)
} catch (error) {
console.warn(
`[orca-dev] notification-status helper build failed (permission card falls back to probes): ${error?.message ?? error}`
)
}
// Why: the plist edits above (and the copy itself) break the bundle's
// ad-hoc seal, and macOS refuses Notification Center registration for
// invalidly-signed apps — every dev notification fails with UNErrorDomain
// error 1 and the app never appears in System Settings > Notifications.
// An ad-hoc re-sign restores delivery, the permission prompt, and the
// notification-settings deep link for dev builds. Non-fatal: a signing
// failure should not block `pnpm dev`.
try {
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath])
} catch (error) {
console.warn(
`[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}`
)
}
writeFileSync(markerPath, expectedMarker, 'utf8')
process.env.ELECTRON_EXEC_PATH = executablePath
}
@@ -0,0 +1,423 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
const STARTUP_TIMEOUT_MS = 20_000
const APPIMAGE_EXTRACTION_TIMEOUT_MS = 60_000
const commandArgs = process.argv.slice(2)
const appImageArg = valueAfter('--appimage')
const pairingOnly = commandArgs.includes('--pairing-only')
if (!appImageArg) {
fail('Usage: run-headless-linux-pairing-docker.mjs --appimage /path/to/orca.AppImage')
}
const appImage = resolve(appImageArg)
if (!existsSync(appImage)) {
fail(`AppImage not found: ${appImage}`)
}
const suffix = `${process.pid}-${Date.now()}`
const artifactVolume = `orca-headless-pairing-artifact-${suffix}`
const network = `orca-headless-pairing-${suffix}`
const containers = new Set()
const images = [
{
name: 'ubuntu-24.04',
tag: 'orca-headless-pairing:ubuntu-24.04',
base: 'ubuntu@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90',
libasound: 'libasound2t64'
},
{
name: 'debian-13',
tag: 'orca-headless-pairing:debian-13',
base: 'debian@sha256:020c0d20b9880058cbe785a9db107156c3c75c2ac944a6aa7ab59f2add76a7bd',
libasound: 'libasound2t64'
},
{
name: 'ubuntu-22.04-baseline',
tag: 'orca-headless-pairing:ubuntu-22.04',
base: 'ubuntu@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982',
libasound: 'libasound2'
}
]
try {
docker(['volume', 'create', artifactVolume])
docker(['network', 'create', network])
for (const image of pairingOnly ? images.slice(0, 2) : images) {
buildImage(image)
}
extractAppImage(images[0].tag)
if (!pairingOnly) {
await validateStartupMatrix()
await validateUnavailableContracts()
}
await validateAuthenticatedPairing()
await validateUnreachableOffer()
console.log('Headless Linux pairing Docker validation passed.')
} finally {
for (const container of containers) {
docker(['rm', '-f', container], { allowFailure: true })
}
docker(['network', 'rm', network], { allowFailure: true })
docker(['volume', 'rm', artifactVolume], { allowFailure: true })
}
function valueAfter(flag) {
const index = commandArgs.indexOf(flag)
return index === -1 ? null : (commandArgs[index + 1] ?? null)
}
function buildImage(image) {
console.log(`Building ${image.name} fixture...`)
docker([
'build',
'--build-arg',
`BASE_IMAGE=${image.base}`,
'--build-arg',
`LIBASOUND_PACKAGE=${image.libasound}`,
'-f',
'config/docker/headless-pairing/Dockerfile',
'-t',
image.tag,
'.'
])
}
function extractAppImage(image) {
console.log('Extracting AppImage once without FUSE...')
// Why: AppImage extraction creates a root-only directory, while launch cases intentionally run as the service user.
docker([
'run',
'--rm',
'--entrypoint',
'bash',
'-v',
`${appImage}:/input/orca.AppImage:ro`,
'-v',
`${artifactVolume}:/artifacts`,
image,
'-lc',
'cp /input/orca.AppImage /artifacts/orca.AppImage && chmod +x /artifacts/orca.AppImage && cd /artifacts && ./orca.AppImage --appimage-extract >/dev/null && chmod -R a+rX /artifacts/squashfs-root'
])
}
async function validateStartupMatrix() {
const required = images.slice(0, 2)
for (const image of required) {
for (const launch of ['direct', 'xvfb', 'dbus-xvfb', 'journal']) {
for (const mode of ['human', 'json']) {
const result = await startAndWait({ image, launch, mode, address: 'pair-host.test' })
validateReady(result.stdout, mode, 'pair-host.test', {
allowStdoutNoise: launch !== 'direct'
})
stopContainer(result.name)
console.log(`PASS ${image.name} ${launch} ${mode}`)
}
}
}
const baseline = await startAndWait({
image: images[2],
launch: 'xvfb',
mode: 'json',
address: '127.0.0.1'
})
validateReady(baseline.stdout, 'json', '127.0.0.1', { allowStdoutNoise: true })
stopContainer(baseline.name)
console.log(`PASS ${images[2].name} xvfb json`)
const publicEntry = await startAndWait({
image: images[0],
launch: 'direct',
mode: 'json',
address: '127.0.0.1',
appPath: '/artifacts/orca.AppImage',
startupTimeoutMs: APPIMAGE_EXTRACTION_TIMEOUT_MS
})
validateReady(publicEntry.stdout, 'json', '127.0.0.1', { allowStdoutNoise: true })
stopContainer(publicEntry.name)
console.log('PASS AppImage --appimage-extract-and-run --no-sandbox serve')
}
async function validateUnavailableContracts() {
const invalid = await startAndWait({
image: images[0],
launch: 'direct',
mode: 'json',
address: '0.0.0.0'
})
const invalidPayload = readyJson(invalid.stdout)
assert(invalidPayload.pairing?.available === false, 'wildcard pairing must be unavailable')
assert(
invalidPayload.pairing.reason === 'invalid_advertised_endpoint',
'wildcard pairing must expose invalid_advertised_endpoint'
)
stopContainer(invalid.name)
const disabled = await startAndWait({
image: images[1],
launch: 'journal',
mode: 'json',
address: '127.0.0.1',
noPairing: true
})
const disabledPayload = readyJson(disabled.stdout)
assert(disabledPayload.pairing?.reason === 'disabled_by_operator', 'disabled reason is missing')
stopContainer(disabled.name)
console.log('PASS explicit unavailable pairing contracts')
}
async function validateAuthenticatedPairing() {
const server = await startAndWait({
image: images[0],
launch: 'direct',
mode: 'json',
address: 'ws://orca-pairing-server:6768/runtime?route=runtime',
port: '6768',
networkAlias: 'orca-pairing-server'
})
const payload = readyJson(server.stdout)
const client = runPairingClient(payload.pairing.url)
assert(client.status === 0, `pairing client failed:\n${client.stderr}\n${client.stdout}`)
const status = lastJsonObject(client.stdout)
const statusResult = status?.result ?? status
assert(
statusResult?.runtime?.state === 'ready',
`paired client did not report runtime.state=ready: ${JSON.stringify(status)}`
)
assert(
statusResult?.reachable === true || statusResult?.runtime?.reachable === true,
`paired client did not report reachable=true: ${JSON.stringify(status)}`
)
assert(
statusResult?.runtime?.runtimeId === payload.runtimeId ||
status?._meta?.runtimeId === payload.runtimeId,
'paired client runtime ID does not match ready contract'
)
stopContainer(server.name)
console.log('PASS authenticated E2EE pairing from an independent Debian container')
}
async function validateUnreachableOffer() {
const server = await startAndWait({
image: images[0],
launch: 'direct',
mode: 'json',
address: 'orca-pairing-server:6769',
port: '6768',
networkAlias: 'orca-pairing-server'
})
const payload = readyJson(server.stdout)
const client = runPairingClient(payload.pairing.url)
assert(client.status !== 0, 'a deliberately mismatched advertised port unexpectedly connected')
stopContainer(server.name)
console.log('PASS unreachable advertised endpoint fails the real client probe')
}
async function startAndWait({
image,
launch,
mode,
address,
port = '0',
appPath = '/artifacts/squashfs-root/AppRun',
networkAlias,
noPairing = false,
startupTimeoutMs = STARTUP_TIMEOUT_MS
}) {
const name = `orca-pairing-${suffix}-${containers.size}`
const args = [
'run',
'-d',
'--init',
'--shm-size',
'256m',
'--name',
name,
'--network',
network,
...(networkAlias ? ['--network-alias', networkAlias] : []),
'-e',
'ORCA_KEEP_RUNNING=1',
'-e',
'LIBGL_ALWAYS_SOFTWARE=1',
'-e',
`ORCA_READY_JSON=${mode === 'json' ? '1' : '0'}`,
'-e',
`ORCA_PAIRING_ADDRESS=${address}`,
'-e',
`ORCA_SERVE_PORT=${port}`,
'-e',
`ORCA_TEST_APPIMAGE=${appPath}`,
...(noPairing ? ['-e', 'ORCA_NO_PAIRING=1'] : []),
'-v',
`${artifactVolume}:/artifacts:ro`,
image.tag,
launch
]
docker(args)
containers.add(name)
const stdout = await waitForReady(name, startupTimeoutMs)
return { name, stdout }
}
async function waitForReady(name, startupTimeoutMs) {
const deadline = Date.now() + startupTimeoutMs
while (Date.now() < deadline) {
const logResult = docker(['logs', name], { allowFailure: true })
const stdout = `${logResult.stdout}${logResult.stderr}`
if (hasCompleteReadyContract(stdout)) {
return stdout
}
const running = docker(['inspect', '-f', '{{.State.Running}}', name], {
allowFailure: true
}).stdout.trim()
if (running === 'false') {
const containerLogs = docker(['logs', name], { allowFailure: true })
throw new Error(
`${name} exited before readiness:\n${containerLogs.stdout}${containerLogs.stderr}`
)
}
await new Promise((resolveWait) => setTimeout(resolveWait, 100))
}
const logResult = docker(['logs', name], { allowFailure: true })
throw new Error(
`${name} did not emit readiness within ${startupTimeoutMs}ms:\n${logResult.stdout}${logResult.stderr}`
)
}
function hasCompleteReadyContract(stdout) {
if (
stdout.includes('Orca server ready\n') &&
(stdout.includes('\nPairing URL: ') || stdout.includes('\nPairing guidance: '))
) {
return true
}
return readyJsonObjects(stdout).length > 0
}
function validateReady(logs, mode, expectedHost, options = {}) {
if (mode === 'human') {
assert(
(logs.match(/^Orca server ready$/gm) ?? []).length === 1,
'human ready marker is not exact-once'
)
assert(logs.includes('Bound endpoint: ws://0.0.0.0:'), 'human bound endpoint is missing')
assert(
logs.includes(`Advertised endpoint: ws://${expectedHost}:`),
'human advertised endpoint is missing'
)
assert(logs.includes('Pairing URL: orca://pair?code='), 'human pairing URL is missing')
return
}
if (!options.allowStdoutNoise) {
const stdoutLines = logs.split(/\r?\n/).filter(Boolean)
assert(
stdoutLines.length === 1,
`JSON stdout must contain exactly one line, found ${stdoutLines.length}:\n${logs}`
)
}
const payload = readyJson(logs)
assert(payload.schemaVersion === 1, 'ready JSON schemaVersion is missing')
assert(payload.endpoint === payload.boundEndpoint, 'legacy JSON endpoint alias is inconsistent')
assert(payload.boundEndpoint?.startsWith('ws://0.0.0.0:'), 'JSON bound endpoint is invalid')
assert(payload.advertisedEndpoint?.includes(expectedHost), 'JSON advertised endpoint is invalid')
assert(payload.pairing?.available === true, 'JSON pairing offer is unavailable')
assert(
new URL(payload.boundEndpoint).port === new URL(payload.advertisedEndpoint).port,
'an advertised host without a port must use the actual bound port'
)
}
function readyJson(logs) {
const matches = readyJsonObjects(logs)
assert(matches.length === 1, `expected one ready JSON object, found ${matches.length}`)
return matches[0]
}
function readyJsonObjects(logs) {
const marker = '{"type":"orca_server_ready"'
return logs
.split(/\r?\n/)
.map((line) => {
const markerIndex = line.indexOf(marker)
return markerIndex === -1 ? null : parseJson(line.slice(markerIndex))
})
.filter((value) => value?.type === 'orca_server_ready')
}
function runPairingClient(pairingUrl) {
return docker(
[
'run',
'--rm',
'--network',
network,
'--entrypoint',
'/artifacts/squashfs-root/AppRun',
'-e',
'APPDIR=/artifacts/squashfs-root',
'-v',
`${artifactVolume}:/artifacts:ro`,
images[1].tag,
'--no-sandbox',
'--pairing-code',
pairingUrl,
'status',
'--json'
],
{ allowFailure: true, timeout: 15_000 }
)
}
function lastJsonObject(output) {
return parseJson(output.trim()) ?? output.split(/\r?\n/).map(parseJson).findLast(Boolean) ?? null
}
function parseJson(value) {
try {
return JSON.parse(value)
} catch {
return null
}
}
function stopContainer(name) {
docker(['stop', '--time', '5', name], { allowFailure: true })
docker(['rm', name], { allowFailure: true })
containers.delete(name)
}
function docker(args, options = {}) {
try {
const stdout = execFileSync('docker', args, {
cwd: process.cwd(),
encoding: 'utf8',
maxBuffer: 50 * 1024 * 1024,
stdio: options.allowFailure ? 'pipe' : ['ignore', 'pipe', 'inherit'],
timeout: options.timeout
})
return { status: 0, stdout, stderr: '' }
} catch (error) {
const result = {
status: typeof error.status === 'number' ? error.status : 1,
stdout: String(error.stdout ?? ''),
stderr: String(error.stderr ?? error.message)
}
if (!options.allowFailure) {
throw new Error(`docker ${args[0]} failed:\n${result.stderr}\n${result.stdout}`)
}
return result
}
}
function assert(condition, message) {
if (!condition) {
throw new Error(message)
}
}
function fail(message) {
console.error(message)
process.exit(2)
}
+16 -1
View File
@@ -461,14 +461,29 @@ async function main() {
path.join(userDataDir, 'orca-data.json'),
`${JSON.stringify(makeCompletedOnboardingProfile(), null, 2)}\n`
)
const { ELECTRON_RUN_AS_NODE, ...cleanEnv } = process.env
const {
ELECTRON_RUN_AS_NODE,
CODEX_HOME: _codexHome,
ORCA_CODEX_HOME: _orcaCodexHome,
...cleanEnv
} = process.env
void ELECTRON_RUN_AS_NODE
void _codexHome
void _orcaCodexHome
// Why: real-home rollout work would both contaminate idle measurements and
// expose the developer Codex profile to this disposable Electron launch.
const isolatedHome = path.join(userDataDir, 'home')
mkdirSync(isolatedHome, { recursive: true })
const app = await electron.launch({
args: launchArgs(mainPath, options.headful),
env: {
...cleanEnv,
NODE_ENV: 'development',
ORCA_E2E_USER_DATA_DIR: userDataDir,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_E2E_HOME_DIR: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
}
})
@@ -0,0 +1,41 @@
import { spawnSync } from 'node:child_process'
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_WEB_CLIENT: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/multi-client-navigation-isolation.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
@@ -0,0 +1,63 @@
/**
* Entry point for the multi-workspace typing-latency bench
* (tests/e2e/terminal-multi-workspace-typing-latency.spec.ts).
*
* Usage:
* pnpm bench:multi-workspace-typing [-- --panes 8 --rate-kbps 512 \
* --keys 48 --cadence-ms 250 --cpu-workers 4 --label before-fix]
*
* Results land in tools/benchmarks/results/multi-workspace-typing-*.json.
* Run once per build/config with distinct --label values, then diff the
* totalMs/inputHalfMs/echoHalfMs percentiles.
*/
import { spawn } from 'node:child_process'
const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'
const knobByFlag = {
'--panes': 'ORCA_TYPING_BENCH_LOAD_PANES',
'--rate-kbps': 'ORCA_TYPING_BENCH_RATE_KBPS',
'--keys': 'ORCA_TYPING_BENCH_KEYS',
'--cadence-ms': 'ORCA_TYPING_BENCH_KEY_CADENCE_MS',
'--cpu-workers': 'ORCA_TYPING_BENCH_CPU_WORKERS',
'--label': 'ORCA_TYPING_BENCH_LABEL'
}
const env = { ...process.env, ORCA_TYPING_BENCH: '1' }
const passthroughArgs = []
const argv = process.argv.slice(2)
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--') {
continue
}
const knob = knobByFlag[argv[i]]
if (knob) {
env[knob] = argv[++i]
} else {
passthroughArgs.push(argv[i])
}
}
const child = spawn(
npxCommand,
[
'playwright',
'test',
'tests/e2e/terminal-multi-workspace-typing-latency.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...passthroughArgs
],
{ stdio: 'inherit', env }
)
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})
@@ -0,0 +1,41 @@
import { spawnSync } from 'node:child_process'
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/ssh-docker-watcher-isolation.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
const DEFAULT_REPORT_PATH = 'test-results/terminal-scale-perf-report.json'
const DEFAULT_HTML_REPORT_PATH = 'test-results/terminal-perf-impact-report.html'
export function parseReportGateArgs(argv, env = process.env) {
const forwardedArgs = [...argv]
@@ -114,7 +115,20 @@ export function runTerminalScalePerfReportGate({
spawnSyncImpl,
env
)
return exitCode(budgetResult)
const budgetExitCode = exitCode(budgetResult)
if (budgetExitCode !== 0) {
return budgetExitCode
}
const htmlReportPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_HTML_REPORT_PATH
const htmlResult = runNodeScript(
'config/scripts/generate-terminal-perf-html-report.mjs',
[reportPath, '--output', htmlReportPath],
'inherit',
spawnSyncImpl,
env
)
return exitCode(htmlResult)
}
if (process.argv[1] === import.meta.filename) {
@@ -77,7 +77,8 @@ describe('run-terminal-scale-perf-report-gate', () => {
expect(calls.map((call) => call.args[0])).toEqual([
'config/scripts/run-terminal-scale-perf-e2e.mjs',
'config/scripts/summarize-terminal-perf-report.mjs',
'config/scripts/check-terminal-perf-report-budgets.mjs'
'config/scripts/check-terminal-perf-report-budgets.mjs',
'config/scripts/generate-terminal-perf-html-report.mjs'
])
expect(calls[0].args).toEqual([
'config/scripts/run-terminal-scale-perf-e2e.mjs',
@@ -92,6 +93,12 @@ describe('run-terminal-scale-perf-report-gate', () => {
'config/scripts/check-terminal-perf-report-budgets.mjs',
reportPath
])
expect(calls[3].args).toEqual([
'config/scripts/generate-terminal-perf-html-report.mjs',
reportPath,
'--output',
'test-results/terminal-perf-impact-report.html'
])
})
it('uses the report path from env when no flag is provided', () => {
@@ -107,6 +114,28 @@ describe('run-terminal-scale-perf-report-gate', () => {
expect(calls[1].args).toEqual(['config/scripts/summarize-terminal-perf-report.mjs', reportPath])
})
it('uses the HTML report path from env when provided', () => {
const reportPath = tempReportPath()
const { calls, spawnSyncImpl } = makeSpawnSync()
const status = runTerminalScalePerfReportGate({
env: {
...process.env,
ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'tmp/terminal-report.html',
ORCA_E2E_TERMINAL_PERF_REPORT_PATH: reportPath
},
spawnSyncImpl
})
expect(status).toBe(0)
expect(calls[3].args).toEqual([
'config/scripts/generate-terminal-perf-html-report.mjs',
reportPath,
'--output',
'tmp/terminal-report.html'
])
})
it('preserves the report when Playwright clears the target report directory', () => {
const reportPath = tempReportPath()
const { spawnSyncImpl } = makeSpawnSync({
@@ -0,0 +1,180 @@
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { build } from 'esbuild'
const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js')
const SUPERVISOR_SOURCE = resolve('src/main/ipc/parcel-watcher-process-supervisor.ts')
const WAIT_TIMEOUT_MS = 15_000
const require = createRequire(import.meta.url)
function withTimeout(promise, label) {
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(
() => rejectPromise(new Error(`Timed out waiting for ${label}`)),
WAIT_TIMEOUT_MS
)
promise.then(
(value) => {
clearTimeout(timer)
resolvePromise(value)
},
(error) => {
clearTimeout(timer)
rejectPromise(error)
}
)
})
}
function nextMatchingEvent(register, predicate, label) {
return withTimeout(
new Promise((resolveEvent) => {
register((events) => {
if (events.some(predicate)) {
resolveEvent(events)
}
})
}),
label
)
}
async function loadSupervisor(bundleDir) {
const outfile = join(bundleDir, 'watcher-supervisor.cjs')
await build({
entryPoints: [SUPERVISOR_SOURCE],
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
external: ['@parcel/watcher', 'electron'],
logLevel: 'silent'
})
return require(outfile).WatcherProcessSupervisor
}
async function main() {
if (process.platform === 'win32') {
console.log('[runtime-file-watcher-fault] SKIP: SIGSEGV oracle is macOS/Linux only')
return
}
if (!existsSync(ENTRY_PATH)) {
throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`)
}
// Why: mkdtemp/realpath/bundle/construction can fail before the body runs.
// Keep cleanup in finally from the first successful mkdtemp onward, and clean
// the original temp path if realpath never succeeds.
let createdRootPath
let bundleDir
let rootPath
let supervisor
let subscription
let watcherCanaryDir
let eventListener = () => undefined
let rejectWatcherError
const watcherError = new Promise((_, reject) => {
rejectWatcherError = reject
})
// Attach early so a callback rejection before the race cannot become unhandled.
watcherError.catch(() => undefined)
try {
createdRootPath = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-fault-'))
bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-harness-'))
// Parcel reports canonical event paths on macOS, where tmpdir() may use the
// /var symlink spelling. Keep the oracle in the same path domain.
rootPath = await realpath(createdRootPath)
const WatcherProcessSupervisor = await loadSupervisor(bundleDir)
supervisor = new WatcherProcessSupervisor()
let resolveInterruption
const interrupted = withTimeout(
new Promise((resolveWait) => {
resolveInterruption = resolveWait
}),
'automatic watcher resubscription'
)
subscription = await supervisor.subscribe(
rootPath,
(error, events) => {
if (error) {
// Why: throws from the async watcher callback escape main()'s try/finally
// and skip teardown. Surface failures through a harness promise instead.
rejectWatcherError(error)
return
}
eventListener(events)
},
{ ignore: ['.git', 'node_modules'] },
{
delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 },
onInterruption: () => resolveInterruption()
}
)
watcherCanaryDir = supervisor.canaryDir
const beforeEvent = nextMatchingEvent(
(listener) => {
eventListener = listener
},
(event) => event.path === join(rootPath, 'before.txt'),
'pre-crash watch event'
)
await writeFile(join(rootPath, 'before.txt'), 'before')
await Promise.race([beforeEvent, watcherError])
const firstChildPid = supervisor.child?.pid
if (!firstChildPid) {
throw new Error('Watcher supervisor did not expose a live child')
}
process.kill(firstChildPid, 'SIGSEGV')
await Promise.race([interrupted, watcherError])
const replacementChildPid = supervisor.child?.pid
if (!replacementChildPid || replacementChildPid === firstChildPid) {
throw new Error('Watcher supervisor did not replace the faulted child')
}
const afterEvent = nextMatchingEvent(
(listener) => {
eventListener = listener
},
(event) => event.path === join(rootPath, 'after.txt'),
'post-crash watch event'
)
await writeFile(join(rootPath, 'after.txt'), 'after')
await Promise.race([afterEvent, watcherError])
console.log(
JSON.stringify({
hostPid: process.pid,
killedWatcherPid: firstChildPid,
replacementWatcherPid: replacementChildPid,
hostSurvived: true,
automaticResubscribe: true,
postCrashEventDelivered: true
})
)
} finally {
try {
await subscription?.unsubscribe()
} finally {
supervisor?.dispose()
await Promise.all([
createdRootPath ? rm(createdRootPath, { recursive: true, force: true }) : Promise.resolve(),
rootPath && rootPath !== createdRootPath
? rm(rootPath, { recursive: true, force: true })
: Promise.resolve(),
bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve()
])
}
}
if (watcherCanaryDir && existsSync(watcherCanaryDir)) {
throw new Error(`Watcher supervisor leaked its canary directory: ${watcherCanaryDir}`)
}
}
await main()
@@ -0,0 +1,257 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, realpath, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { build } from 'esbuild'
const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js')
const POOL_SOURCE = resolve('src/main/ipc/runtime-watcher-process-pool.ts')
const FAILURE_SOURCE = resolve('src/main/ipc/parcel-watcher-process-failure.ts')
const REGISTRY_SOURCE = resolve('src/main/ipc/parcel-watcher-child-registry.ts')
const MAX_CHILD_RSS_KIB = 128 * 1024
const MAX_CHILD_CPU_PERCENT = 50
const MAX_QUARANTINE_RSS_KIB = 512 * 1024
const MAX_QUARANTINE_CPU_PERCENT = 100
const PHYSICAL_CHILD_CAP = 8
const WAIT_TIMEOUT_MS = 15_000
const execFileAsync = promisify(execFile)
const require = createRequire(import.meta.url)
async function main() {
if (!existsSync(ENTRY_PATH)) {
throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`)
}
const createdRoots = []
const exactPids = new Set()
let bundleDir
let pool
let result
try {
bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-resource-'))
const { RuntimeWatcherProcessPool, WatcherProcessFailure, reserveWatcherChild } =
await loadProbe(bundleDir)
const roots = []
for (let index = 0; index < 5; index++) {
const createdRoot = await mkdtemp(join(tmpdir(), `orca-watcher-resource-${index}-`))
createdRoots.push(createdRoot)
roots.push(await realpath(createdRoot))
}
pool = new RuntimeWatcherProcessPool()
await Promise.all(roots.map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {})))
const healthyPids = activeChildPids(pool)
assertPidCount(healthyPids, 1, 'five healthy roots')
healthyPids.forEach((pid) => exactPids.add(pid))
const healthyResources = await sampleResources(healthyPids)
assertResourceBudget(healthyResources, 'healthy pool', {
maxTotalRssKiB: MAX_CHILD_RSS_KIB,
maxTotalCpuPercent: MAX_CHILD_CPU_PERCENT
})
assertRegistryCapacity(reserveWatcherChild, 7)
const sharedSlot = [...pool.activeSlots][0]
const failure = new WatcherProcessFailure(
'resource probe synthetic shared-shard fault',
'supervisor',
'supervisor_crash_fuse'
)
for (const record of sharedSlot.supervisor.records.values()) {
record.hooks.onTerminalError?.(failure)
}
await waitFor(() => !isPidAlive(healthyPids[0]), 'healthy child exit')
await Promise.all(
roots.slice(0, 4).map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {}))
)
const quarantinePids = activeChildPids(pool)
assertPidCount(quarantinePids, 4, 'four fault-quarantine roots')
quarantinePids.forEach((pid) => exactPids.add(pid))
const quarantineResources = await sampleResources(quarantinePids)
assertResourceBudget(quarantineResources, 'quarantine pool', {
maxTotalRssKiB: MAX_QUARANTINE_RSS_KIB,
maxTotalCpuPercent: MAX_QUARANTINE_CPU_PERCENT
})
// Four live quarantine children leave four reservations before the global
// eight-physical-child ceiling rejects the next launch.
assertRegistryCapacity(reserveWatcherChild, 4)
result = {
hostPid: process.pid,
healthy: summarizeResources(healthyResources),
quarantine: summarizeResources(quarantineResources),
physicalChildCap: PHYSICAL_CHILD_CAP,
budgets: {
maxChildRssKiB: MAX_CHILD_RSS_KIB,
maxChildCpuPercent: MAX_CHILD_CPU_PERCENT,
maxQuarantineRssKiB: MAX_QUARANTINE_RSS_KIB,
maxQuarantineCpuPercent: MAX_QUARANTINE_CPU_PERCENT,
maxPhysicalRssKiB: MAX_CHILD_RSS_KIB * PHYSICAL_CHILD_CAP,
maxPhysicalCpuPercent: MAX_CHILD_CPU_PERCENT * PHYSICAL_CHILD_CAP
},
exactPidCleanup: true
}
} finally {
pool?.dispose()
await Promise.all(
[...exactPids].map((pid) => waitFor(() => !isPidAlive(pid), `watcher PID ${pid} cleanup`))
)
await Promise.all([
...createdRoots.map((rootPath) => rm(rootPath, { recursive: true, force: true })),
bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve()
])
}
console.log(JSON.stringify(result))
}
async function loadProbe(bundleDir) {
const outfile = join(bundleDir, 'watcher-resource-probe.cjs')
await build({
stdin: {
contents: [
`export { RuntimeWatcherProcessPool } from ${JSON.stringify(POOL_SOURCE)}`,
`export { WatcherProcessFailure } from ${JSON.stringify(FAILURE_SOURCE)}`,
`export { reserveWatcherChild } from ${JSON.stringify(REGISTRY_SOURCE)}`
].join('\n'),
resolveDir: process.cwd(),
sourcefile: 'runtime-file-watcher-resource-probe-entry.ts',
loader: 'ts'
},
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
external: ['@parcel/watcher', 'electron'],
logLevel: 'silent'
})
return require(outfile)
}
function activeChildPids(pool) {
return [...pool.activeSlots]
.map((slot) => slot.supervisor.child?.pid)
.filter((pid) => Number.isInteger(pid))
}
function assertPidCount(pids, expected, label) {
if (new Set(pids).size !== expected || pids.some((pid) => !isPidAlive(pid))) {
throw new Error(
`${label} used ${new Set(pids).size} live watcher children; expected ${expected}`
)
}
}
function assertRegistryCapacity(reserveWatcherChild, availableReservations) {
const releases = []
try {
for (let index = 0; index < availableReservations; index++) {
const release = reserveWatcherChild()
if (!release) {
throw new Error(`Watcher child registry rejected reservation ${index + 1}`)
}
releases.push(release)
}
const unexpectedReservation = reserveWatcherChild()
if (unexpectedReservation) {
unexpectedReservation()
throw new Error('Watcher child registry exceeded its global physical-process cap')
}
} finally {
releases.forEach((release) => release())
}
}
async function sampleResources(pids) {
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
return Promise.all(pids.map((pid) => sampleProcess(pid)))
}
async function sampleProcess(pid) {
if (process.platform === 'win32') {
const command =
`$p=Get-Process -Id ${pid};` +
`[pscustomobject]@{rssKiB=[math]::Round($p.WorkingSet64/1KB);cpuSeconds=$p.CPU}|ConvertTo-Json -Compress`
const first = JSON.parse(
(await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim()
)
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
const second = JSON.parse(
(await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim()
)
return {
pid,
rssKiB: second.rssKiB,
cpuPercent: Math.max(0, (second.cpuSeconds - first.cpuSeconds) * 400)
}
}
const { stdout } = await execFileAsync('ps', ['-o', 'rss=', '-o', '%cpu=', '-p', String(pid)], {
env: { ...process.env, LC_ALL: 'C' }
})
const [rssKiB, cpuPercent] = stdout.trim().split(/\s+/).map(Number)
return { pid, rssKiB, cpuPercent }
}
function assertResourceBudget(resources, label, { maxTotalRssKiB, maxTotalCpuPercent }) {
for (const resource of resources) {
if (!Number.isFinite(resource.rssKiB) || resource.rssKiB <= 0) {
throw new Error(`Missing RSS evidence for watcher PID ${resource.pid}`)
}
if (resource.rssKiB > MAX_CHILD_RSS_KIB) {
throw new Error(
`Watcher PID ${resource.pid} RSS ${resource.rssKiB} KiB exceeded ${MAX_CHILD_RSS_KIB} KiB`
)
}
if (!Number.isFinite(resource.cpuPercent) || resource.cpuPercent < 0) {
throw new Error(`Missing CPU evidence for watcher PID ${resource.pid}`)
}
if (resource.cpuPercent > MAX_CHILD_CPU_PERCENT) {
throw new Error(
`Watcher PID ${resource.pid} CPU ${resource.cpuPercent}% exceeded ${MAX_CHILD_CPU_PERCENT}%`
)
}
}
const summary = summarizeResources(resources)
if (summary.totalRssKiB > maxTotalRssKiB) {
throw new Error(
`${label} RSS ${summary.totalRssKiB} KiB exceeded aggregate budget ${maxTotalRssKiB} KiB`
)
}
if (summary.sampledCpuPercent > maxTotalCpuPercent) {
throw new Error(
`${label} CPU ${summary.sampledCpuPercent}% exceeded aggregate budget ${maxTotalCpuPercent}%`
)
}
}
function summarizeResources(resources) {
return {
childCount: resources.length,
pids: resources.map(({ pid }) => pid),
totalRssKiB: resources.reduce((total, { rssKiB }) => total + rssKiB, 0),
sampledCpuPercent: resources.reduce((total, { cpuPercent }) => total + cpuPercent, 0)
}
}
function isPidAlive(pid) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function waitFor(predicate, label) {
const deadline = Date.now() + WAIT_TIMEOUT_MS
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for ${label}`)
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25))
}
}
await main()
@@ -37,6 +37,8 @@ const profileDir =
fixedProfileDir ?? mkdtempSync(path.join(tmpdir(), 'orca-headless-pairing-profile-'))
const ownsProfileDir = !fixedProfileDir
mkdirSync(profileDir, { recursive: true })
const isolatedHome = path.join(profileDir, 'home')
mkdirSync(isolatedHome, { recursive: true })
let cleanedUp = false
let child = null
@@ -44,13 +46,20 @@ let sawPairingUrl = false
let stopAttempts = 0
// Why: temp dev worktrees do not have a root-owned chrome-sandbox; this script
// is only for local headless testing, not packaged production.
const childEnv = {
...process.env,
const childEnv = { ...process.env }
delete childEnv.CODEX_HOME
delete childEnv.ORCA_CODEX_HOME
Object.assign(childEnv, {
// Why: a fresh temporary Orca profile must not make the default Codex lane
// read or mutate the developer profile during a pairing smoke test.
ORCA_DEV_USER_DATA_PATH: profileDir,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
...(process.platform === 'linux'
? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' }
: {})
}
})
console.error(`[headless-pairing] userData=${profileDir}`)
console.error(`[headless-pairing] starting: orca-dev serve --json${formatForwardedArgs(serveArgs)}`)
@@ -251,7 +260,7 @@ function printReadyLine(line) {
if (!payload || payload.type !== 'orca_server_ready') {
return false
}
console.log(`Orca server ready: ${payload.endpoint ?? 'websocket unavailable'}`)
console.log(`Orca server ready: ${payload.boundEndpoint ?? 'websocket unavailable'}`)
if (payload.pairing?.endpoint) {
console.log(`Pairing endpoint: ${payload.pairing.endpoint}`)
}
@@ -1,93 +0,0 @@
#!/usr/bin/env node
// Benchmark: main-process retained-Set growth of the serve-sim state watcher's
// external-helper dedupe across worktree switches.
//
// emitIfExternal() records one entry per (worktreeId, deviceUdid, pid, wsUrl,
// streamUrl) tuple in `seenExternalKeys` so a helper is only auto-tabbed once.
// Before the fix, forgetWorktree() pruned ptyToWorktree and ptyBuffers but never
// touched seenExternalKeys, so its keys accumulated for the renderer/main
// process lifetime — only cleared at app shutdown (stop()). This script
// simulates N bind→detect→forget cycles and reports retained Set size with the
// prune disabled vs enabled.
import { performance } from 'node:perf_hooks'
const CYCLES = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_CYCLES ?? '5000', 10)
const HELPERS_PER_WORKTREE = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_HELPERS ?? '2', 10)
for (const [name, value] of [
['ORCA_SEEN_KEYS_BENCH_CYCLES', CYCLES],
['ORCA_SEEN_KEYS_BENCH_HELPERS', HELPERS_PER_WORKTREE]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
// Mirror of the watcher's dedupe bookkeeping (serve-sim-state-watcher.ts): a
// Set of `${worktreeId}::${instanceKey}` keys, plus the prefix-prune the fix
// adds to forgetWorktree().
function makeWatcher({ prune }) {
const seenExternalKeys = new Set()
return {
set: seenExternalKeys,
emitIfExternal(worktreeId, instanceKey) {
const dedupeKey = `${worktreeId}::${instanceKey}`
if (seenExternalKeys.has(dedupeKey)) {
return false
}
seenExternalKeys.add(dedupeKey)
return true
},
forgetWorktree(worktreeId) {
if (!prune) {
return
}
const prefix = `${worktreeId}::`
for (const key of seenExternalKeys) {
if (key.startsWith(prefix)) {
seenExternalKeys.delete(key)
}
}
}
}
}
function run({ prune }) {
const watcher = makeWatcher({ prune })
const start = performance.now()
for (let cycle = 0; cycle < CYCLES; cycle++) {
const worktreeId = `worktree-${cycle}`
for (let h = 0; h < HELPERS_PER_WORKTREE; h++) {
// A realistic instance key: device udid :: pid :: wsUrl :: streamUrl.
const instanceKey = `udid-${cycle}-${h}::pid-${1000 + h}::ws://127.0.0.1:${3100 + h}/ws::http://127.0.0.1:${3100 + h}/stream.mjpeg`
watcher.emitIfExternal(worktreeId, instanceKey)
}
watcher.forgetWorktree(worktreeId)
}
return { retained: watcher.set.size, elapsedMs: performance.now() - start }
}
console.log(
`serve-sim seenExternalKeys leak benchmark: ${CYCLES} worktree open/forget cycles, ` +
`${HELPERS_PER_WORKTREE} helper(s)/worktree\n`
)
const before = run({ prune: false })
const after = run({ prune: true })
// Each key is ~120-160 bytes (worktreeId + udid + pid + two URLs).
const APPROX_BYTES_PER_KEY = 140
const leakedKb = (before.retained * APPROX_BYTES_PER_KEY) / 1024
console.log(
`before (no prune) retained=${before.retained} keys ` +
`(~${leakedKb.toFixed(0)} KiB) time=${before.elapsedMs.toFixed(1)}ms`
)
console.log(
`after (prune) retained=${after.retained} keys ` +
`(~0 KiB) time=${after.elapsedMs.toFixed(1)}ms`
)
console.log(
`\nBounded the dedupe Set to live worktrees: ${before.retained} → ${after.retained} ` +
`retained keys after ${CYCLES} worktree switches.`
)
+1 -1
View File
@@ -25,7 +25,7 @@ function getPackagedCliPath(appDir) {
return join(appDir, 'Contents', 'Resources', 'bin', 'orca')
}
if (process.platform === 'win32') {
return join(appDir, 'resources', 'bin', 'orca.cmd')
return join(appDir, 'resources', 'bin', 'orca.exe')
}
return join(appDir, 'resources', 'bin', 'orca-ide')
}
@@ -1,4 +1,5 @@
import ts from 'typescript'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const STYLED_SCROLLBAR_CLASSES = new Set(
'scrollbar-sleek scrollbar-editor worktree-sidebar-scrollbar'.split(' ')
@@ -25,6 +25,7 @@ function printMarkdownTable(rows) {
['Frames', 'frames'],
['Median', 'median'],
['Worst', 'worst'],
['Revisit', 'revisit'],
['Scroll', 'scroll'],
['Restore', 'restore'],
['Max Drift', 'maxTimerDrift'],
@@ -0,0 +1,199 @@
import { readFileSync } from 'node:fs'
const BUDGETS = {
medianMs: 75,
worstMs: 300,
revisitMs: 300,
maxTimerDriftMs: 150,
scrollMs: 150,
restoreMs: 1000,
rendererQueuedChars: 2 * 1024 * 1024,
rendererPeakQueuedChars: 2 * 1024 * 1024,
rendererDroppedBacklogs: 0
}
const SCENARIO_LABELS = [
['opencode-scale-same-workspace', 'Same workspace panes'],
['opencode-scale-cross-workspace', 'Cross-workspace hidden panes'],
['opencode-scale-pressure', 'ACK-backpressured PTYs'],
['opencode-scale-hidden-pressure', 'Hidden real PTYs'],
['opencode-cross-workspace-typing', 'Cross-workspace typing'],
['opencode-main-pressure', 'Main renderer pressure'],
['opencode-hidden-pressure', 'Hidden pressure'],
['opencode-revisit-pressure', 'Revisit under pressure'],
// Why: the prefix also matches opencode-parked-memory-disabled, so both
// parked-memory scenarios group under one label.
['opencode-parked-memory', 'Parked hidden terminal memory']
]
export function readJsonReport(path) {
const raw = readFileSync(path, 'utf8')
const start = raw.indexOf('{')
const end = raw.lastIndexOf('}')
if (start === -1 || end <= start) {
throw new Error(`${path}: no JSON object found`)
}
return JSON.parse(raw.slice(start, end + 1))
}
function parseAnnotationDescription(description) {
const values = {}
for (const part of description.split(/\s+/)) {
const index = part.indexOf('=')
if (index === -1) {
continue
}
values[part.slice(0, index)] = part.slice(index + 1)
}
return values
}
export function collectTerminalPerfRows(report, source) {
const rows = []
const visitSuite = (suite) => {
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
for (const annotation of test.annotations ?? []) {
if (!annotation.type.startsWith('opencode-')) {
continue
}
rows.push(
normalizeRow({
source,
scenario: annotation.type,
...parseAnnotationDescription(annotation.description ?? '')
})
)
}
}
}
for (const child of suite.suites ?? []) {
visitSuite(child)
}
}
for (const suite of report.suites ?? []) {
visitSuite(suite)
}
return rows
}
function parseMs(value) {
const match = String(value ?? '').match(/^(-?\d+(?:\.\d+)?)ms$/)
return match ? Number(match[1]) : null
}
function parseCount(value) {
if (value == null || value === '') {
return null
}
const count = Number(value)
return Number.isFinite(count) ? count : null
}
function normalizeRow(row) {
return {
...row,
group: scenarioGroup(row.scenario),
panes: parseCount(row.panes),
frames: parseCount(row.frames),
medianMs: parseMs(row.median),
worstMs: parseMs(row.worst),
revisitMs: parseMs(row.revisit),
maxTimerDriftMs: parseMs(row.maxTimerDrift),
scrollMs: parseMs(row.scroll),
restoreMs: parseMs(row.restore),
rendererQueuedChars: parseCount(row.rendererQueuedChars),
rendererPeakQueuedChars: parseCount(row.rendererPeakQueuedChars),
rendererDroppedBacklogs: parseCount(row.rendererDroppedBacklogs),
mainPeakPendingChars: parseCount(row.mainPeakPendingChars),
mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars),
heldAckChars: parseCount(row.heldAckChars),
hiddenSkippedChars: parseCount(row.hiddenSkippedChars),
// Why: parked-memory annotations report a fractional MB heap figure plus
// live renderer view counts; Number() keeps the MB float intact.
heapUsedMB: parseCount(row.heapUsedMB),
liveTerminals: parseCount(row.liveTerminals),
livePaneManagers: parseCount(row.livePaneManagers)
}
}
export function scenarioGroup(scenario) {
for (const [prefix, label] of SCENARIO_LABELS) {
if (scenario.startsWith(prefix)) {
return label
}
}
return 'Other terminal scenarios'
}
function scenarioSortKey(scenario) {
const prefixIndex = SCENARIO_LABELS.findIndex(([prefix]) => scenario.startsWith(prefix))
const paneMatch = scenario.match(/-(\d+)$/)
return [
prefixIndex === -1 ? SCENARIO_LABELS.length : prefixIndex,
paneMatch ? Number(paneMatch[1]) : 0,
scenario
]
}
export function compareScenarios(a, b) {
const ka = scenarioSortKey(a)
const kb = scenarioSortKey(b)
if (ka[0] !== kb[0]) {
return ka[0] - kb[0]
}
if (ka[1] !== kb[1]) {
return ka[1] - kb[1]
}
return ka[2] < kb[2] ? -1 : ka[2] > kb[2] ? 1 : 0
}
export function scenarioTitle(scenario, row) {
const group = scenarioGroup(scenario)
if (row?.panes != null) {
return `${group} — ${row.panes} panes`
}
return group
}
export function budgetFailures(row) {
const failures = []
for (const [key, budget] of Object.entries(BUDGETS)) {
const value = row[key]
if (value == null) {
continue
}
if (value > budget) {
failures.push(`${key} ${value} > ${budget}`)
}
}
return failures
}
export function formatMs(value) {
if (value == null) {
return '—'
}
return `${value.toFixed(1)}ms`
}
export function formatLargeValue(value) {
if (value == null) {
return '—'
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(2)}M`
}
if (value >= 1024) {
return `${Math.round(value / 1024)}k`
}
return String(value)
}
export function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
@@ -0,0 +1,394 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import {
accessSync,
chmodSync,
constants as fsConstants,
mkdtempSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const MANAGED_SCRIPTS = [
['antigravity-hook.sh', 'antigravity'],
['claude-hook.sh', 'claude'],
['codex-hook.sh', 'codex'],
['command-code-hook.sh', 'command-code'],
['copilot-hook.sh', 'copilot'],
['cursor-hook.sh', 'cursor'],
['devin-hook.sh', 'devin'],
['droid-hook.sh', 'droid'],
['gemini-hook.sh', 'gemini'],
['grok-hook.sh', 'grok'],
['kimi-hook.sh', 'kimi'],
['openclaude-hook.sh', 'claude']
]
const REQUIRED_JSON_STDOUT = new Set(['antigravity-hook.sh', 'copilot-hook.sh', 'gemini-hook.sh'])
function parseArgs(argv) {
const result = { home: process.env.HOME ?? '', minMtime: 0 }
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index]
if (value === '--home') {
result.home = argv[index + 1] ?? ''
index += 1
} else if (value === '--min-mtime') {
result.minMtime = Number(argv[index + 1] ?? 0)
index += 1
} else {
throw new Error(['Unknown argument: ', value].join(''))
}
}
if (!result.home) {
throw new Error('Pass --home or set HOME to the isolated Electron home directory')
}
return result
}
function withoutOrcaEnvironment(extra = {}) {
return {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_'))),
...extra
}
}
function runShell(command, payload, env) {
return new Promise((resolve, reject) => {
const child = spawn('/bin/sh', ['-c', command], {
env,
stdio: ['pipe', 'pipe', 'pipe']
})
const stdout = []
const stderr = []
const stdinErrors = []
const timeout = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(['Timed out running: ', command.slice(0, 120)].join('')))
}, 10_000)
child.stdout.on('data', (chunk) => stdout.push(chunk))
child.stderr.on('data', (chunk) => stderr.push(chunk))
child.stdin.on('error', (error) => stdinErrors.push(error))
child.on('error', (error) => {
clearTimeout(timeout)
reject(error)
})
child.on('close', (exitCode) => {
clearTimeout(timeout)
resolve({
exitCode,
stdinErrors,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8')
})
})
child.stdin.end(payload)
})
}
function assertSuccessfulWrite(result, label) {
if (result.exitCode !== 0) {
throw new Error(
[label, ' exited ', String(result.exitCode), ': ', result.stderr.slice(0, 500)].join('')
)
}
if (result.stdinErrors.length > 0) {
throw new Error(
[
label,
' produced stdin errors: ',
result.stdinErrors.map((error) => error.message).join(', ')
].join('')
)
}
}
function assertProtocolStdout(fileName, stdout) {
if (!REQUIRED_JSON_STDOUT.has(fileName)) {
return
}
const firstLine = stdout.trim().split(/\r?\n/, 1)[0]
try {
JSON.parse(firstLine)
} catch {
throw new Error([fileName, ' did not emit protocol JSON: ', stdout.slice(0, 200)].join(''))
}
}
function readGeneratedScripts(home, minMtime) {
const hooksDir = join(home, '.orca', 'agent-hooks')
return MANAGED_SCRIPTS.map(([fileName, source]) => {
const path = join(hooksDir, fileName)
const stats = statSync(path)
if (!stats.isFile()) {
throw new Error([fileName, ' is not a regular file'].join(''))
}
try {
accessSync(path, fsConstants.R_OK | fsConstants.X_OK)
} catch {
throw new Error([fileName, ' is not readable and executable'].join(''))
}
if (minMtime > 0 && stats.mtimeMs < minMtime) {
throw new Error([fileName, ' predates the Electron launch'].join(''))
}
const body = readFileSync(path, 'utf8')
const captureIndex = body.indexOf('payload=$(cat)')
const firstExitIndex = body.indexOf('exit 0')
if (captureIndex < 0 || firstExitIndex <= captureIndex) {
throw new Error([fileName, ' can exit before capturing stdin'].join(''))
}
return { body, fileName, path, source }
})
}
function findStrings(value, matches = []) {
if (typeof value === 'string') {
matches.push(value)
return matches
}
if (Array.isArray(value)) {
for (const child of value) {
findStrings(child, matches)
}
return matches
}
if (value && typeof value === 'object') {
for (const child of Object.values(value)) {
findStrings(child, matches)
}
}
return matches
}
function nextRequest(server) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
server.removeListener('request', onRequest)
reject(new Error('Generated hook did not reach the loopback server'))
}, 8_000)
const onRequest = (request, response) => {
const chunks = []
request.on('data', (chunk) => chunks.push(chunk))
request.on('end', () => {
clearTimeout(timeout)
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end('{}')
resolve({
body: Buffer.concat(chunks).toString('utf8'),
headers: request.headers,
url: request.url
})
})
}
server.once('request', onRequest)
})
}
async function verifyNoOpWrites(scripts, home, payload) {
const commandCodeBin = mkdtempSync(join(tmpdir(), 'orca-hook-command-code-bin-'))
symlinkSync('/bin/cat', join(commandCodeBin, 'cat'))
try {
for (const script of scripts) {
const path =
script.fileName === 'command-code-hook.sh'
? commandCodeBin
: (process.env.PATH ?? '/usr/bin:/bin')
const result = await runShell(
['/bin/sh ', JSON.stringify(script.path)].join(''),
payload,
withoutOrcaEnvironment({
HOME: home,
PATH: path,
ORCA_AGENT_HOOK_ENDPOINT: ''
})
)
assertSuccessfulWrite(result, [script.fileName, ' no-op'].join(''))
assertProtocolStdout(script.fileName, result.stdout)
}
} finally {
rmSync(commandCodeBin, { recursive: true, force: true })
}
}
async function verifyClaudeDevinSkip(scripts, home, payload) {
const claude = scripts.find((script) => script.fileName === 'claude-hook.sh')
let unexpectedRequests = 0
const server = createServer((_request, response) => {
unexpectedRequests += 1
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end('{}')
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
try {
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('Claude skip verifier did not receive a TCP port')
}
const result = await runShell(
['/bin/sh ', JSON.stringify(claude.path)].join(''),
payload,
withoutOrcaEnvironment({
DEVIN_PROJECT_DIR: join(home, 'devin-project'),
HOME: home,
ORCA_AGENT_HOOK_ENDPOINT: '',
ORCA_AGENT_HOOK_PORT: String(address.port),
ORCA_AGENT_HOOK_TOKEN: 'electron-verification-token',
ORCA_PANE_KEY: 'electron-verification-pane'
})
)
assertSuccessfulWrite(result, 'Claude Devin-import skip')
if (unexpectedRequests !== 0) {
throw new Error('Claude forwarded a hook imported by Devin')
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
}
async function verifyForwarding(scripts, home, payload) {
const server = createServer()
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
try {
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('Loopback verifier did not receive a TCP port')
}
for (const script of scripts) {
const requestPromise = nextRequest(server)
const result = await runShell(
['/bin/sh ', JSON.stringify(script.path)].join(''),
payload,
withoutOrcaEnvironment({
HOME: home,
ORCA_AGENT_HOOK_ENDPOINT: '',
ORCA_AGENT_HOOK_PORT: String(address.port),
ORCA_AGENT_HOOK_TOKEN: 'electron-verification-token',
ORCA_PANE_KEY: 'electron-verification-pane',
ORCA_TAB_ID: 'electron-verification-tab',
ORCA_WORKTREE_ID: 'electron-verification-worktree',
ORCA_AGENT_HOOK_ENV: 'test',
ORCA_AGENT_HOOK_VERSION: '1',
ORCA_ANTIGRAVITY_EVENT: 'PostInvocation',
ORCA_COPILOT_HOOK_EVENT: 'PostToolUse'
})
)
assertSuccessfulWrite(result, [script.fileName, ' forwarding'].join(''))
assertProtocolStdout(script.fileName, result.stdout)
const request = await requestPromise
const form = new URLSearchParams(request.body)
if (request.url !== ['/hook/', script.source].join('')) {
throw new Error(
[script.fileName, ' posted to ', String(request.url), ' instead of ', script.source].join(
''
)
)
}
if (request.headers['x-orca-agent-hook-token'] !== 'electron-verification-token') {
throw new Error([script.fileName, ' lost the hook token header'].join(''))
}
if (form.get('payload') !== payload) {
throw new Error([script.fileName, ' changed the forwarded payload'].join(''))
}
if (form.get('paneKey') !== 'electron-verification-pane') {
throw new Error([script.fileName, ' changed the forwarded pane key'].join(''))
}
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
}
// Why: rewrite from the path embedded in the installed command, not a
// reconstructed join(home, ...). That way missing/failing-script cases cannot
// silently re-run the real script if the install layout changes.
function rewriteLauncherScriptPath(command, nextPath) {
const match = /if \[ -f '([^']+)'/.exec(command)
if (!match) {
throw new Error('Installed launcher command did not reference a quoted script path')
}
return command.replaceAll(match[1], nextPath)
}
async function verifyInstalledLauncher(home, payload) {
const settingsPath = join(home, '.claude', 'settings.json')
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
const command = findStrings(settings).find(
(value) => value.includes('claude-hook.sh') && value.includes('if [ -f ')
)
if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) {
throw new Error('Electron did not install the guarded Claude launcher')
}
const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-'))
try {
const missingPath = join(scratch, 'missing-hook.sh')
const missingResult = await runShell(
rewriteLauncherScriptPath(command, missingPath),
payload,
withoutOrcaEnvironment({ HOME: home })
)
assertSuccessfulWrite(missingResult, 'installed missing-script launcher')
const failingPath = join(scratch, 'failing-hook.sh')
writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8')
chmodSync(failingPath, 0o755)
const failingResult = await runShell(
rewriteLauncherScriptPath(command, failingPath),
payload,
withoutOrcaEnvironment({ HOME: home })
)
if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) {
throw new Error('Installed launcher did not preserve a running script failure')
}
} finally {
rmSync(scratch, { recursive: true, force: true })
}
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const payload = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'shell',
tool_output: 'x'.repeat(1_200_000)
})
const scripts = readGeneratedScripts(args.home, args.minMtime)
await verifyNoOpWrites(scripts, args.home, payload)
await verifyClaudeDevinSkip(scripts, args.home, payload)
await verifyForwarding(scripts, args.home, payload)
await verifyInstalledLauncher(args.home, payload)
process.stdout.write(
[
JSON.stringify(
{
forwardingPayloadBytes: Buffer.byteLength(payload),
claudeDevinSkipCases: 1,
launcherCases: 2,
noOpScripts: scripts.length,
forwardedScripts: scripts.length,
status: 'passed'
},
null,
2
),
'\n'
].join('')
)
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack : error)
process.exitCode = 1
})
@@ -2,7 +2,7 @@
import { _electron as electron } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
@@ -148,6 +148,10 @@ async function runValidation(mode) {
const repoPath = createGitRepo()
const userDataPath = mkdtempSync(path.join(tmpdir(), 'orca-wayland-gpu-userdata-'))
// Why: this harness cannot use the E2E flag because that disables Linux GPU,
// but its Codex and Node home still must stay inside the disposable profile.
const isolatedHome = path.join(userDataPath, 'home')
mkdirSync(isolatedHome, { recursive: true })
const runId = `${Date.now()}`
let app
let page
@@ -192,9 +196,17 @@ async function runValidation(mode) {
})
try {
const { ELECTRON_RUN_AS_NODE: _unused, DISPLAY: _display, ...env } = process.env
const {
ELECTRON_RUN_AS_NODE: _unused,
DISPLAY: _display,
CODEX_HOME: _codexHome,
ORCA_CODEX_HOME: _orcaCodexHome,
...env
} = process.env
void _unused
void _display
void _codexHome
void _orcaCodexHome
logPhase('launch.start')
app = await runWithTimeout(
'Electron launch',
@@ -205,6 +217,9 @@ async function runValidation(mode) {
...env,
NODE_ENV: 'development',
ORCA_DEV_USER_DATA_PATH: userDataPath,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
ELECTRON_ENABLE_LOGGING: '1',
ELECTRON_ENABLE_STACK_DUMPING: '1',
ELECTRON_OZONE_PLATFORM_HINT: 'wayland',
@@ -3,7 +3,8 @@ import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
import ts from 'typescript'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
@@ -0,0 +1,58 @@
const { existsSync } = require('node:fs')
const { spawnSync } = require('node:child_process')
const { join } = require('node:path')
// Why: `asarUnpack` in config/electron-builder.config.cjs lists
// out/main/daemon-entry.js on every platform, and the packaged daemon fork
// (src/main/daemon/daemon-init.ts) resolves exactly this unpacked path. A
// missing entry means the package layout regressed, so the check throws
// instead of skipping — a silent skip false-passed exactly the layout bug
// this gate exists to catch.
function assertPackagedDaemonEntryExists(resourcesDir) {
const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js')
if (!existsSync(entryPath)) {
throw new Error(
`[verify-packaged-daemon-entry] missing unpacked daemon entry at ${entryPath} — ` +
`asarUnpack expects out/main/daemon-entry.js on every platform, so the packaged ` +
`daemon cannot be forked from this layout`
)
}
return entryPath
}
// Why: v1.4.129-rc.1 shipped a terminal daemon that could not load (an electron
// `require` leaked into its bundle) while every build check passed. This boots
// the PACKAGED daemon-entry under plain Node against the asar-unpacked layout,
// so a bundling / asar-unpack regression fails packaging instead of reaching
// users. Module-load proof only: with no args the entry must reach argv parsing
// and print its "Usage: daemon-entry" error — a MODULE_NOT_FOUND or a missing
// usage line means the packaged graph does not load and the build must fail.
//
// resourcesDir is the packaged Resources dir (Contents/Resources on macOS,
// <appOutDir>/resources elsewhere). execPath defaults to the packaging Node.
function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) {
const execPath = options.execPath || process.execPath
const entryPath = assertPackagedDaemonEntryExists(resourcesDir)
const result = spawnSync(execPath, [entryPath], { encoding: 'utf8', timeout: 10_000 })
if (result.error) {
throw new Error(
`[verify-packaged-daemon-entry] could not launch daemon-entry.js: ${result.error.message}`
)
}
const stderr = result.stderr || ''
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[verify-packaged-daemon-entry] packaged daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (!stderr.includes('Usage: daemon-entry')) {
throw new Error(
`[verify-packaged-daemon-entry] packaged daemon-entry.js did not reach argv parsing ` +
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
)
}
console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node')
}
module.exports = { assertPackagedDaemonEntryExists, verifyPackagedDaemonEntryBoots }
@@ -0,0 +1,57 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
assertPackagedDaemonEntryExists,
verifyPackagedDaemonEntryBoots
} = require('./verify-packaged-daemon-entry.cjs')
describe('verify-packaged-daemon-entry', () => {
let resourcesDir
beforeEach(() => {
resourcesDir = mkdtempSync(join(tmpdir(), 'orca-daemon-entry-verify-'))
})
afterEach(() => {
rmSync(resourcesDir, { recursive: true, force: true })
})
function writePackagedEntry(source) {
const entryDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
mkdirSync(entryDir, { recursive: true })
writeFileSync(join(entryDir, 'daemon-entry.js'), source)
}
// Why: a silent skip on a missing entry false-passed exactly the packaged
// layout regression this gate exists to catch (rc.1 daemon-load incident).
it('throws when the unpacked daemon entry is missing', () => {
expect(() => assertPackagedDaemonEntryExists(resourcesDir)).toThrow(
/missing unpacked daemon entry/
)
expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(
/missing unpacked daemon entry/
)
})
it('passes when the packaged entry loads and reaches argv parsing', () => {
writePackagedEntry('console.error("Usage: daemon-entry <socket>"); process.exit(1)\n')
expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).not.toThrow()
})
it('fails when the packaged entry cannot resolve its module graph', () => {
writePackagedEntry('require("orca-module-that-does-not-exist")\n')
expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(
/failed to load under plain Node/
)
})
it('fails when the packaged entry never reaches argv parsing', () => {
writePackagedEntry('process.exit(0)\n')
expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(/did not reach argv parsing/)
})
})
@@ -0,0 +1,249 @@
import { execFileSync } from 'node:child_process'
import {
chmod,
cp,
lstat,
mkdir,
mkdtemp,
readFile,
realpath,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs'
function option(name) {
return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3)
}
const cliVersion = option('cli')
const autocrlf = option('autocrlf')
const shape = option('shape')
// Why: PR branch names are untrusted workflow input. Keep them out of the
// generated shell command and pass them to Node through the environment.
const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE
const ref = option('ref') ?? process.env.SKILL_UPDATE_REF
if (
!cliVersion ||
(autocrlf !== 'true' && autocrlf !== 'false') ||
(shape !== 'symlink' && shape !== 'copy') ||
!source ||
!ref ||
!/^[^/\s]+\/[^/\s]+$/.test(source)
) {
throw new Error(
'Usage: verify-skill-update-roundtrip.mjs --cli=<version> --autocrlf=true|false --shape=symlink|copy --source=<owner/repo> --ref=<git-ref>'
)
}
const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-'))
const home = path.join(sandbox, 'home')
const stateHome = path.join(home, '.state')
const fakeBin = path.join(sandbox, 'bin')
const targetName = 'orca-cli'
const controlName = 'orchestration'
const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8'))
const registry = JSON.parse(await readFile('resources/skills/snapshot-registry.json', 'utf8'))
const releaseMapping = JSON.parse(await readFile('resources/skills/release-mapping.json', 'utf8'))
function currentSkill(name) {
const skill = manifest.skills.find((entry) => entry.name === name)
if (!skill) {
throw new Error(`Current manifest is missing ${name}`)
}
return skill
}
function historicalRelease(name) {
const current = currentSkill(name)
for (const release of releaseMapping.releases.toReversed()) {
const revision = release.skills[name]
if (typeof revision !== 'number' || revision >= current.releaseRevision) {
continue
}
const snapshot = registry.skills[name]?.find((entry) => entry.releaseRevision === revision)
if (snapshot) {
return { tag: `v${release.appVersion}`, snapshot }
}
}
throw new Error(`No historical released snapshot is available for ${name}`)
}
async function materializePackage(name, tag, destination) {
const prefix = `skills/${name}/`
const entries = execFileSync('git', ['ls-tree', '-r', '-z', tag, '--', `skills/${name}`])
.toString('utf8')
.split('\0')
.filter(Boolean)
if (entries.length === 0) {
throw new Error(`${tag} does not contain ${name}`)
}
for (const entry of entries) {
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(entry)
if (!match || match[2] !== 'blob') {
throw new Error(`Unsupported historical tree entry: ${entry}`)
}
const relativePath = match[4].slice(prefix.length)
const destinationPath = path.join(destination, ...relativePath.split('/'))
await mkdir(path.dirname(destinationPath), { recursive: true })
await writeFile(destinationPath, execFileSync('git', ['cat-file', 'blob', match[3]]))
if (process.platform !== 'win32' && match[1] === '100755') {
await chmod(destinationPath, 0o755)
}
}
}
async function seedPlacement(name, tag) {
const canonical = path.join(home, '.agents', 'skills', name)
await materializePackage(name, tag, canonical)
const providerRoot = path.join(home, '.claude', 'skills')
const provider = path.join(providerRoot, name)
await mkdir(providerRoot, { recursive: true })
await (shape === 'copy'
? cp(canonical, provider, { recursive: true })
: symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir'))
}
async function installFakeAgentCommands() {
await mkdir(fakeBin, { recursive: true })
for (const name of ['codex', 'claude']) {
const executable = path.join(fakeBin, process.platform === 'win32' ? `${name}.cmd` : name)
await writeFile(
executable,
process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n'
)
if (process.platform !== 'win32') {
await chmod(executable, 0o755)
}
}
}
async function packageDigestAt(pathValue) {
return packageDigest(await collectPackageFiles(pathValue))
}
async function assertCurrentCanonical(name) {
const expected = currentSkill(name).packageDigest
const canonical = path.join(home, '.agents', 'skills', name)
if ((await packageDigestAt(canonical)) !== expected) {
throw new Error(`${name} canonical placement did not update to the PR content`)
}
}
function execSkills(args) {
const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx'
const cliArgs = ['--yes', `skills@${cliVersion}`, ...args]
execFileSync(
executable,
process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs,
{
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home,
CODEX_HOME: path.join(home, '.codex'),
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
XDG_STATE_HOME: stateHome,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.autocrlf',
GIT_CONFIG_VALUE_0: autocrlf,
PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`,
CI: '1'
},
stdio: 'inherit'
}
)
}
try {
const targetHistorical = historicalRelease(targetName)
const controlHistorical = historicalRelease(controlName)
await installFakeAgentCommands()
await mkdir(path.join(home, '.codex'), { recursive: true })
await mkdir(path.join(home, '.claude'), { recursive: true })
await seedPlacement(targetName, targetHistorical.tag)
await seedPlacement(controlName, controlHistorical.tag)
const targetProvider = path.join(home, '.claude', 'skills', targetName)
const controlCanonical = path.join(home, '.agents', 'skills', controlName)
const controlProvider = path.join(home, '.claude', 'skills', controlName)
const targetProviderBefore = await packageDigestAt(await realpath(targetProvider))
const controlBefore = await packageDigestAt(controlCanonical)
const controlProviderBefore = await packageDigestAt(await realpath(controlProvider))
const timestamp = new Date().toISOString()
const lock = {
version: 3,
skills: {
[targetName]: {
source,
sourceType: 'github',
sourceUrl: `https://github.com/${source}.git`,
ref,
skillPath: `skills/${targetName}/SKILL.md`,
skillFolderHash: targetHistorical.snapshot.gitTreeSha,
installedAt: timestamp,
updatedAt: timestamp
},
[controlName]: {
source,
sourceType: 'github',
sourceUrl: `https://github.com/${source}.git`,
ref,
skillPath: `skills/${controlName}/SKILL.md`,
skillFolderHash: controlHistorical.snapshot.gitTreeSha,
installedAt: timestamp,
updatedAt: timestamp
}
}
}
const lockPath = path.join(stateHome, 'skills', '.skill-lock.json')
await mkdir(path.dirname(lockPath), { recursive: true })
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
// Why: this is the exact user-visible rail. A bare update would include
// unrelated vendors, while this command must leave the control skill alone.
execSkills(['update', targetName, '--global'])
await assertCurrentCanonical(targetName)
const targetProviderAfter = await packageDigestAt(await realpath(targetProvider))
const targetProviderStat = await lstat(targetProvider)
if (shape === 'symlink' && !targetProviderStat.isSymbolicLink()) {
throw new Error(`${targetName} provider alias was replaced with an independent copy`)
}
if (shape === 'symlink' && targetProviderAfter !== currentSkill(targetName).packageDigest) {
throw new Error(`${targetName} provider alias did not converge with the canonical update`)
}
if (
shape === 'copy' &&
targetProviderAfter !== targetProviderBefore &&
targetProviderAfter !== currentSkill(targetName).packageDigest
) {
throw new Error('Independent provider copy changed to an unexpected package identity')
}
if (shape === 'copy') {
// Why: hosted 1.5.17 replaces copies with aliases while equivalent local runs
// retain the copy. Both prove this input topology must remain ineligible.
const outcome = targetProviderStat.isSymbolicLink()
? 'converged to an alias'
: targetProviderAfter === targetProviderBefore
? 'remained a historical copy'
: 'converged as a copy'
console.log(`[skill-update-roundtrip] independent copy ${outcome}`)
}
if ((await packageDigestAt(controlCanonical)) !== controlBefore) {
throw new Error('Targeted update changed the non-targeted control skill')
}
if ((await packageDigestAt(await realpath(controlProvider))) !== controlProviderBefore) {
throw new Error('Targeted update changed the non-targeted control provider placement')
}
const controlProviderStat = await lstat(controlProvider)
if (shape === 'symlink' && !controlProviderStat.isSymbolicLink()) {
throw new Error('Targeted update changed the non-targeted control topology')
}
} finally {
await rm(sandbox, { recursive: true, force: true })
}
@@ -0,0 +1,227 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import { parseArgs } from '../../tools/win-crash-survival-e2e/cli-args.mjs'
import { buildCrashAssertions } from '../../tools/win-crash-survival-e2e/crash-assertions.mjs'
import { scanPwshFailFast } from '../../tools/win-crash-survival-e2e/crash-step.mjs'
import { selectScopedDaemon } from '../../tools/win-crash-survival-e2e/daemon-identity.mjs'
import {
reattachSentinelMatches,
selectCreatedTabId
} from '../../tools/win-crash-survival-e2e/reattach-proof.mjs'
import { quotePowerShellLiteral } from '../../tools/win-update-e2e/powershell-runner.mjs'
import { closeApp, resolveElectronMainPid } from '../../tools/win-update-e2e/app-driver.mjs'
import { isPidAlive } from '../../tools/win-update-e2e/daemon-processes.mjs'
describe('win-crash-survival-e2e proof contracts', () => {
it('keeps the packaged proof wired as a targeted pull-request gate', () => {
const workflow = readFileSync('.github/workflows/win-crash-survival-e2e.yml', 'utf8')
expect(workflow).toMatch(/^ pull_request:/m)
expect(workflow).not.toMatch(/^ push:/m)
expect(workflow).toContain("- 'src/main/daemon/**'")
expect(workflow).toContain("- 'src/main/index.ts'")
expect(workflow).toContain("- 'src/main/ipc/pty*.ts'")
expect(workflow).toContain("- 'src/main/startup/first-window-startup-services.ts'")
expect(workflow).toContain("- 'src/main/window/attach-main-window-services.ts'")
expect(workflow).toContain("- 'src/preload/**'")
expect(workflow).toContain("- 'src/renderer/src/components/terminal-pane/**'")
expect(workflow).toContain("- 'src/renderer/src/store/slices/terminals.ts'")
expect(workflow).toContain("- '!src/**/*.test.*'")
expect(workflow).toContain("- '!src/**/*.bench.*'")
expect(workflow).toContain('--expect "$env:EXPECT"')
expect(workflow).toContain('exit $LASTEXITCODE')
expect(workflow).toContain("'!config/**/*.test.*'")
expect(workflow).toContain("'!src/**/*.test.*'")
expect(workflow).toContain("'!src/**/*.bench.*'")
expect(workflow).toContain("'!config/reliability-gates.jsonc'")
expect(workflow).toContain("'resources/**'")
expect(workflow).toContain('cache: pnpm')
expect(workflow.indexOf('- name: Setup Node.js')).toBeGreaterThan(
workflow.indexOf('- name: Setup pnpm')
)
expect(workflow).toContain("if: steps.cache-installer.outputs.cache-hit != 'true'")
expect(workflow).toContain('crash-survival-electron-builder-')
})
it('requires the full survival oracle, including daemon identity and reattach', () => {
const base = {
profile: 'survival',
mainDied: true,
daemonAliveAfterCrash: true,
shellAliveAfterCrash: true,
failFastEvents: [],
preDaemonPid: 101,
postDaemonPid: 101,
postDaemonAlive: true,
reattachProven: true
}
expect(buildCrashAssertions(base).every((entry) => entry.pass)).toBe(true)
expect(
buildCrashAssertions({ ...base, postDaemonPid: 202 }).find((entry) =>
entry.name.startsWith('relaunch adopts')
)?.pass
).toBe(false)
expect(
buildCrashAssertions({ ...base, reattachProven: false }).find((entry) =>
entry.name.startsWith('reattached UI')
)?.pass
).toBe(false)
})
it('scans for FailFast only after the post-crash input probe', () => {
const harness = readFileSync('tools/win-crash-survival-e2e/run.mjs', 'utf8')
const scanIndex = harness.indexOf('const { events: failFastEvents }')
const probeIndex = harness.indexOf('reattachProven = await proveReattachedShell')
expect(scanIndex).not.toBe(-1)
expect(probeIndex).not.toBe(-1)
expect(scanIndex).toBeGreaterThan(probeIndex)
})
it('fails closed when the Windows event log query fails', () => {
let command = ''
expect(() =>
scanPwshFailFast(1234, (received) => {
command = received
return { code: 1, stdout: '', stderr: 'access denied', error: null }
})
).toThrow('pwsh-failfast scan failed (exit 1): access denied')
expect(command).toContain('-ErrorAction Stop')
expect(command).toContain('NoMatchingEventsFound*')
})
it('accepts an empty event result only from the serialized evidence envelope', () => {
expect(
scanPwshFailFast(1234, () => ({
code: 0,
stdout: '{"events":[]}',
stderr: '',
error: null
}))
).toEqual({ events: [] })
expect(() =>
scanPwshFailFast(1234, () => ({ code: 0, stdout: '', stderr: '', error: null }))
).toThrow('pwsh-failfast scan returned no JSON output')
expect(() =>
scanPwshFailFast(1234, () => ({ code: 0, stdout: '{}', stderr: '', error: null }))
).toThrow('without an events envelope')
})
it('fails closed when PID liveness evidence is unavailable', () => {
expect(isPidAlive(42, () => ({ code: 0, stdout: 'alive\n', stderr: '', error: null }))).toBe(
true
)
expect(isPidAlive(42, () => ({ code: 0, stdout: 'dead\n', stderr: '', error: null }))).toBe(
false
)
expect(() =>
isPidAlive(42, () => ({ code: 1, stdout: '', stderr: 'access denied', error: null }))
).toThrow('PID liveness probe failed (exit 1): access denied')
expect(() => isPidAlive(42, () => ({ code: 0, stdout: '', stderr: '', error: null }))).toThrow(
'PID liveness probe returned an invalid state'
)
})
it('uses the scoped live process as daemon authority', () => {
expect(
selectScopedDaemon(
[{ pid: 42, appVersion: '1.2.3' }],
[{ pid: 42, commandLine: 'daemon-entry.js --socket scoped' }]
)
).toEqual({ pid: 42, appVersion: '1.2.3' })
expect(() =>
selectScopedDaemon(
[{ pid: 41, appVersion: 'stale' }],
[{ pid: 42, commandLine: 'daemon-entry.js --socket scoped' }]
)
).toThrow('daemon PID file does not match scoped live daemon 42')
expect(() => selectScopedDaemon([], [])).toThrow('expected exactly one')
expect(() => selectScopedDaemon([], [{ pid: 1 }, { pid: 2 }])).toThrow('expected exactly one')
})
it('rejects CLI typos and duplicate value flags before launching', () => {
const baseArgs = ['--expect', 'survival', '--exe-path', process.execPath]
expect(parseArgs(baseArgs).errors).toEqual([])
expect(parseArgs([...baseArgs, '--exe-pathh', process.execPath]).errors).toContain(
'Unknown argument: --exe-pathh'
)
expect(parseArgs([...baseArgs, '--expect', 'orphaned']).errors).toContain(
'Duplicate argument: --expect'
)
})
it('quotes apostrophes in generated PowerShell path literals', () => {
expect(quotePowerShellLiteral("C:\\Users\\O'Brien\\shell.pid")).toBe(
"'C:\\Users\\O''Brien\\shell.pid'"
)
})
it('targets exactly the terminal tab created before the crash', () => {
expect(selectCreatedTabId(['agent-tab'], ['agent-tab', 'terminal-tab'])).toBe('terminal-tab')
expect(() => selectCreatedTabId(['agent-tab'], ['agent-tab'])).toThrow(
'expected exactly one created terminal tab, found 0'
)
expect(() => selectCreatedTabId([], ['first', 'second'])).toThrow(
'expected exactly one created terminal tab, found 2'
)
})
it('requires both the per-shell canary and exact survivor pid', () => {
expect(reattachSentinelMatches('1660|canary\r\n', 'canary', 1660)).toBe(true)
expect(reattachSentinelMatches('1770|canary', 'canary', 1660)).toBe(false)
expect(reattachSentinelMatches('1660|other', 'canary', 1660)).toBe(false)
expect(reattachSentinelMatches('1660|canary|extra', 'canary', 1660)).toBe(false)
})
it('requires the real packaged main for the crash proof but permits fallback cleanup', async () => {
const harness = readFileSync('tools/win-crash-survival-e2e/run.mjs', 'utf8')
expect(harness).toContain(
'resolveElectronMainPid(session.app, { allowLauncherFallback: false })'
)
expect(
await resolveElectronMainPid({
evaluate: async () => 222,
process: () => ({ pid: 111 })
})
).toBe(222)
const unavailableApp = {
evaluate: async () => {
throw new Error('main unavailable')
},
process: () => ({ pid: 111 })
}
expect(
await resolveElectronMainPid(unavailableApp, { allowLauncherFallback: false })
).toBeNull()
expect(await resolveElectronMainPid(unavailableApp)).toBe(111)
})
it('bounds main PID resolution when the Electron connection is wedged', async () => {
vi.useFakeTimers()
try {
const result = resolveElectronMainPid(
{
evaluate: () => new Promise(() => {}),
process: () => ({ pid: 333 })
},
{ timeoutMs: 20 }
)
await vi.advanceTimersByTimeAsync(20)
await expect(result).resolves.toBe(333)
} finally {
vi.useRealTimers()
}
})
it('releases the close deadline after a successful app close', async () => {
vi.useFakeTimers()
try {
await closeApp({
evaluate: async () => 444,
process: () => ({ pid: 333 }),
close: async () => {}
})
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
})
@@ -1,6 +1,6 @@
import { chromium } from '@playwright/test'
import { spawn } from 'node:child_process'
import { mkdtempSync } from 'node:fs'
import { mkdirSync, mkdtempSync } from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
@@ -50,11 +50,20 @@ export function createGpuUserDataDirectory(gpuMode) {
export function launchDevApp({ cdpPort, userDataDir }) {
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
const isolatedHome = path.join(userDataDir, 'home')
mkdirSync(isolatedHome, { recursive: true })
Object.assign(env, {
ELECTRON_ENABLE_LOGGING: '1',
ELECTRON_ENABLE_STACK_DUMPING: '1',
NODE_ENV: 'development',
// Why: this disposable repro profile must not add real-home Codex work to
// app-hang measurements or expose the developer's Codex state.
ORCA_DEV_USER_DATA_PATH: userDataDir,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
ORCA_SKIP_DEV_WEB_PREPARE: '1',
ORCA_STARTUP_DIAGNOSTICS: '1',
REMOTE_DEBUGGING_PORT: String(cdpPort),
@@ -0,0 +1,370 @@
/**
* Reproduces issue #8048 against the built daemon on Windows.
*
* A witness PowerShell stays alive while victim sessions receive the same
* graceful-then-immediate kill pair emitted when Orca closes a workspace.
* The daemon PID and witness session must survive every iteration.
*/
import { fork } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { connect } from 'node:net'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const projectDir = resolve(import.meta.dirname, '../..')
const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js')
const iterations = Number(process.env.ORCA_WINDOWS_DAEMON_CLOSE_ITERATIONS ?? 25)
const requestTimeoutMs = 15_000
function log(message) {
process.stdout.write(`[windows-daemon-workspace-close] ${message}\n`)
}
function readProtocolVersion() {
const source = readFileSync(
join(projectDir, 'src/main/daemon/daemon-protocol-version.ts'),
'utf8'
)
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
if (!match) {
throw new Error('Could not read the daemon protocol version')
}
return Number(match[1])
}
function createStreamSocket(socketPath, token, protocolVersion, clientId, onFailure) {
const socket = connect(socketPath)
let buffer = ''
return new Promise((resolveStream, rejectStream) => {
let handshakeComplete = false
const rejectHandshake = (error) => {
if (handshakeComplete) {
onFailure(error)
return
}
handshakeComplete = true
clearTimeout(timer)
socket.removeListener('data', onData)
socket.removeListener('error', onError)
socket.removeListener('close', onClose)
socket.destroy()
rejectStream(error)
}
const onError = (error) => rejectHandshake(error)
const onClose = () => rejectHandshake(new Error('Daemon stream socket closed'))
const onData = (chunk) => {
buffer += chunk.toString('utf8')
const newline = buffer.indexOf('\n')
if (newline === -1) {
return
}
const message = JSON.parse(buffer.slice(0, newline))
if (message.type !== 'hello') {
return
}
if (!message.ok) {
rejectHandshake(new Error(message.error ?? 'Daemon rejected stream hello'))
return
}
handshakeComplete = true
clearTimeout(timer)
socket.removeListener('data', onData)
// Why: drain terminal events even though this lifecycle repro only asserts through RPC.
socket.on('data', () => {})
resolveStream(socket)
}
const timer = setTimeout(
() => rejectHandshake(new Error('Daemon stream hello timed out')),
requestTimeoutMs
)
socket.on('error', onError)
socket.on('close', onClose)
socket.on('data', onData)
socket.once('connect', () => {
socket.write(
`${JSON.stringify({
type: 'hello',
version: protocolVersion,
token,
clientId,
role: 'stream'
})}\n`
)
})
})
}
function createRpcClient(socketPath, tokenPath) {
const socket = connect(socketPath)
const clientId = randomUUID()
const protocolVersion = readProtocolVersion()
const token = readFileSync(tokenPath, 'utf8').trim()
const pending = new Map()
let buffer = ''
let requestId = 0
let streamSocket
let connectionError
let helloResolve
let helloReject
let helloTimer
const hello = new Promise((resolveHello, rejectHello) => {
helloResolve = () => {
clearTimeout(helloTimer)
resolveHello()
}
helloReject = (error) => {
clearTimeout(helloTimer)
rejectHello(error)
}
})
helloTimer = setTimeout(() => {
helloReject(new Error('Daemon control hello timed out'))
socket.destroy()
}, requestTimeoutMs)
const rejectPending = (error) => {
connectionError ??= error
helloReject(error)
for (const { reject, timer } of pending.values()) {
clearTimeout(timer)
reject(error)
}
pending.clear()
}
socket.on('error', rejectPending)
socket.on('close', () => rejectPending(new Error('Daemon control socket closed')))
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8')
let newline = buffer.indexOf('\n')
while (newline !== -1) {
const line = buffer.slice(0, newline)
buffer = buffer.slice(newline + 1)
const message = JSON.parse(line)
if (message.type === 'hello') {
if (message.ok) {
helloResolve()
} else {
helloReject(new Error(message.error ?? 'Daemon rejected hello'))
}
} else if (message.id) {
const request = pending.get(message.id)
if (request) {
pending.delete(message.id)
clearTimeout(request.timer)
if (message.ok) {
request.resolve(message.payload)
} else {
request.reject(new Error(message.error ?? 'Daemon request failed'))
}
}
}
newline = buffer.indexOf('\n')
}
})
const socketConnected = new Promise((resolveConnected, rejectConnected) => {
socket.once('connect', resolveConnected)
socket.once('error', rejectConnected)
})
const connected = Promise.all([
socketConnected.then(() => {
socket.write(
`${JSON.stringify({
type: 'hello',
version: protocolVersion,
token,
clientId,
role: 'control'
})}\n`
)
}),
hello
]).then(async () => {
// Why: v24 only admits terminals for the same complete control+stream pair as production.
streamSocket = await createStreamSocket(
socketPath,
token,
protocolVersion,
clientId,
rejectPending
)
})
return {
async request(type, payload) {
await connected
if (connectionError) {
throw connectionError
}
const id = `repro-${++requestId}`
return new Promise((resolveRequest, rejectRequest) => {
const timer = setTimeout(() => {
pending.delete(id)
rejectRequest(new Error(`Daemon request ${type} timed out`))
}, requestTimeoutMs)
pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer })
socket.write(`${JSON.stringify({ id, type, ...(payload ? { payload } : {}) })}\n`)
})
},
close() {
streamSocket?.destroy()
socket.destroy()
}
}
}
function waitForReady(child, stderr) {
return new Promise((resolveReady, rejectReady) => {
const timer = setTimeout(
() => rejectReady(new Error(`Daemon readiness timed out.\n${stderr()}`)),
requestTimeoutMs
)
child.on('message', (message) => {
if (message?.type === 'ready') {
clearTimeout(timer)
resolveReady()
}
})
child.once('exit', (code, signal) => {
clearTimeout(timer)
rejectReady(
new Error(`Daemon exited before readiness (code=${code}, signal=${signal}).\n${stderr()}`)
)
})
})
}
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return
}
const exited = new Promise((resolveExit) => child.once('exit', resolveExit))
child.kill('SIGTERM')
await Promise.race([
exited,
new Promise((resolveTimeout) =>
setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL')
}
resolveTimeout()
}, 5_000)
)
])
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0)
return true
} catch (error) {
if (error?.code === 'ESRCH') {
return false
}
throw error
}
}
async function waitForVictimExit(rpc, sessionId, pid) {
const deadline = Date.now() + requestTimeoutMs
while (Date.now() < deadline) {
const { sessions } = await rpc.request('listSessions')
const sessionAlive = sessions.some((session) => session.sessionId === sessionId)
if (!sessionAlive && !isProcessAlive(pid)) {
return sessions
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25))
}
throw new Error(`Victim ${sessionId} or OS pid ${pid} was not reaped`)
}
async function main() {
if (process.platform !== 'win32') {
log('SKIP: Windows ConPTY is required')
return
}
if (!existsSync(entryPath)) {
throw new Error(`Missing ${entryPath}; run pnpm build:electron-vite first`)
}
const scratch = mkdtempSync(join(tmpdir(), 'orca-windows-daemon-close-'))
const socketPath = `\\\\.\\pipe\\orca-daemon-close-${process.pid}-${randomUUID()}`
const tokenPath = join(scratch, 'daemon.token')
const daemonLogPath = join(scratch, 'daemon.log')
const child = fork(
entryPath,
['--socket', socketPath, '--token', tokenPath, '--log-file', daemonLogPath],
{
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
windowsHide: true,
env: { ...process.env, ORCA_USER_DATA_PATH: scratch }
}
)
const daemonPid = child.pid
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
let rpc
try {
await waitForReady(child, () => stderr)
rpc = createRpcClient(socketPath, tokenPath)
const witnessId = `repro-witness@@${randomUUID().slice(0, 8)}`
await rpc.request('createOrAttach', {
sessionId: witnessId,
cols: 80,
rows: 24,
cwd: projectDir,
shellOverride: 'powershell.exe'
})
for (let index = 0; index < iterations; index += 1) {
const victimId = `repro-victim-${index}@@${randomUUID().slice(0, 8)}`
const victim = await rpc.request('createOrAttach', {
sessionId: victimId,
cols: 80,
rows: 24,
cwd: projectDir,
shellOverride: 'powershell.exe'
})
if (!Number.isInteger(victim.pid) || victim.pid <= 0) {
throw new Error(`Victim ${victimId} did not return a valid OS pid`)
}
// Why: sending both RPCs before awaiting either preserves the renderer
// unmount/worktree-sweep overlap that produced issue #8048.
const graceful = rpc.request('kill', { sessionId: victimId, immediate: false })
const forced = rpc.request('kill', { sessionId: victimId, immediate: true })
await Promise.all([graceful, forced])
const sessions = await waitForVictimExit(rpc, victimId, victim.pid)
if (child.pid !== daemonPid || child.exitCode !== null) {
throw new Error(`Daemon PID ${daemonPid} exited while closing victim ${index}`)
}
if (!sessions.some((session) => session.sessionId === witnessId && session.isAlive)) {
throw new Error(`Witness PTY disappeared while closing victim ${index}`)
}
}
await rpc.request('kill', { sessionId: witnessId, immediate: true })
log(
`PASS: ${iterations} victim sessions/PIDs were reaped while daemon ${daemonPid} and the witness PTY survived`
)
} catch (error) {
const daemonLog = existsSync(daemonLogPath) ? readFileSync(daemonLogPath, 'utf8') : ''
throw new Error(`${error.message}\nstderr:\n${stderr}\ndaemon.log:\n${daemonLog}`)
} finally {
rpc?.close()
await stopChild(child)
rmSync(scratch, { recursive: true, force: true })
}
}
main().catch((error) => {
process.stderr.write(`[windows-daemon-workspace-close] FAIL: ${error.message}\n`)
process.exitCode = 1
})
@@ -0,0 +1,435 @@
import { spawn, spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { createConnection } from 'node:net'
import { join, resolve } from 'node:path'
const projectDir = resolve(import.meta.dirname, '..', '..')
const relayBuildDir = join(projectDir, 'out', 'relay', 'win32-x64')
const SENTINEL = Buffer.from('ORCA-RELAY v0.1.0 READY\n')
const HEADER_LENGTH = 13
const REGRESSION_TIMEOUT_MS = 15_000
const NODE_PTY_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
if (process.platform !== 'win32') {
console.log('SKIP: Windows ConPTY regression only runs on Windows.')
process.exit(0)
}
const options = parseArgs(process.argv.slice(2))
const nodePath = resolve(options.node ?? process.execPath)
const nodePtyDir = resolve(options.nodePty ?? join(projectDir, 'node_modules', 'node-pty'))
const expectFailure = options.expect === 'attach-console-failure'
for (const required of [
nodePath,
nodePtyDir,
join(relayBuildDir, 'relay.js'),
join(relayBuildDir, '.version')
]) {
if (!existsSync(required)) {
throw new Error(`Required regression input is missing: ${required}`)
}
}
const runDir = mkdtempSync(join(projectDir, '.issue-9586-relay-repro-'))
const relayPath = join(runDir, 'relay.js')
const stdoutLog = join(runDir, 'relay.log')
const stderrLog = join(runDir, 'relay.err.log')
const socketPath = `\\\\.\\pipe\\orca-issue-9586-${process.pid}-${Date.now()}`
let relayPid
try {
prepareRelayTree(runDir, nodePtyDir)
if (!options.skipRelayPatch) {
applyPackagedNodePtyPatch(nodePath, runDir)
}
relayPid = launchRelayWithoutConsole({
nodePath,
relayPath,
runDir,
socketPath,
stdoutLog,
stderrLog
})
await waitForPipe(socketPath, 5_000)
const observation = await exerciseRelayClient({
nodePath,
relayPath,
runDir,
socketPath,
shell: options.shell
})
await waitForExit(relayPid, 8_000)
const relayStdout = readIfPresent(stdoutLog)
const relayStderr = readIfPresent(stderrLog)
const attachConsoleFailed = relayStderr.includes('Error: AttachConsole failed')
const installedNodePtyDir = join(runDir, 'node_modules', 'node-pty')
const agentPath = join(installedNodePtyDir, 'lib', 'conpty_console_list_agent.js')
const nativeBindingPath = join(
installedNodePtyDir,
'prebuilds',
`${process.platform}-${process.arch}`,
'conpty.node'
)
const summary = {
node: observation.nodeVersion,
nodePty: JSON.parse(readFileSync(join(nodePtyDir, 'package.json'), 'utf8')).version,
shell: options.shell,
relayPid,
handshake: observation.handshake,
ptySpawn: observation.ptySpawn,
ptyShutdown: observation.ptyShutdown,
relayAliveAfterPtyShutdown: observation.relayAliveAfterPtyShutdown,
bridgeExit: observation.bridgeExit,
relayPatchApplied: !options.skipRelayPatch,
agentSha256: fileSha256(agentPath),
nativeBindingPresent: existsSync(nativeBindingPath),
attachConsoleFailed,
relayExitedAfterClientDisconnect: !isProcessAlive(relayPid)
}
console.log(JSON.stringify(summary, null, 2))
if (expectFailure !== attachConsoleFailed) {
throw new Error(
expectFailure
? `Expected the real console-list agent to fail AttachConsole. stderr:\n${relayStderr}`
: `The real console-list agent failed AttachConsole. stderr:\n${relayStderr}`
)
}
if (
!observation.handshake ||
!observation.ptySpawn ||
!observation.ptyShutdown ||
!observation.relayAliveAfterPtyShutdown
) {
throw new Error(
`Relay lifecycle did not complete. stdout:\n${relayStdout}\nstderr:\n${relayStderr}`
)
}
} finally {
if (relayPid && isProcessAlive(relayPid)) {
stopExactProcess(relayPid, relayPath)
}
rmSync(runDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
}
function parseArgs(args) {
const parsed = { expect: 'clean', shell: 'cmd.exe', skipRelayPatch: false }
for (let index = 0; index < args.length; index++) {
const flag = args[index]
if (flag === '--skip-relay-patch') {
parsed.skipRelayPatch = true
continue
}
const value = args[index + 1]
if (!value || !['--node', '--node-pty', '--expect', '--shell'].includes(flag)) {
throw new Error(
'Usage: node windows-ssh-attach-console-repro.mjs [--node PATH] [--node-pty DIR] [--shell PATH] [--skip-relay-patch] [--expect clean|attach-console-failure]'
)
}
if (flag === '--node') {
parsed.node = value
}
if (flag === '--node-pty') {
parsed.nodePty = value
}
if (flag === '--expect') {
parsed.expect = value
}
if (flag === '--shell') {
parsed.shell = value
}
index++
}
if (!['clean', 'attach-console-failure'].includes(parsed.expect)) {
throw new Error(`Unsupported expectation: ${parsed.expect}`)
}
return parsed
}
function prepareRelayTree(runDir, nodePtyDir) {
for (const filename of [
'relay.js',
'relay-watcher.js',
'managed-hook-runtime.js',
NODE_PTY_PATCH_FILENAME,
'.version'
]) {
copyFileSync(join(relayBuildDir, filename), join(runDir, filename))
}
cpSync(nodePtyDir, join(runDir, 'node_modules', 'node-pty'), { recursive: true })
}
function applyPackagedNodePtyPatch(nodePath, runDir) {
const result = spawnSync(nodePath, [join(runDir, NODE_PTY_PATCH_FILENAME)], {
cwd: runDir,
encoding: 'utf8',
windowsHide: true
})
if (result.status !== 0) {
throw new Error(`Packaged node-pty patch failed: ${result.stderr || result.stdout}`)
}
}
function launchRelayWithoutConsole({
nodePath,
relayPath,
runDir,
socketPath,
stdoutLog,
stderrLog
}) {
const relayArgs = [
quoteWindowsArg(nodePath),
quoteWindowsArg(relayPath),
'--detached',
'--grace-time',
'5',
'--sock-path',
quoteWindowsArg(socketPath),
'--endpoint-dir',
quoteWindowsArg(join(runDir, 'endpoint')),
'--log-file',
quoteWindowsArg(stdoutLog),
`1>${quoteWindowsArg(stdoutLog)}`,
`2>${quoteWindowsArg(stderrLog)}`
].join(' ')
const commandLine = `cmd.exe /d /s /c "${relayArgs}"`
const script = [
`$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(commandLine)}; CurrentDirectory = ${powerShellLiteral(runDir)} }`,
`if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed with $($result.ReturnValue)" }`,
'$result.ProcessId'
].join('; ')
const launched = runPowerShell(script)
const pid = Number(launched.stdout.trim())
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error(`Could not parse detached relay pid from: ${launched.stdout}`)
}
return pid
}
function exerciseRelayClient({ nodePath, relayPath, runDir, socketPath, shell }) {
return new Promise((resolvePromise, rejectPromise) => {
const bridge = spawn(nodePath, [relayPath, '--connect', '--sock-path', socketPath], {
cwd: runDir,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe']
})
const observation = {
nodeVersion: readNodeVersion(nodePath),
handshake: false,
ptySpawn: false,
ptyShutdown: false,
relayAliveAfterPtyShutdown: false,
bridgeExit: null
}
let stderr = ''
let buffer = Buffer.alloc(0)
let sentinelRead = false
let outgoingSequence = 1
let settled = false
const timeout = setTimeout(
() => finish(new Error(`Timed out waiting for relay client lifecycle. stderr:\n${stderr}`)),
REGRESSION_TIMEOUT_MS
)
bridge.stderr.on('data', (data) => {
stderr += data.toString()
})
bridge.on('error', finish)
bridge.on('close', (code, signal) => {
observation.bridgeExit = { code, signal }
if (!settled && observation.relayAliveAfterPtyShutdown) {
finish()
} else if (!settled) {
finish(new Error(`Relay bridge closed before lifecycle completed. stderr:\n${stderr}`))
}
})
bridge.stdout.on('data', (data) => {
try {
buffer = Buffer.concat([buffer, data])
if (!sentinelRead) {
const sentinelIndex = buffer.indexOf(SENTINEL)
if (sentinelIndex === -1) {
return
}
buffer = buffer.subarray(sentinelIndex + SENTINEL.length)
sentinelRead = true
observation.handshake = true
request(1, 'pty.spawn', {
shellOverride: shell,
cwd: projectDir,
cols: 80,
rows: 24,
env: {}
})
}
for (const message of drainMessages()) {
if (message.id === 1) {
throwResponseError(message)
observation.ptySpawn = true
request(2, 'pty.shutdown', { id: message.result.id, immediate: true })
} else if (message.id === 2) {
throwResponseError(message)
observation.ptyShutdown = true
request(3, 'relay.status', {})
} else if (message.id === 3) {
throwResponseError(message)
observation.relayAliveAfterPtyShutdown = message.result.ptys.active === 0
bridge.stdin.end()
}
}
} catch (error) {
finish(error)
}
})
function request(id, method, params) {
bridge.stdin.write(encodeRequestFrame({ id, method, params }, outgoingSequence++))
}
function drainMessages() {
const messages = []
while (buffer.length >= HEADER_LENGTH) {
const type = buffer[0]
const payloadLength = buffer.readUInt32BE(9)
if (buffer.length < HEADER_LENGTH + payloadLength) {
break
}
const payload = buffer.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength)
buffer = buffer.subarray(HEADER_LENGTH + payloadLength)
if (type === 1) {
messages.push(JSON.parse(payload.toString('utf8')))
}
}
return messages
}
function finish(error) {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (error) {
bridge.kill()
rejectPromise(error)
} else {
resolvePromise(observation)
}
}
})
}
function waitForPipe(socketPath, timeoutMs) {
const deadline = Date.now() + timeoutMs
return new Promise((resolvePromise, rejectPromise) => {
const attempt = () => {
const socket = createConnection(socketPath)
let settled = false
const retry = () => {
if (settled) {
return
}
settled = true
socket.destroy()
if (Date.now() >= deadline) {
rejectPromise(new Error(`Detached relay did not listen on ${socketPath}`))
} else {
setTimeout(attempt, 50)
}
}
socket.once('connect', () => {
if (settled) {
return
}
settled = true
socket.destroy()
resolvePromise()
})
socket.once('error', retry)
socket.setTimeout(250, retry)
}
attempt()
})
}
function encodeRequestFrame({ id, method, params }, sequence) {
const payload = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
const header = Buffer.alloc(HEADER_LENGTH)
header[0] = 1
header.writeUInt32BE(sequence, 1)
header.writeUInt32BE(0, 5)
header.writeUInt32BE(payload.length, 9)
return Buffer.concat([header, payload])
}
function throwResponseError(message) {
if (message.error) {
throw new Error(`Relay RPC failed: ${message.error.message}`)
}
}
function readNodeVersion(nodePath) {
return spawnSync(nodePath, ['--version'], { encoding: 'utf8' }).stdout.trim()
}
function waitForExit(pid, timeoutMs) {
const deadline = Date.now() + timeoutMs
return new Promise((resolvePromise) => {
const poll = () => {
if (!isProcessAlive(pid) || Date.now() >= deadline) {
resolvePromise()
return
}
setTimeout(poll, 50)
}
poll()
})
}
function isProcessAlive(pid) {
const result = runPowerShell(
`if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'ALIVE' }`
)
return result.stdout.includes('ALIVE')
}
function stopExactProcess(pid, relayPath) {
const script = [
`$process = Get-CimInstance Win32_Process -Filter ${powerShellLiteral(`ProcessId = ${pid}`)}`,
`if ($process -and $process.CommandLine -like ${powerShellLiteral(`*${relayPath}*`)}) { Stop-Process -Id ${pid} -Force }`
].join('; ')
runPowerShell(script)
}
function runPowerShell(script) {
const encoded = Buffer.from(script, 'utf16le').toString('base64')
const result = spawnSync(
'powershell.exe',
['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
{ encoding: 'utf8' }
)
if (result.status !== 0) {
throw new Error(`PowerShell failed (${result.status}): ${result.stderr || result.stdout}`)
}
return result
}
function quoteWindowsArg(value) {
return `"${value.replaceAll('"', '\\"')}"`
}
function powerShellLiteral(value) {
return `'${value.replaceAll("'", "''")}'`
}
function readIfPresent(path) {
return existsSync(path) ? readFileSync(path, 'utf8') : ''
}
function fileSha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}

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