Merge remote-tracking branch 'origin/main' into pr-17757-ready

# Conflicts:
#	src/main/runtime/orca-runtime.test.ts
#	src/main/runtime/orca-runtime.ts
This commit is contained in:
Merge Sim
2026-09-02 16:47:27 -07:00
3418 changed files with 354430 additions and 225314 deletions
+4 -8
View File
@@ -1,11 +1,7 @@
/config/scripts/create-draft-release.mjs text eol=lf
/config/scripts/orca-dev.mjs text eol=lf
/config/scripts/latest-stable-release.mjs text eol=lf
/config/scripts/publish-complete-draft-releases.mjs text eol=lf
/config/scripts/release-rc-history.mjs text eol=lf
/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
# A shebang plus CRLF makes vite's SSR transform emit a literal `#!` mid-module,
# so any suite importing the script dies at load with a SyntaxError. Pin the whole
# directory rather than the scripts that happen to have a test today.
/config/scripts/**/*.mjs text eol=lf
/skill-guides/*.md text eol=lf
/skill-stubs/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
+27 -3
View File
@@ -401,27 +401,51 @@ jobs:
echo "::warning::Could not discard draft $TAG; remove it manually."
- name: Prune expired adhoc releases
# Only after a live publish: $TAG is then a non-draft this step must not
# delete, and a run that failed before publishing has nothing to retire.
if: steps.publish_live.outcome == 'success'
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
# Protect the tag this run just shipped, so no filter mistake can delete
# a build minutes after the person who cut it was told it exists.
TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
# Why compute the cutoff in bash rather than with jq's `now`: this runs
# once per dispatch, and a fixed epoch makes the threshold visible in the
# log when someone asks where their build went.
cutoff=$(( $(date -u +%s) - ADHOC_RETAIN_DAYS * 86400 ))
echo "Pruning adhoc releases created before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')"
echo "Pruning adhoc releases published before $(date -u -r "$cutoff" '+%Y-%m-%dT%H:%M:%SZ')"
# --cleanup-tag so pruning does not leave orphan tags with no release or
# assets attached. Drafts are excluded: a stale draft is the failure
# path's business, not the retention window's.
stale="$(gh release list --repo "$ADHOC_REPO" --limit 200 --json tagName,createdAt,isDraft \
--jq "map(select(.isDraft | not)) | map(select((.createdAt | fromdateiso8601) < $cutoff)) | .[].tagName")"
#
# Age comes from publishedAt, never createdAt. GitHub reports createdAt
# as the date of the *commit* a release's tag points at, and every tag
# here is cut from this repo's one seed commit — so all of them carry
# that same createdAt, and the day the window rolled past it the entire
# channel expired at once and a single run deleted it. A release with no
# publishedAt is kept rather than aged by guesswork.
jq_filter='map(select(.isDraft | not))'
if [[ -n "${TAG:-}" ]]; then
jq_filter+=" | map(select(.tagName != \"${TAG//\"/\\\"}\"))"
fi
jq_filter+=" | map(select((.publishedAt // \"\") != \"\"))"
jq_filter+=" | map(select((.publishedAt | fromdateiso8601) < $cutoff)) | .[].tagName"
stale="$(gh release list --repo "$ADHOC_REPO" --limit 200 --json tagName,publishedAt,isDraft \
--jq "$jq_filter")"
if [[ -z "$stale" ]]; then
echo "Nothing to prune."
exit 0
fi
while read -r tag; do
[[ -n "$tag" ]] || continue
# Belt-and-suspenders: the filter above should already exclude $TAG.
if [[ -n "${TAG:-}" && "$tag" == "$TAG" ]]; then
echo "::warning::Prune list still included just-published $tag after protect; skipping delete."
continue
fi
echo "Pruning $tag"
gh release delete "$tag" --repo "$ADHOC_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
+40 -1
View File
@@ -131,6 +131,9 @@ jobs:
- name: Enforce max-lines ratchet
run: pnpm run check:max-lines-ratchet
- name: Enforce ts-nocheck ratchet
run: pnpm run check:ts-nocheck-ratchet
- name: Enforce runtime Electron-import ratchet
run: pnpm run check:runtime-electron-ratchet
@@ -620,6 +623,8 @@ jobs:
needs: [code_paths]
if: needs.code_paths.outputs.package == 'true'
runs-on: ubuntu-latest
# Let the serial Docker gates reach their own deadlines and report cleanup failures.
timeout-minutes: 90
steps:
- name: Checkout
@@ -675,14 +680,45 @@ jobs:
- name: Build native components
run: pnpm run build:native
- name: Install Linux package tooling
run: sudo apt-get update && sudo apt-get install -y cpio rpm
- name: Package unpacked app
env:
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage --x64 --publish never
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish never
- name: Verify root-package marker payloads
run: |
set -euo pipefail
version="$(node -p "require('./package.json').version")"
deb="dist/orca-ide_${version}_amd64.deb"
rpm="dist/orca-ide-${version}.x86_64.rpm"
test -s "$deb"
test -s "$rpm"
deb_marker="$(dpkg-deb --fsys-tarfile "$deb" | tar -xOf - ./opt/Orca/resources/package-type)"
rpm_marker="$(rpm2cpio "$rpm" | cpio --quiet --extract --to-stdout ./opt/Orca/resources/package-type)"
[[ "$deb_marker" == deb ]] || { echo "Expected deb marker, got: $deb_marker"; exit 1; }
[[ "$rpm_marker" == rpm ]] || { echo "Expected rpm marker, got: $rpm_marker"; exit 1; }
- name: Verify headless serve signal shutdown
run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage
- name: Verify extracted launcher serve signal shutdown
run: >-
node config/scripts/run-headless-serve-shutdown-docker.mjs
--appimage dist/orca-linux.AppImage --entrypoint launcher
- name: Verify AppImage CLI registration and serve signal shutdown
run: >-
node config/scripts/run-headless-serve-shutdown-docker.mjs
--appimage dist/orca-linux.AppImage --entrypoint appimage
--signal-target serving-electron --int-delivery pid
# A default container reproduces the hostile AppImage launch environment.
- name: Verify Linux CLI launch contract
run: node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
@@ -861,6 +897,9 @@ jobs:
contents: read
uses: ./.github/workflows/e2e.yml
with:
# The synthetic pull-request merge ref can disappear while this reusable
# workflow is queued. The head SHA is immutable and works for every PR.
ref: ${{ github.event.pull_request.head.sha }}
test_files: ${{ needs.e2e-paths.outputs.test_files }}
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
+71 -27
View File
@@ -922,9 +922,7 @@ jobs:
run: |
$env:SKIP_BUILD = '1'
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
pnpm run --if-present test:e2e:workspace-session-golden
pnpm run --if-present test:e2e:windows-fresh-startup-golden
pnpm run --if-present test:e2e:source-control-golden
- name: Upload Playwright traces
if: failure()
@@ -940,6 +938,9 @@ jobs:
if: needs.cut.outputs.should_release == 'true'
name: skill sharing release gate ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
# The full suite is release-blocking on macOS. Windows still produces the
# same evidence, but intermittent filesystem contention cannot block signing.
continue-on-error: ${{ matrix.platform == 'windows' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -1122,10 +1123,33 @@ jobs:
retention-days: 7
if-no-files-found: ignore
# Why: artifact jobs submit Windows binaries to SignPath. Keep every
# quota-consuming build behind all blocking release gates so a late test
# failure cannot create signing requests that can never be published.
release-preflight:
needs:
- cut
- terminal-rendering-golden
- skill-sharing-release-gate
- skill-sharing-linux-floor-release-gate
if: >-
always() &&
needs.cut.outputs.should_release == 'true' &&
needs.terminal-rendering-golden.result == 'success' &&
needs.skill-sharing-release-gate.result == 'success' &&
needs.skill-sharing-linux-floor-release-gate.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Confirm blocking release gates passed
run: echo "All blocking release gates passed; artifact builds may start."
build:
needs:
- cut
- create-release
- release-preflight
if: needs.cut.outputs.should_release == 'true'
strategy:
fail-fast: false
@@ -1170,12 +1194,22 @@ jobs:
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
# GitHub reruns also resume jobs skipped behind a failed gate. Never
# recreate Windows signing requests on a rerun; reuse the assets from the
# original attempt and require a fresh dispatch if they are missing.
- name: Skip Windows artifact rebuild on rerun
if: matrix.platform == 'win' && github.run_attempt != 1
shell: bash
run: |
echo "Windows artifact/signing steps are disabled on reruns (attempt $GITHUB_RUN_ATTEMPT)."
echo "Existing signed release assets must be reused; dispatch a fresh release only when a rebuild is required." >> "$GITHUB_STEP_SUMMARY"
# Why: `uses: ./…` resolves from the checked-out tag, not from the workflow
# ref, so cutting from an older/off-main ref whose tree predates a composite
# action would fail the step with "Can't find 'action.yml'". Restore the
# actions directory from the commit this workflow file itself came from.
- name: Restore composite actions from the workflow ref
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: bash
env:
WORKFLOW_SHA: ${{ github.workflow_sha }}
@@ -1321,6 +1355,7 @@ jobs:
# otherwise undecodable. The main bundle is platform-independent, so one
# leg publishes the maps for the whole release.
- name: Bundle main-process source maps
id: bundle-main-sourcemaps
if: matrix.platform == 'linux-x64'
shell: bash
env:
@@ -1328,9 +1363,17 @@ jobs:
run: |
set -euo pipefail
if [ -z "$(find out/main -name '*.js.map' -print -quit)" ]; then
echo "::error::No main-process source maps in out/main. Did build.sourcemap regress in electron.vite.config.ts?"
exit 1
# Older cut tags predate the hidden-source-map build setting. They
# are valid legacy releases, but have no map bundle to publish.
if grep -Eq "sourcemap:[[:space:]]*['\"]hidden['\"]" electron.vite.config.ts; then
echo "::error::No main-process source maps in out/main despite build.sourcemap='hidden'."
exit 1
fi
echo "has_maps=false" >>"$GITHUB_OUTPUT"
echo "::notice::Cut ref predates hidden main-process source maps; skipping map publication."
exit 0
fi
echo "has_maps=true" >>"$GITHUB_OUTPUT"
# Why: every entry in electron-builder's `files` is a negation, so
# app-builder prepends `**/*` and packs anything left in the workspace
# root into app.asar. Stage the bundle outside the checkout instead.
@@ -1338,7 +1381,7 @@ jobs:
ls -l "$RUNNER_TEMP/orca-sourcemaps-$TAG.zip"
- name: Publish main-process source maps
if: matrix.platform == 'linux-x64'
if: matrix.platform == 'linux-x64' && steps.bundle-main-sourcemaps.outputs.has_maps == 'true'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
@@ -1373,7 +1416,7 @@ jobs:
# Why: SignPath signs GitHub workflow artifacts, so Windows builds must
# upload only after the production-signed installer has been returned.
- name: Build Windows release artifacts
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
uses: nick-fields/retry@v4
with:
timeout_minutes: 30
@@ -1384,7 +1427,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify Windows node-pty ConPTY runtime
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
run: |
$runtimeDir = 'dist/win-unpacked/resources/node_modules/node-pty/build/Release'
@@ -1401,7 +1444,7 @@ jobs:
}
- name: Install SignPath PowerShell module
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
uses: ./.github/actions/install-signpath-module
# ── Windows inner-binary signing (issue #7785) ─────────────────────
@@ -1418,7 +1461,7 @@ jobs:
# 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'
if: matrix.platform == 'win' && github.run_attempt == 1
continue-on-error: true
shell: pwsh
run: |
@@ -1457,7 +1500,7 @@ jobs:
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.stage-inner.outcome == 'success'
continue-on-error: true
uses: actions/upload-artifact@v7
with:
@@ -1467,7 +1510,7 @@ jobs:
- name: Submit inner binaries signing request
id: submit-inner-signing
if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.upload-unsigned-inner.outcome == 'success'
continue-on-error: true
uses: signpath/github-action-submit-signing-request@v2
with:
@@ -1481,7 +1524,7 @@ jobs:
- 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'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
@@ -1549,7 +1592,7 @@ jobs:
# 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'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
@@ -1572,7 +1615,7 @@ jobs:
# 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'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.download-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
@@ -1613,7 +1656,7 @@ jobs:
# 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'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
@@ -1640,7 +1683,7 @@ jobs:
- name: Rebuild NSIS installer from signed unpacked app
id: rebuild-nsis-signed
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
@@ -1657,7 +1700,7 @@ jobs:
}
- name: Roll back to original installer after failed rebuild
if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure'
if: matrix.platform == 'win' && github.run_attempt == 1 && steps.rebuild-nsis-signed.outcome == 'failure'
shell: pwsh
run: |
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
@@ -1667,7 +1710,7 @@ jobs:
}
# ── End Windows inner-binary signing ───────────────────────────────
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
id: upload-unsigned-windows-installer
uses: actions/upload-artifact@v7
with:
@@ -1679,7 +1722,7 @@ jobs:
# so the release job waits while the signing request is approved in UI.
- name: Submit Windows installer signing request
id: submit-signing-request
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -1691,7 +1734,7 @@ jobs:
wait-for-completion: false
- name: Notify Slack that Windows signing is waiting for approval
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -1755,7 +1798,7 @@ jobs:
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
- name: Download signed Windows installer from SignPath
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
@@ -1773,7 +1816,7 @@ jobs:
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
- name: Stage signed Windows release assets
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
run: |
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
@@ -1810,7 +1853,7 @@ jobs:
Get-Item 'dist/orca-windows-setup.exe', 'dist/orca-windows-setup.exe.blockmap', 'dist/latest.yml'
- name: Verify signed Windows installer
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
run: |
$signature = Get-AuthenticodeSignature -FilePath 'dist/orca-windows-setup.exe'
@@ -1828,7 +1871,7 @@ jobs:
# 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'
if: matrix.platform == 'win' && github.run_attempt == 1
shell: pwsh
env:
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
@@ -1960,7 +2003,7 @@ jobs:
if ($policyFailure) { throw $policyFailure }
- name: Upload Windows inner signing evidence
if: always() && matrix.platform == 'win'
if: always() && matrix.platform == 'win' && github.run_attempt == 1
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
@@ -1971,7 +2014,7 @@ jobs:
retention-days: 30
- name: Publish signed Windows release artifacts
if: matrix.platform == 'win'
if: matrix.platform == 'win' && github.run_attempt == 1
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
@@ -2026,6 +2069,7 @@ jobs:
needs:
- cut
- create-release
- release-preflight
if: needs.cut.outputs.should_release == 'true'
# Why: SignPath requires every job in this signing workflow to be
# GitHub-hosted. The actual mac build runs in release-mac-build.yml so
+1
View File
@@ -110,6 +110,7 @@ docs/**
!docs/reference/macos-press-and-hold.md
!docs/reference/orcad-operations.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/windows-edr-posture.md
!docs/reference/windows-process-enumeration.md
!docs/reference/wsl-runner-verification.md
!docs/reference/remote-wire-compatibility.md
+7
View File
@@ -49,6 +49,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import.
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
- **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reading [`docs/reference/windows-edr-posture.md`](./docs/reference/windows-edr-posture.md) first — behavioural EDR scores each of those, and being signed does not clear them.
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
@@ -76,6 +77,12 @@ When adding or changing a Git command:
- 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 Scan Safety
- Never enumerate every ref and then run `git ls-tree -r` or `git show` once per ref. That ref × tree fan-out can retain gigabytes of output before a downstream `sort -u` or search can make progress.
- Prefer `rg` over the checked-out files for source searches. For history or refs, use a named ref, an explicit namespace/path, `--max-count`, and a bounded output; do not use an unqualified `--all` scan as a first diagnostic.
- Keep repository-wide commands targeted to the current repository and worktree. If an unbounded scan is genuinely required, measure the ref count first, explain the cost, and get confirmation before running it.
## 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.
+3 -4
View File
@@ -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.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/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.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/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.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
- **Android:** [Download APK 0.0.47](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.47/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
---
@@ -238,9 +238,8 @@ 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 to join the Orca community WeChat group 7. If it is full, use group 8.
- **WeChat:** Scan to join the Orca community WeChat group 8.
<img src="docs/assets/wechat-qr-group7.jpg" alt="WeChat group 7 QR code for the Orca community" width="160" />&nbsp;&nbsp;
<img src="docs/assets/wechat-qr-group8.jpg" alt="WeChat group 8 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).
@@ -0,0 +1,34 @@
ARG BASE_IMAGE=ubuntu:24.04
FROM ${BASE_IMAGE}
ARG LIBASOUND_PACKAGE=libasound2t64
ENV DEBIAN_FRONTEND=noninteractive
# Install Electron's link-time libraries without adding a display server or FUSE.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
coreutils \
"${LIBASOUND_PACKAGE}" \
libatk-bridge2.0-0 \
libatspi2.0-0 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
procps \
util-linux \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
COPY run-cli-case.sh /usr/local/bin/run-cli-case
ENTRYPOINT ["/usr/local/bin/run-cli-case"]
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Print a parseable verdict; the host script owns expected statuses.
set -uo pipefail
case_name=${1:?launch case is required}
extracted_root=${ORCA_TEST_EXTRACTED_ROOT:-/artifacts/squashfs-root}
launcher="$extracted_root/resources/bin/orca-ide"
command_timeout_seconds=${ORCA_TEST_COMMAND_TIMEOUT_SECONDS:-60}
if ((EUID == 0)); then
# Reproduce extracted AppImage sandbox ownership as an unprivileged user.
exec runuser --user orca --preserve-environment -- "$0" "$@"
fi
# Guard the restricted-userns precondition instead of accepting a false pass.
if [[ "$case_name" == *-userns-* ]]; then
if unshare -Ur true 2>/dev/null; then
echo "PRECONDITION_FAILED user namespaces are available; this case needs them restricted"
exit 90
fi
fi
if [[ "$case_name" == nofuse-* && -e /dev/fuse ]]; then
echo "PRECONDITION_FAILED /dev/fuse is present; this case needs it absent"
exit 90
fi
unset DISPLAY WAYLAND_DISPLAY XDG_RUNTIME_DIR
if [[ "$case_name" == stale-display-* ]]; then
DISPLAY=:77
export DISPLAY
fi
case "$case_name" in
# The bundled launcher must stay in Electron's node mode.
nofuse-userns-bundled-help) command=("$launcher" --help) ;;
nofuse-userns-bundled-version) command=("$launcher" --version) ;;
nofuse-userns-bundled-status) command=("$launcher" status) ;;
nofuse-userns-bundled-skills) command=("$launcher" skills --help) ;;
nofuse-userns-bundled-worktree) command=("$launcher" worktree list) ;;
# Direct binaries must hand off before Ozone initializes.
nofuse-nosandbox-direct-binary-skills)
command=("$extracted_root/orca-ide" --no-sandbox skills --help)
;;
nofuse-nosandbox-direct-binary-gui)
command=("$extracted_root/orca-ide" --no-sandbox)
;;
stale-display-nosandbox-direct-binary-gui)
command=("$extracted_root/orca-ide" --no-sandbox)
;;
*)
echo "UNKNOWN_CASE $case_name"
exit 91
;;
esac
output=$(timeout --foreground --signal=TERM --kill-after=5s "${command_timeout_seconds}s" "${command[@]}" 2>&1)
status=$?
if ((status == 124)); then
echo "TIMED_OUT seconds=$command_timeout_seconds case=$case_name"
printf '%s\n' "$output" | tail -30
exit 94
fi
if [[ "$case_name" == nofuse-userns-bundled-version ]]; then
version_file="$extracted_root/resources/app.asar.unpacked/out/package.json"
expected_version=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$version_file")
if [[ -z "$expected_version" || "$output" != "$expected_version" ]]; then
output="VERSION_MISMATCH expected=${expected_version:-missing} got=$output"
status=93
fi
fi
# Shell signal exits are reported as 128 plus the signal number.
if ((status >= 128)); then
echo "CRASHED status=$status case=$case_name"
printf '%s\n' "$output" | tail -30
exit 92
fi
echo "RESULT status=$status case=$case_name"
# Preserve the help header used by output assertions.
printf '%s\n' "$output" | head -200
exit 0
@@ -28,7 +28,6 @@ RUN apt-get update \
util-linux \
xauth \
xvfb \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
@@ -22,16 +22,15 @@ RUN apt-get update \
libxkbcommon0 \
libxrandr2 \
libxss1 \
p7zip-full \
procps \
util-linux \
xauth \
xvfb \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
COPY run-signal-case.sh /usr/local/bin/run-signal-case
COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case
ENTRYPOINT ["/usr/local/bin/run-signal-case"]
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
set -euo pipefail
appimage=${1:-/input/orca.AppImage}
startup_timeout_seconds=90
if [[ $# -gt 1 ]]; then
echo "usage: run-appimage-desktop-startup-case.sh [appimage]" >&2
exit 64
fi
if ((EUID == 0)); then
if ! state_dir=$(mktemp -d /tmp/orca-appimage-startup.XXXXXX); then
echo 'FAIL: unable to create the AppImage startup state directory' >&2
exit 1
fi
if ! chown orca:orca "$state_dir"; then
echo "FAIL: unable to hand the AppImage startup state directory to orca: $state_dir" >&2
rm -rf -- "$state_dir" || true
exit 1
fi
exec runuser --user orca --preserve-environment -- env \
ORCA_STARTUP_STATE_DIR="$state_dir" \
ORCA_STARTUP_STATE_DIR_CLEANUP=1 \
"$0" "$@"
fi
remove_state_dir_on_exit=${ORCA_STARTUP_STATE_DIR_CLEANUP:-0}
if [[ -n "${ORCA_STARTUP_STATE_DIR:-}" ]]; then
state_dir=$ORCA_STARTUP_STATE_DIR
else
if ! state_dir=$(mktemp -d /tmp/orca-appimage-startup.XXXXXX); then
echo 'FAIL: unable to create the AppImage startup state directory' >&2
exit 1
fi
remove_state_dir_on_exit=1
fi
stdout_log="$state_dir/stdout.log"
stderr_log="$state_dir/stderr.log"
launcher_pid=
launcher_start_ticks=
launcher_pgid=
launcher_status=
launcher_waited=false
tree_pids=()
declare -A tree_start_ticks=()
read_start_ticks() {
local pid=$1
[[ -r "/proc/$pid/stat" ]] || return 1
awk '{print $22}' "/proc/$pid/stat"
}
identity_alive() {
local pid=$1
local expected_ticks=$2
[[ -n "$expected_ticks" ]] || return 1
[[ -r "/proc/$pid/stat" ]] || return 1
[[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "$expected_ticks" ]] || return 1
local process_state
process_state=$(ps -o stat= -p "$pid" 2>/dev/null | tr -d '[:space:]' || true)
[[ -n "$process_state" && "$process_state" != Z* ]]
}
collect_process_tree() {
tree_pids=()
tree_start_ticks=()
[[ -n "$launcher_pid" ]] || return
[[ -n "$launcher_start_ticks" ]] || return
tree_pids+=("$launcher_pid")
tree_start_ticks["$launcher_pid"]="$launcher_start_ticks"
local -a frontier=("$launcher_pid")
while ((${#frontier[@]})); do
local parent=${frontier[0]}
frontier=("${frontier[@]:1}")
while read -r child; do
[[ -n "$child" ]] || continue
[[ -z "${tree_start_ticks[$child]+present}" ]] || continue
local child_ticks
child_ticks=$(read_start_ticks "$child" 2>/dev/null || true)
[[ -n "$child_ticks" ]] || continue
tree_pids+=("$child")
tree_start_ticks["$child"]="$child_ticks"
frontier+=("$child")
done < <(ps -eo pid=,ppid= | awk -v parent="$parent" '$2 == parent {print $1}')
done
}
process_is_xvfb() {
local pid=$1
local command_name
command_name=$(ps -o comm= -p "$pid" 2>/dev/null || true)
[[ "$command_name" == Xvfb ]] && return 0
local command_line
command_line=$(ps -o args= -p "$pid" 2>/dev/null || true)
[[ "$command_line" =~ (^|[[:space:]/])Xvfb([[:space:]]|$) ]]
}
signal_process_group() {
local signal=$1
identity_alive "$launcher_pid" "$launcher_start_ticks" || return 0
[[ "$launcher_pgid" =~ ^[0-9]+$ ]] || return 0
[[ "$launcher_pgid" != "$(ps -o pgid= -p "$$" | tr -d ' ')" ]] || return 0
kill -s "$signal" -- "-$launcher_pgid" 2>/dev/null || true
}
signal_owned_processes() {
local signal=$1
local index pid ticks
for ((index = ${#tree_pids[@]} - 1; index >= 0; index--)); do
pid=${tree_pids[index]}
ticks=${tree_start_ticks[$pid]-}
if identity_alive "$pid" "$ticks"; then
kill -s "$signal" "$pid" 2>/dev/null || true
fi
done
}
wait_for_owned_exit() {
local timeout_seconds=$1
local deadline=$((SECONDS + timeout_seconds))
local pid ticks alive
while ((SECONDS < deadline)); do
alive=0
for pid in "${tree_pids[@]}"; do
ticks=${tree_start_ticks[$pid]-}
if identity_alive "$pid" "$ticks"; then
alive=1
break
fi
done
if ((alive == 0)); then
return 0
fi
sleep 0.2
done
return 1
}
dump_logs() {
echo "--- desktop startup stdout ---" >&2
cat "$stdout_log" >&2 2>/dev/null || true
echo "--- desktop startup stderr ---" >&2
cat "$stderr_log" >&2 2>/dev/null || true
}
cleanup_state_dir() {
[[ "$remove_state_dir_on_exit" == 1 ]] || return 0
[[ "$state_dir" =~ ^/tmp/orca-appimage-startup\.[^/]+$ ]] || return 0
[[ -d "$state_dir" && ! -L "$state_dir" && -O "$state_dir" ]] || return 0
rm -rf -- "$state_dir"
}
capture_launcher_status() {
[[ "$launcher_waited" == false ]] || return 0
[[ -n "$launcher_pid" ]] || return 1
if wait "$launcher_pid"; then
launcher_status=0
else
launcher_status=$?
fi
launcher_waited=true
}
report_launcher_exit() {
local reason=$1
local observed_status=unknown
local exit_status=1
if capture_launcher_status; then
observed_status=$launcher_status
if ((launcher_status != 0)); then
exit_status=$launcher_status
fi
fi
echo "FAIL: desktop launcher exited before ${reason} (status=${observed_status})" >&2
exit "$exit_status"
}
cleanup() {
local status=$?
trap - EXIT
signal_process_group TERM || true
signal_owned_processes TERM || true
if ! wait_for_owned_exit 10; then
signal_process_group KILL || true
signal_owned_processes KILL || true
wait_for_owned_exit 5 || status=1
fi
capture_launcher_status || true
if ((status != 0)); then
dump_logs
else
if ! cleanup_state_dir; then
status=1
dump_logs
fi
fi
exit "$status"
}
trap cleanup EXIT
mkdir -p "$state_dir/home" "$state_dir/config" "$state_dir/cache" "$state_dir/runtime"
chmod 700 "$state_dir/runtime"
export HOME="$state_dir/home"
export XDG_CONFIG_HOME="$state_dir/config"
export XDG_CACHE_HOME="$state_dir/cache"
export XDG_RUNTIME_DIR="$state_dir/runtime"
export LIBGL_ALWAYS_SOFTWARE=1
export ORCA_STARTUP_DIAGNOSTICS=1
ulimit -c 0
[[ -r "$appimage" ]] || { echo "FAIL: AppImage is not readable: $appimage" >&2; exit 1; }
[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; }
setsid --wait dbus-run-session -- xvfb-run -a "$appimage" --appimage-extract-and-run --no-sandbox \
>"$stdout_log" 2>"$stderr_log" &
launcher_pid=$!
launcher_start_ticks=$(read_start_ticks "$launcher_pid" 2>/dev/null || true)
launcher_pgid=$(ps -o pgid= -p "$launcher_pid" 2>/dev/null | tr -d ' ' || true)
if [[ -z "$launcher_start_ticks" ]]; then
report_launcher_exit 'its identity could be recorded'
fi
marker_seen=false
deadline=$((SECONDS + startup_timeout_seconds))
while ((SECONDS < deadline)); do
if grep -Eq '^\[startup\] updater-setup-done t=[0-9]+$' "$stderr_log"; then
marker_seen=true
break
fi
if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then
report_launcher_exit 'the updater-setup-done marker'
fi
sleep 0.2
done
if [[ "$marker_seen" != true ]]; then
if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then
report_launcher_exit 'the updater-setup-done marker'
fi
echo "FAIL: desktop AppImage did not emit updater-setup-done within ${startup_timeout_seconds}s" >&2
exit 1
fi
if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then
echo "FAIL: desktop launcher identity changed after startup marker" >&2
exit 1
fi
collect_process_tree
xvfb_pids=()
for pid in "${tree_pids[@]}"; do
if process_is_xvfb "$pid"; then
xvfb_pids+=("$pid")
fi
done
if ((${#xvfb_pids[@]} == 0)); then
echo "FAIL: no launcher-owned Xvfb process was found after startup" >&2
exit 1
fi
for pid in "${xvfb_pids[@]}"; do
if ! identity_alive "$pid" "${tree_start_ticks[$pid]-}"; then
echo "FAIL: launcher-owned Xvfb identity changed before cleanup" >&2
exit 1
fi
done
echo "Desktop AppImage startup validation passed (launcher=${launcher_pid}, xvfb=${xvfb_pids[*]})."
@@ -6,7 +6,9 @@ app_root=${ORCA_TEST_APP_ROOT:-/artifacts/root}
signal_target_kind=${ORCA_SIGNAL_TARGET:-app}
entrypoint_kind=${ORCA_TEST_ENTRYPOINT:-app}
int_delivery=${ORCA_INT_DELIVERY:-foreground-process-group}
startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-90}
# Packaged Electron startup can approach 90s on a cold CI runner; leave room
# for the readiness line to reach the log before the observer deadline.
startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-180}
if ((EUID == 0)); then
exec runuser --user orca --preserve-environment -- "$0" "$@"
@@ -41,8 +43,8 @@ chmod 700 "$XDG_RUNTIME_DIR"
case "$entrypoint_kind" in
app) entrypoint=("$app_root/AppRun" --no-sandbox) ;;
appimage) entrypoint=(/input/orca.AppImage --appimage-extract-and-run --no-sandbox) ;;
launcher)
export ELECTRON_DISABLE_SANDBOX=1
entrypoint=("$app_root/resources/bin/orca-ide")
;;
*) echo "unsupported entrypoint: $entrypoint_kind" >&2; exit 64 ;;
@@ -53,18 +55,50 @@ setsid env -u DISPLAY "${entrypoint[@]}" serve --port 0 --pairing-address 127.0.
app_pid=$!
app_start_ticks=$(awk '{print $22}' "/proc/$app_pid/stat")
# The inner shell expands its positional parameters.
# shellcheck disable=SC2016
ready_line=$(timeout "$startup_timeout_seconds" bash -c '
tail --pid="$1" -n +1 -F "$2" 2>/dev/null \
| jq --unbuffered -nc '\''first(inputs | select(.type == "orca_server_ready" and .schemaVersion == 1))'\''
' bash "$app_pid" "$stdout_log" || true)
# jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F
# observer can outlive the timeout and leak into the next signal case. Poll
# finite snapshots instead; each parser invocation has a definite EOF.
read_ready_line() {
sed -u -n 's/^[^{]*//p' "$stdout_log" \
| jq --unbuffered -Rnc 'first(inputs | fromjson? | select(.type == "orca_server_ready" and .schemaVersion == 1))'
}
ready_line=''
startup_deadline=$((SECONDS + startup_timeout_seconds))
while (( SECONDS < startup_deadline )); do
ready_line=$(read_ready_line)
[[ -n "$ready_line" ]] && break
kill -0 "$app_pid" 2>/dev/null || break
sleep 1
done
# A readiness event can land as the final poll races the write.
if [[ -z "$ready_line" ]]; then
ready_line=$(read_ready_line)
fi
if [[ -z "$ready_line" ]]; then
cat "$stdout_log" "$stderr_log" >&2
echo "FAIL: AppRun exited or timed out before orca_server_ready" >&2
echo "FAIL: entrypoint exited or timed out before orca_server_ready" >&2
exit 1
fi
registered_cli_verified=false
if [[ "$entrypoint_kind" == appimage ]]; then
registered_cli="$HOME/.local/bin/orca-ide"
expected_target="$XDG_CACHE_HOME/orca/appimage/launcher/orca-ide"
actual_target=$(readlink "$registered_cli" 2>/dev/null || true)
if [[ "$actual_target" != "$expected_target" ]]; then
echo "FAIL: registered CLI target is ${actual_target:-missing}; expected $expected_target" >&2
exit 1
fi
if ! registered_help=$("$registered_cli" --help 2>&1) \
|| [[ "$registered_help" != *'Usage: orca <command>'* ]]; then
echo "FAIL: registered CLI did not execute the packaged help command" >&2
printf '%s\n' "$registered_help" >&2
exit 1
fi
registered_cli_verified=true
fi
bound_endpoint=$(jq -r '.boundEndpoint' <<<"$ready_line")
bound_port=${bound_endpoint##*:}
listener_before=$(ss -H -ltnp "sport = :$bound_port" || true)
@@ -72,6 +106,7 @@ if [[ -z "$listener_before" ]]; then
echo "FAIL: ready listener has no socket owner at $bound_endpoint" >&2
exit 1
fi
listener_before_pids=$(grep -oE 'pid=[0-9]+' <<<"$listener_before" | cut -d= -f2 || true)
tree_pids=()
declare -A tree_start_ticks
@@ -104,8 +139,15 @@ fi
signal_target_pid=$app_pid
if [[ "$signal_target_kind" == serving-electron ]]; then
signal_target_pid=$(awk '/\/orca-ide .* --serve / {print $1; exit}' <<<"$tree_snapshot")
# The ready socket identifies the serving Electron even when AppImage's
# extraction wrapper rewrites the command line before it reaches Chromium.
signal_target_pid=$(head -n1 <<<"$listener_before_pids")
[[ -n "$signal_target_pid" ]] || { echo "FAIL: serving Electron process not found" >&2; exit 1; }
if [[ -z "${tree_start_ticks[$signal_target_pid]+present}" ]]; then
echo "FAIL: ready listener PID $signal_target_pid is outside the entrypoint process tree" >&2
echo "listener: $listener_before" >&2
exit 1
fi
elif [[ "$signal_target_kind" != app ]]; then
echo "unsupported signal target: $signal_target_kind" >&2
exit 64
@@ -138,17 +180,25 @@ fi
kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
listener_after=$(ss -H -ltnp "sport = :$bound_port" || true)
survivors=()
for pid in "${tree_pids[@]}"; do
if [[ -r "/proc/$pid/stat" ]] \
&& [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \
&& ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then
survivors+=("$pid")
# Crashpad can exit just after Electron; poll all owned shutdown state for up to 5s.
for shutdown_poll in {0..50}; do
listener_after=$(ss -H -ltnp "sport = :$bound_port" || true)
survivors=()
for pid in "${tree_pids[@]}"; do
if [[ -r "/proc/$pid/stat" ]] \
&& [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \
&& ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then
survivors+=("$pid")
fi
done
owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \
'($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true)
if [[ -z "$listener_after" && -z "$owned_residue" ]] \
&& ((${#survivors[@]} == 0)); then
break
fi
((shutdown_poll < 50)) && sleep 0.1
done
owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \
'($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true)
canary_alive=false
if kill -0 "$canary_pid" 2>/dev/null \
@@ -170,16 +220,18 @@ jq -nc \
--argjson signalTargetPid "$signal_target_pid" \
--arg endpoint "$bound_endpoint" \
--arg listenerBefore "$listener_before" \
--arg listenerBeforePids "$listener_before_pids" \
--arg listenerAfter "$listener_after" \
--arg xvfbPids "$xvfb_pids" \
--arg treeBefore "$tree_snapshot" \
--argjson waitStatus "$wait_status" \
--argjson fatalEvidence "$fatal_evidence" \
--argjson canaryAlive "$canary_alive" \
--argjson registeredCliVerified "$registered_cli_verified" \
--arg survivors "${survivors[*]:-}" \
--arg residue "$owned_residue" \
--arg corePattern "$(cat /proc/sys/kernel/core_pattern)" \
'{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}'
'{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerBeforePids:$listenerBeforePids,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,registeredCliVerified:$registeredCliVerified,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}'
if ((wait_status != 0)) || [[ -n "$listener_after" ]] || [[ "$fatal_evidence" != false ]] \
|| [[ "$canary_alive" != true ]] || ((${#survivors[@]})) || [[ -n "$owned_residue" ]]; then
+82 -8
View File
@@ -1,4 +1,4 @@
const { chmodSync, existsSync, readdirSync } = require('node:fs')
const { chmodSync, existsSync, readdirSync, readFileSync, writeFileSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const { join, resolve } = require('node:path')
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs')
@@ -18,6 +18,7 @@ const {
verifyPackagedNodePtyJobOwnership
} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs')
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs')
// Why: dev-channel builds must carry the *release* identity — same bundle id,
// Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to
@@ -104,12 +105,41 @@ const winSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-win-x64',
to: 'node_modules/sherpa-onnx-win-x64'
}
// electron-builder replaces these defaults when `depends` is configured; retain
// Electron's loader requirements alongside Orca's headless-host dependencies.
const debElectronRuntimeDependencies = [
'libgtk-3-0',
'libnotify4',
'libnss3',
'libxss1',
'libxtst6',
'xdg-utils',
'libatspi2.0-0',
'libuuid1',
'libsecret-1-0'
]
const rpmElectronRuntimeDependencies = [
'gtk3',
'libnotify',
'nss',
'libXScrnSaver',
'(libXtst or libXtst6)',
'xdg-utils',
'at-spi2-core',
'(libuuid or libuuid1)'
]
// Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build.
// Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with
// config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows.
const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx']
/** @type {import('electron-builder').Configuration} */
module.exports = {
appId,
productName: 'Orca',
protocols: [{ name: 'Orca', schemes: ['orca'] }],
toolsets: { appimage: '1.0.3' },
...(devChannelBuildVersion
? { extraMetadata: { version: devChannelBuildVersion } }
: localBuildVersion
@@ -230,12 +260,21 @@ module.exports = {
'node_modules/zod/**',
'node_modules/yaml/**'
],
artifactBuildCompleted: ({ file, arch }) => {
if (file.endsWith('.AppImage')) {
verifyStaticAppImagePackage(file, arch)
}
},
afterPack: async (context) => {
// Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node
// requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902).
// Fail packaging if any bundled native binary exceeds the supported floor.
if (context.electronPlatformName === 'linux') {
verifyLinuxGlibcFloor(context.appOutDir)
// Why the arch is passed: symbol-version checks pass happily on a wrong-architecture binary,
// so a cross-built slice could ship the host's pty.node and only fail at runtime.
verifyLinuxGlibcFloor(context.appOutDir, {
targetArch: { 1: 'x64', 3: 'arm64' }[context.arch]
})
}
const resourcesDir =
context.electronPlatformName === 'darwin'
@@ -249,6 +288,10 @@ module.exports = {
if (!existsSync(resourcesDir)) {
throw new Error(`Missing packaged resources directory: ${resourcesDir}`)
}
// FpmTarget replaces this with deb/rpm while building those artifacts from the shared app tree.
if (context.electronPlatformName === 'linux') {
writeFileSync(join(resourcesDir, 'package-type'), 'AppImage')
}
if (context.electronPlatformName === 'darwin') {
const architectureByEnum = { 1: 'x64', 3: 'arm64' }
const architecture = architectureByEnum[context.arch]
@@ -268,6 +311,7 @@ module.exports = {
}
writeMacBuildCompatibility(resourcesDir, { version, commit, architecture })
}
stampPackagedCliVersion(resourcesDir, context.packager.appInfo.version)
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
verifyPackagedMainRuntimeDeps(resourcesDir)
// Why: boot the packaged daemon-entry under plain Node, but only for the
@@ -376,12 +420,24 @@ module.exports = {
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
createDesktopShortcut: 'always',
// Why: on a real uninstall, stop and remove the relocated terminal daemon
// (which lives outside the install dir under LOCALAPPDATA by design). Guarded
// by ${isUpdated} inside so it never runs during an update's uninstallOldVersion.
include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh')
// Why: electron-builder allows one include, so both Windows installer hooks live in it -
// the relocated-daemon uninstall sweep (guarded by ${isUpdated} so it never runs during an
// update's uninstallOldVersion) and the additive markdown "Open with" registration.
// Windows markdown association is deliberately NOT done via `fileAssociations`; see the
// header comment in that file for why that would steal the user's default .md handler.
include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh')
},
mac: {
// Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming
// LSHandlerRank ownership, so whichever editor the user already prefers stays the default.
// Why one entry per extension: app-builder-lib globs `*.${ext}`, which an array would break.
fileAssociations: MARKDOWN_FILE_EXTENSIONS.map((ext) => ({
ext,
name: 'Markdown Document',
description: 'Markdown Document',
role: 'Editor',
rank: 'Alternate'
})),
icon: 'resources/build/icon.icns',
entitlements: 'resources/build/entitlements.mac.plist',
entitlementsInherit: 'resources/build/entitlements.mac.plist',
@@ -468,6 +524,12 @@ module.exports = {
artifactName: 'orca-macos-${arch}.${ext}'
},
linux: {
// Why mimeTypes and not fileAssociations: shared-mime-info already maps *.md/*.markdown to
// text/markdown, so reusing that type puts Orca in the Open With list without shipping a glob
// override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the
// default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to
// application/x-genesis-32x-rom, so claiming it here would need a glob override.
mimeTypes: ['text/markdown'],
// Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca.
// The Linux installer should not claim those system package/file names.
executableName: 'orca-ide',
@@ -499,7 +561,8 @@ module.exports = {
},
featureWallResources
],
target: ['AppImage', 'deb'],
// Keep local artifacts aligned with the release pipeline.
target: ['AppImage', 'deb', 'rpm'],
maintainer: 'stablyai',
category: 'Utility'
},
@@ -513,6 +576,7 @@ module.exports = {
// Linux host — Chromium needs a display server even for offscreen rendering,
// and serve starts Xvfb itself when present (see ensure-virtual-display.ts).
depends: [
...debElectronRuntimeDependencies,
'python3',
'python3-gi',
'gir1.2-atspi-2.0',
@@ -534,9 +598,9 @@ module.exports = {
// Why: see deb depends. RPM distros ship Xvfb as xorg-x11-server-Xvfb (there
// is no `xvfb` package), so the name differs from the deb here.
depends: [
...rpmElectronRuntimeDependencies,
'python3',
'python3-gobject',
'at-spi2-core',
'xdotool',
'xclip',
'xorg-x11-server-Xvfb'
@@ -561,6 +625,16 @@ module.exports = {
}
}
// Stamp the effective channel version where node-mode CLI code can read it.
function stampPackagedCliVersion(resourcesDir, version) {
const packageJsonPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json')
if (!existsSync(packageJsonPath)) {
throw new Error(`Missing unpacked CLI package boundary: ${packageJsonPath}`)
}
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
writeFileSync(packageJsonPath, `${JSON.stringify({ ...packageJson, version }, null, 2)}\n`)
}
function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) {
if (electronPlatformName === 'win32') {
return
-36
View File
@@ -2,51 +2,15 @@
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
inline src/main/agent-hooks/server.ts
inline src/main/browser/agent-browser-bridge.ts
inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-manager.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/index.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.ts
inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/ssh/ssh-channel-multiplexer.ts
inline src/main/ssh/ssh-connection.ts
inline src/main/ssh/ssh-relay-deploy.ts
inline src/main/ssh/ssh-relay-session.ts
inline src/main/updater.ts
inline src/preload/index.ts
inline src/relay/pty-handler.ts
inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
inline src/renderer/src/components/WorktreeJumpPalette.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/ui.ts
inline src/shared/keybindings.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/session/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/index.tsx
mobile-config scripts/mock-server.ts
mobile-config src/terminal/terminal-webview-html.ts
mobile-config src/transport/rpc-client.ts
-23
View File
@@ -1,23 +0,0 @@
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
!macroend
+79
View File
@@ -0,0 +1,79 @@
; electron-builder NSIS hooks for the Orca Windows installer.
;
; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall /
; customUnInstall hook Orca needs lives here.
; ---------------------------------------------------------------------------
; Markdown "Open with Orca" (issue #10138)
;
; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows:
; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is
; WriteRegStr SHELL_CONTEXT "Software\Classes\.md" "" "<ProgID>"
; That overwrites whichever editor currently owns .md, with no backup, for every
; existing user on their next UPDATE - and APP_UNASSOCIATE never restores it, so
; uninstalling Orca would leave .md pointing at a deleted ProgID.
;
; These writes are additive only. Registering a ProgID plus an OpenWithProgids
; hint and an Applications\<exe>\SupportedTypes entry puts Orca in Explorer's
; "Open with" list and in "Choose another app", while the default handler stays
; exactly where the user left it. Never add a `Software\Classes\.<ext>` default
; value here.
;
; MARKDOWN_PROGID must stay in sync with the extension list handled by
; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts.
; ---------------------------------------------------------------------------
!define MARKDOWN_PROGID "Orca.Markdown"
!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT
WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" ""
!macroend
!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT
DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}"
!macroend
!macro customInstall
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"'
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md"
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown"
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx"
; Why: Explorer caches the association list until told otherwise.
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
; ---------------------------------------------------------------------------
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so
; dropping them during uninstallOldVersion is correct and keeps the pair symmetric.
DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx"
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
+133 -6
View File
@@ -138,8 +138,34 @@ index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441
process.exit(0);
//# sourceMappingURL=conpty_console_list_agent.js.map
\ No newline at end of file
diff --git a/lib/terminal.js b/lib/terminal.js
index e2f9bc9131077b53ebc32d207207ad82804ff185..6c63bfaaf75128d88f9a2efece13476348780cfd 100644
--- a/lib/terminal.js
+++ b/lib/terminal.js
@@ -172,6 +172,21 @@ var Terminal = /** @class */ (function () {
this.end = function () { };
this._writable = false;
this._readable = false;
+ // Orca: libuv closes the master fd on EIO/EOF, and the kernel may hand
+ // that number straight to the next open(2). Retire it in the same block
+ // that gives up the handle so no later ioctl can address a reused fd.
+ // Inert on Windows, where `_fd` is written once and never read back.
+ // Upstream named this mechanism in microsoft/node-pty#220 ("fd number got
+ // reattached to something else"), closed 2025-12-19 as completed after
+ // only improving the error message; #827 is still open. Windows guards in
+ // windowsPtyAgent.ts, Unix does not. Orca tracking: #18109.
+ this._fd = -1;
+ // Orca: the write stream holds its own copy of that number, so retiring
+ // `_fd` alone leaves the queued and in-flight writes addressing it.
+ // Undefined on Windows and on `UnixTerminal.open()` handles.
+ if (this._writeStream) {
+ this._writeStream.dispose();
+ }
};
Terminal.prototype._parseEnv = function (env) {
var keys = Object.keys(env || {});
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..d838d795ecb9ea72e3bcc31113344947c006af7e 100644
--- a/lib/unixTerminal.js
+++ b/lib/unixTerminal.js
@@ -28,8 +28,12 @@ var native = utils_1.loadNativeModule('pty');
@@ -157,6 +183,56 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
var DEFAULT_FILE = 'sh';
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
@@ -234,6 +238,11 @@ var UnixTerminal = /** @class */ (function (_super) {
* Gets the name of the process.
*/
get: function () {
+ // Orca: tcgetpgrp on a retired fd would name whatever process now
+ // owns that descriptor, so a closed master reports the spawn file.
+ if (this._fd < 0) {
+ return this._file;
+ }
if (process.platform === 'darwin') {
var title = pty.process(this._fd);
return (title !== 'kernel_task') ? title : this._file;
@@ -250,6 +259,11 @@ var UnixTerminal = /** @class */ (function (_super) {
if (cols <= 0 || rows <= 0 || isNaN(cols) || isNaN(rows) || cols === Infinity || rows === Infinity) {
throw new Error('resizing must be done using positive cols and rows');
}
+ // Orca: a retired master is unreachable rather than EBADF-or-worse; cols
+ // and rows stay at the last size actually applied instead of a claim.
+ if (this._fd < 0) {
+ return;
+ }
pty.resize(this._fd, cols, rows);
this._cols = cols;
this._rows = rows;
@@ -287,8 +301,15 @@ var CustomWriteStream = /** @class */ (function () {
CustomWriteStream.prototype.dispose = function () {
clearImmediate(this._writeImmediate);
this._writeImmediate = undefined;
+ // Orca: retire this stream's own copy of the master fd and drop what has
+ // not shipped, so nothing queued here reaches a reused descriptor.
+ this._fd = -1;
+ this._writeQueue.length = 0;
};
CustomWriteStream.prototype.write = function (data) {
+ if (this._fd < 0) {
+ return;
+ }
// Writes are put in a queue and processed asynchronously in order to handle
// backpressure from the kernel buffer.
var buffer = typeof data === 'string'
@@ -304,7 +325,8 @@ var CustomWriteStream = /** @class */ (function () {
CustomWriteStream.prototype._processWriteQueue = function () {
var _this = this;
this._writeImmediate = undefined;
- if (this._writeQueue.length === 0) {
+ // Orca: an in-flight fs.write can re-enter here after dispose().
+ if (this._fd < 0 || this._writeQueue.length === 0) {
return;
}
var task = this._writeQueue[0];
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644
--- a/src/conpty_console_list_agent.ts
@@ -176,7 +252,7 @@ index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd
process.send!({ consoleProcessList });
process.exit(0);
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..2ae787c5bd4f3eba470584dc658a01a52c690e0a 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -23,7 +23,9 @@
@@ -215,7 +291,17 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
/* Some platforms name VWERASE and VDISCARD differently */
#if !defined(VWERASE) && defined(VWERSE)
#define VWERASE VWERSE
@@ -237,13 +258,23 @@ pty_getproc(int, char *);
@@ -228,6 +249,9 @@ Napi::Value PtyGetProc(const Napi::CallbackInfo& info);
static int
pty_nonblock(int);
+static int
+pty_cloexec(int);
+
#if defined(__APPLE__)
static char *
pty_getproc(int);
@@ -237,13 +261,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
@@ -240,7 +326,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
#endif
struct DelBuf {
@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
@@ -367,14 +401,18 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
argv[i + 3] = strdup(arg.c_str());
}
@@ -256,7 +342,48 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) {
}
+ if (pty_cloexec(master) == -1) {
+ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");
+ }
#else
int argc = argv_.Length();
int argl = argc + 2;
@@ -445,6 +483,9 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
}
+ if (pty_cloexec(master) == -1) {
+ throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");
+ }
}
#endif
@@ -586,6 +627,23 @@ pty_nonblock(int fd) {
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
+/**
+ * Orca: close-on-exec FD
+ *
+ * forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without
+ * FD_CLOEXEC is inherited by every later child of this process -- including
+ * later pty children -- which keeps its /dev/pts device and buffers alive long
+ * after its own session ends (#8362).
+ */
+
+static int
+pty_cloexec(int fd) {
+ int flags = fcntl(fd, F_GETFD);
+ if (flags == -1) return -1;
+ if (flags & FD_CLOEXEC) return 0;
+ return fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
+}
+
/**
* pty_getproc
* Taken from tmux.
@@ -684,15 +742,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
@@ -332,7 +459,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env,
@@ -706,80 +822,118 @@ pty_posix_spawn(char** argv, char** env,
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
@@ -0,0 +1,318 @@
/**
* Relay-side pty-master close-on-exec patch for node-pty 1.1.0 (#17915).
*
* The app gets this through pnpm `patchedDependencies`; the relay installs stock
* node-pty from npm onto the host, where no pnpm patch reaches. Without it every
* later child of the relay -- pty children, git helpers, probes, agent CLIs --
* inherits each live master fd and keeps its /dev/pts device alive for the life
* of the relay (#8362).
*
* Linux only, deliberately: it is the only relay platform that takes forkpty()'s
* no-atomic-O_CLOEXEC path, and the only one that already compiles node-pty at
* install time, so the rebuild costs a second compile rather than a first one.
* macOS re-opens the tty through uv_tty_init's cloexec dup and Windows has no fds.
*
* Non-fatal by construction: the working build is moved aside before anything is
* touched and moved back on any failure, and a failed attempt drops a skip marker
* so the compile is attempted at most once per relay directory.
*/
const { spawnSync } = require('node:child_process')
const { createHash } = require('node:crypto')
const {
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync
} = require('node:fs')
const { dirname, join, resolve } = require('node:path')
const EXPECTED_NODE_PTY_VERSION = '1.1.0'
const ORIGINAL_SOURCE_SHA256 = '5e1005d6bdcfbe97b486ee415419fe7adae99035047f07340fbad36419e0bae6'
const PATCHED_SOURCE_SHA256 = '97dea52199216c01b62070758f0f38621ae53adc16c221271dd35ae2d8ee3482'
const STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:'
const SKIP_MARKER_FILENAME = '.node-pty-cloexec-skip'
const BACKUP_DIRNAME = '.orca-cloexec-prepatch-release'
// Under the caller's 240s SSH command timeout, so the rollback below still runs.
const REBUILD_TIMEOUT_MS = 200000
const VERIFY_TIMEOUT_MS = 15000
const FORWARD_DECLARATION = [
'static int\npty_nonblock(int);\n',
'static int\npty_nonblock(int);\n\nstatic int\npty_cloexec(int);\n'
]
const DEFINITION = [
`static int
pty_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
`,
`static int
pty_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
/**
* Orca: close-on-exec FD
*
* forkpty()/posix_openpt() have no atomic O_CLOEXEC, so a master left without
* FD_CLOEXEC is inherited by every later child of this process -- including
* later pty children -- which keeps its /dev/pts device and buffers alive long
* after its own session ends (#8362).
*/
static int
pty_cloexec(int fd) {
int flags = fcntl(fd, F_GETFD);
if (flags == -1) return -1;
if (flags & FD_CLOEXEC) return 0;
return fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
}
`
]
const FORKPTY_CALL_SITE = [
` default:
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
}
}
`,
` default:
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
}
if (pty_cloexec(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to close-on-exec.");
}
}
`
]
const REPLACEMENTS = [FORWARD_DECLARATION, DEFINITION, FORKPTY_CALL_SITE]
function sourceSha256(source) {
return createHash('sha256').update(source).digest('hex')
}
function nodePtyDir(relayDir) {
return resolve(relayDir, 'node_modules', 'node-pty')
}
function inspectNodePtyUnixSource(relayDir) {
const ptyDir = nodePtyDir(relayDir)
const sourcePath = join(ptyDir, 'src', 'unix', 'pty.cc')
const version = JSON.parse(readFileSync(join(ptyDir, 'package.json'), 'utf8')).version
if (version !== EXPECTED_NODE_PTY_VERSION) {
throw new Error(`Refusing to patch node-pty ${version}; expected ${EXPECTED_NODE_PTY_VERSION}`)
}
return { ptyDir, sourcePath, source: readFileSync(sourcePath, 'utf8') }
}
function writeSourceAtomically(sourcePath, contents) {
const temporaryPath = `${sourcePath}.orca-patch-${process.pid}`
// Why: a terminated install must leave one of the two known source versions on disk.
try {
writeFileSync(temporaryPath, contents)
renameSync(temporaryPath, sourcePath)
} finally {
rmSync(temporaryPath, { force: true })
}
}
function rewriteSource(source, reverse) {
let rewritten = source
for (const [original, patched] of REPLACEMENTS) {
const from = reverse ? patched : original
const to = reverse ? original : patched
if (rewritten.split(from).length - 1 !== 1) {
throw new Error('Refusing to rewrite unexpected node-pty pty.cc source')
}
rewritten = rewritten.replace(from, to)
}
return rewritten
}
/** True when the patch was applied, false when it was already installed. */
function patchNodePtyMasterCloexecSource(relayDir = process.cwd()) {
const inspected = inspectNodePtyUnixSource(relayDir)
const hash = sourceSha256(inspected.source)
if (hash === PATCHED_SOURCE_SHA256) {
return false
}
if (hash !== ORIGINAL_SOURCE_SHA256) {
throw new Error('Refusing to patch unexpected node-pty pty.cc source')
}
writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, false))
assertPatchedNodePtyMasterCloexecSource(relayDir)
return true
}
function assertPatchedNodePtyMasterCloexecSource(relayDir = process.cwd()) {
const inspected = inspectNodePtyUnixSource(relayDir)
if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) {
throw new Error('node-pty pty master close-on-exec patch is not installed')
}
}
function revertNodePtyMasterCloexecSource(relayDir = process.cwd()) {
const inspected = inspectNodePtyUnixSource(relayDir)
if (sourceSha256(inspected.source) === ORIGINAL_SOURCE_SHA256) {
return false
}
writeSourceAtomically(inspected.sourcePath, rewriteSource(inspected.source, true))
return true
}
function rebuildNodePty(relayDir) {
const result = spawnSync('npm', ['rebuild', '--ignore-scripts=false', 'node-pty'], {
cwd: relayDir,
encoding: 'utf8',
timeout: REBUILD_TIMEOUT_MS,
windowsHide: true
})
if (result.error) {
throw new Error(`npm rebuild node-pty failed: ${result.error.message}`)
}
if (result.status !== 0) {
const tail = `${result.stdout || ''}${result.stderr || ''}`.trim().slice(-300)
throw new Error(`npm rebuild node-pty exited ${result.status ?? result.signal}: ${tail}`)
}
}
// Why a child: a bad build can abort the process on require, which would strand the
// moved-aside working build. Why the reachability check: a host without /proc cannot
// show inheritance, and an unobservable flag is not evidence the rebuild was wrong.
const VERIFY_SCRIPT = `
const pty = require(process.argv[1]);
const term = pty.spawn('/bin/sh', ['-c', 'exit 0'], {
name: 'xterm-256color', cols: 80, rows: 24, cwd: process.cwd(), env: process.env
});
const probe = require('node:child_process').spawnSync('/bin/sh', ['-c', 'ls -l /proc/self/fd'], { encoding: 'utf8' });
try { term.kill() } catch {}
const listing = probe.stdout || '';
if (probe.status !== 0 || !listing.includes('->')) { console.log('UNVERIFIED'); process.exit(0) }
console.log(listing.includes('ptmx') ? 'INHERITED' : 'ISOLATED');
process.exit(0);
`
/** 'isolated' when a later plain child no longer inherits the master, 'unverified' when /proc cannot say. */
function verifyMasterNotInheritedByLaterChild(relayDir) {
const result = spawnSync(process.execPath, ['-e', VERIFY_SCRIPT, nodePtyDir(relayDir)], {
cwd: relayDir,
encoding: 'utf8',
timeout: VERIFY_TIMEOUT_MS,
windowsHide: true
})
const output = `${result.stdout || ''}`
if (result.status !== 0 || result.error) {
const tail = `${output}${result.stderr || ''}`.trim().slice(-300)
throw new Error(
`rebuilt node-pty did not load: ${tail || result.error?.message || result.signal}`
)
}
if (output.includes('INHERITED')) {
throw new Error('rebuilt node-pty still leaks the pty master into later children')
}
return output.includes('ISOLATED') ? 'isolated' : 'unverified'
}
function rollback(relayDir, releaseDir, backupDir) {
rmSync(releaseDir, { recursive: true, force: true })
try {
revertNodePtyMasterCloexecSource(relayDir)
} catch {
// The build that is about to be restored predates the patch either way.
}
if (existsSync(backupDir)) {
mkdirSync(dirname(releaseDir), { recursive: true })
renameSync(backupDir, releaseDir)
}
}
/**
* Patch and rebuild the host's node-pty, or leave it exactly as found.
* Never throws: the caller is on the connect path and a leaky relay beats no relay.
*/
function applyNodePtyMasterCloexecPatch(relayDir = process.cwd(), options = {}) {
const platform = options.platform || process.platform
const rebuild = options.rebuild || rebuildNodePty
const verify = options.verify || verifyMasterNotInheritedByLaterChild
if (platform !== 'linux') {
return 'skipped:not-linux'
}
const skipMarkerPath = join(relayDir, SKIP_MARKER_FILENAME)
if (existsSync(skipMarkerPath)) {
return 'skipped:earlier-attempt-failed'
}
const releaseDir = join(nodePtyDir(relayDir), 'build', 'Release')
const backupDir = join(nodePtyDir(relayDir), BACKUP_DIRNAME)
// A backup stranded by a connection that died mid-rebuild is stale by definition:
// whatever repaired node-pty since built from the source now on disk.
rmSync(backupDir, { recursive: true, force: true })
let inspected
try {
inspected = inspectNodePtyUnixSource(relayDir)
} catch (err) {
return `skipped:${err.message}`
}
const hash = sourceSha256(inspected.source)
if (hash === PATCHED_SOURCE_SHA256) {
return 'already-patched'
}
if (hash !== ORIGINAL_SOURCE_SHA256) {
return 'skipped:unexpected-source'
}
// No compiled build means the host runs a prebuild or nothing at all; rebuilding
// could only take away the artifact the probe just proved loadable.
if (!existsSync(join(releaseDir, 'pty.node'))) {
return 'skipped:no-compiled-build'
}
try {
renameSync(releaseDir, backupDir)
} catch (err) {
return `skipped:${err.message}`
}
try {
patchNodePtyMasterCloexecSource(relayDir)
rebuild(relayDir)
const verdict = verify(relayDir)
rmSync(backupDir, { recursive: true, force: true })
return verdict === 'isolated' ? 'patched' : 'patched-unverified'
} catch (err) {
rollback(relayDir, releaseDir, backupDir)
// Bounded on purpose: one compile attempt per relay directory, never a retry loop.
try {
writeFileSync(skipMarkerPath, `${new Date().toISOString()} ${err.message}\n`)
} catch {
// A relay dir we cannot write to will fail the cheap checks above next time anyway.
}
return `failed:${err.message}`
}
}
if (require.main === module) {
console.log(`${STATUS_PREFIX}${applyNodePtyMasterCloexecPatch()}`)
}
module.exports = {
EXPECTED_NODE_PTY_VERSION,
ORIGINAL_SOURCE_SHA256,
PATCHED_SOURCE_SHA256,
SKIP_MARKER_FILENAME,
STATUS_PREFIX,
applyNodePtyMasterCloexecPatch,
assertPatchedNodePtyMasterCloexecSource,
patchNodePtyMasterCloexecSource,
revertNodePtyMasterCloexecSource
}
+524 -29
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-23",
"updatedAt": "2026-08-31",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -2493,30 +2493,31 @@
"https://github.com/stablyai/orca/issues/11298",
"https://github.com/stablyai/orca/pull/11300"
],
"invariant": "Every accepted runtime socket installs message, pong, close, and error ownership before any heartbeat probe. With uninterrupted timer delivery, the first socket that arms an idle heartbeat is probed immediately and an unresponsive socket is reaped within one interval. Later sockets join the existing shared cadence without another timer or immediate sweep and are reaped within two intervals. Responsive sockets survive, pause recovery grants a fresh probe, and close or error-to-close releases connection listeners and timers.",
"oracle": "With one fake clock and exact socket identities, accept the first socket at 0 ms and require an immediate owned probe plus reaping at 100 ms when unresponsive. Keep a responsive first socket, accept an unresponsive later socket at 50 ms, require the same shared timer, its first probe at 100 ms, no early reap, and termination at 200 ms. Inject synchronous message, pong, close, and error events, then require exact heartbeat membership and zero retained timers/listeners after final close. Production transport tests independently cover real socket round trips, pre-auth and capacity bounds, revocation, shutdown, and half-open cleanup.",
"invariant": "Every accepted runtime socket installs message, pong, close, and error ownership before any heartbeat probe. Unauthenticated sockets receive no heartbeat control frames during E2EE and are bounded by the pre-auth timeout; authenticated sockets share one periodic cadence, tolerate missed probes and event-loop pause, and release listeners and timers on close or error.",
"oracle": "With one fake clock and exact socket identities, accept an authenticated first socket at 0 ms and require its first probe on the 100 ms tick. Accept an unauthenticated later socket at 50 ms and require no probe at the 100 ms tick; authenticate it, then require its first probe on the next shared tick and termination only after the configured consecutive-miss budget. Inject synchronous message, pong, close, and error events, then require exact heartbeat membership and zero retained timers/listeners after final close. Production transport tests independently cover real socket round trips, pre-auth and capacity bounds, revocation, shutdown, and half-open cleanup.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts"
],
"testFiles": [
"src/main/runtime/rpc/ws-transport-accept-order.test.ts",
"src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts",
"src/main/runtime/rpc/ws-transport.test.ts"
"src/main/runtime/rpc/ws-transport.test.ts",
"src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/rpc/ws-transport-accept-order.test.ts",
"assertions": [
"the first synchronous probe observes message, pong, close, and error ownership",
"the first unresponsive socket is reaped at one interval",
"a later socket keeps the original shared timer, is first probed on the shared tick, and is reaped within two intervals",
"the first periodic probe observes message, pong, close, and error ownership",
"an authenticated unresponsive socket is reaped on the configured consecutive-miss budget",
"an unauthenticated later socket is not probed before authentication, then joins the original shared timer",
"final close releases heartbeat membership, listeners, and timers"
]
},
{
"file": "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts",
"assertions": [
"one missed probe reaps only the unresponsive client",
"a single missed probe does not reap the unresponsive client",
"event-loop resume grants clients a fresh probe"
]
},
@@ -2527,6 +2528,12 @@
"pre-auth, raw TCP, and accepted WebSocket resource bounds remain enforced",
"error and close races finalize membership once"
]
},
{
"file": "src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts",
"assertions": [
"an authenticated real WebSocket survives one swallowed pong and responds to later probes"
]
}
],
"evidenceRuns": [
@@ -2534,10 +2541,10 @@
"date": "2026-07-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts src/main/runtime/rpc/ws-transport-transient-packet-loss.test.ts",
"result": "passed",
"durationSeconds": 0.91,
"summary": "Three runtime transport files and 35 tests passed with deterministic first- and later-socket cadence, exact listener/timer ownership, pause recovery, security bounds, and cleanup."
"summary": "Four runtime transport files and 42 tests passed with deterministic authenticated heartbeat cadence, handshake grace for later sockets, exact listener/timer ownership, pause recovery, transient packet-loss tolerance, security bounds, and cleanup."
}
],
"runtimeBudget": {
@@ -2550,7 +2557,7 @@
},
"redGreenEvidence": {
"status": "complete",
"evidence": "On latest main and with the listener-order fix disabled, the first synchronous probe observed no message, pong, close, or error owner. The structural listener-order fix passed those assertions. The published delayed-first-sweep alternative missed the first-socket one-interval cleanup bound. A later-socket oracle now separately pins the intended shared cadence at a 100 ms first probe and 200 ms reap after acceptance at 50 ms."
"evidence": "On the candidate before this review fix, an authenticated first socket kept the shared timer alive while an unauthenticated socket accepted at 50 ms was pinged at the 100 ms tick; the new oracle failed. After filtering heartbeat clients to the authenticated transport map, the same byte-identical oracle passes: no pre-auth ping, first post-auth probe on the next shared tick, and bounded cleanup."
},
"performanceBudget": {
"required": true,
@@ -5722,6 +5729,284 @@
],
"demotionRule": "Keep experimental or demote if ownership cardinality flakes, duplicate replay reaches a second renderer, active metadata or authority moves to the wrong leaf, deep normalization regresses, or a supported provider bypasses normalization."
},
{
"id": "terminal-session.split-activation-ordering",
"title": "Terminal splits activate before inherited-CWD lookup settles",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-renderer-lifecycle",
"layer": "renderer-unit-and-local-transport",
"surfaces": [
"terminal pane split",
"inherited working directory",
"pre-connect terminal input",
"split close cleanup",
"pre-bind pane detach"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "local-daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "remote-runtime"],
"coverageNotes": "Deterministic renderer contracts hold CWD resolution behind an explicit promise, require the new pane to be created synchronously, and prove that close cancels the pending connection. A module-level stable-pane-key handoff preserves the exact CWD promise and bounded pre-connect input across whole-tab remounts, including a tab rehome between worktree buckets; stale owners are fenced, concrete PTY bind and definitive spawn failure clear the record, and explicit pane close discards it. Detach contracts reject cwd-pending and cwd-resolved deferred splits before PTY bind without mutation, carry resolved cwd for other unbound panes, and preserve persisted or live PTY handoff. Local IPC transport contracts exercise the real bounded pre-connect buffer and one live input FIFO across seeded and newly typed ordinary, acknowledged, and immediate writes, concurrent flushes, in-flight teardown, late spawn success or failure, attach failure, failed spawn, same-id reuse, stale-spawn retirement ownership, destroy, and mutable recovery metadata. The handoff registry is capped at 64 records for 15 seconds and shares the existing 1,024-entry/conservative UTF-16 input ceilings. A mocked direct-SSH authority-rotation contract proves a rejected stale spawn releases its deferred-CWD fence. The schema-v2 headful Electron benchmark records exact revision identity and attributes CWD request/settlement, PTY spawn request/result, bind, fixture unlock request/IPC write, fixture readiness, input, and first echo across 3 warmups and 20 measured cold-CWD cycles, requiring a distinct child PTY and observed child pty:exit before the next cycle. Remote-runtime coverage proves delegation remains host-owned; its host-delegated split path does not consume the local pre-connect input options, so remote-runtime input-remount replay and physical local-daemon, SSH, WSL, Linux, Windows, and folder-workspace latency journeys remain gaps.",
"motivatingLinks": ["https://github.com/stablyai/orca/commit/572ed1a8882"],
"invariant": "A terminal split creates and activates its renderer pane before an inherited-CWD lookup settles, starts its PTY only after the resolved directory is available, and cannot be externally detached while that deferred spawn remains unbound. A whole-tab remount or worktree rehome preserves the same stable pane's CWD promise and admitted local pre-connect bytes in order until a successor binds or the intent is definitively abandoned; stale owners cannot append or clear the successor's record. Other unbound panes preserve resolved cwd as startupCwd when detached. Bounded pre-connect input and later live local input share byte order, and teardown settles acknowledged writes without creating or rebinding a stale PTY. A disconnected or detached pending connect cannot bind its late fresh spawn, report its late failure through current callbacks, or ID-retire a newer same-ID owner; rejecting a stale direct-SSH spawn also releases the matching deferred-CWD fence. Natural exit cannot deliver queued work into a reused PTY id. Bound and remote-runtime splits remain owned by their execution host.",
"oracle": "Hold CWD resolution behind a controllable promise, invoke the production split path, and require manager.splitPane plus split telemetry before resolving it. Before PTY bind, require both cwd-pending and cwd-resolved deferred detach attempts to return null without layout, pane, tab, ownership, or focus mutation; separately require resolved cwd on an allowed unbound detach and unchanged persisted/live PTY adoption. Exercise the stable-pane handoff registry through repeated remounts and a worktree rehome, requiring the identical CWD promise, ordered ordinary/acknowledged/immediate seed replay, stale-owner fencing, 64-record/15-second bounds, and discard on bind, failure, or explicit close. Rotate a mocked direct-SSH authority while its delayed spawn is in flight, reject and disconnect the stale PTY claim, then require exactly one deferred-CWD cleanup when the delayed connect settles. At the local IPC PTY boundary, require zero connect calls while pending, the resolved CWD in spawn and local recovery metadata, one shared FIFO across seeded/new pre-connect and live ordinary/acknowledged/immediate input, prompt predecessor acknowledged-promise settlement on teardown, retirement of an unowned late fresh spawn, preservation of a newer same-ID owner, and zero stale delivery after failure, close, destroy, detach, natural exit, or same-id reuse. Capture callback exceptions must not change admission results. In visible Electron, press the real split shortcut for 3 warmup cycles, then after a cold inherited-CWD interval for each of 20 measured cycles, require an exact clean revision identity, complete focus/CWD/spawn/bind/fixture/input/echo attribution, distinct child PTYs, pane count, and child exits for every cycle; a timed-out, missing-event, or cleanup-aborted run must publish no headline latency and fail.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts tests/e2e/terminal-split-activation-latency-main-probe.ts tests/e2e/terminal-split-activation-latency-phases.ts tests/e2e/terminal-split-activation-latency-report.unit.test.ts --reporter=dot",
// Historical evidence record retained so its seven-file command remains auditable.
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts --reporter=dot",
// Historical evidence record retained so its nine-file command remains auditable.
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts --reporter=dot",
// Historical schema-v1 evidence records only; their labels do not verify the checkout or harness.
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
// Exact-HEAD schema-v1 evidence records use the committed harness but predate revision metadata.
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-current ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
// Schema-v2 evidence embeds the exact checkout identity and clean/dirty state.
"ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-073e6c7b0eb-headful-clean ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-073e6c7b0eb-headful-clean.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts",
"src/renderer/src/lib/pane-manager/pane-split-close.test.ts",
"src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts",
"src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
"src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts",
"src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts",
"tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts",
"tests/e2e/terminal-split-activation-latency-main-probe.ts",
"tests/e2e/terminal-split-activation-latency-phases.ts",
"tests/e2e/terminal-split-activation-latency-report.unit.test.ts",
"tests/e2e/terminal-split-activation-latency.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts",
"assertions": [
"creates and records the split before pending CWD resolution",
"passes the resolved CWD promise without reviving a stale manager",
"rapid nested splits reuse one pending CWD lookup",
"keeps remote-runtime split ownership on its execution host"
]
},
{
"file": "src/renderer/src/lib/pane-manager/pane-split-close.test.ts",
"assertions": ["focuses the new pane before publishing an unresolved CWD spawn hint"]
},
{
"file": "src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts",
"assertions": [
"preserves a deferred split fence when OSC 7 updates cwd",
"clears a settled deferred entry only for matching promise identity",
"keeps a newer deferred lookup when an older cleanup callback arrives"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts",
"assertions": [
"starts no PTY connection before inherited CWD resolves",
"applies the resolved directory to transport options",
"disposing the split before resolution cancels the pending spawn",
"invokes deferred-CWD cleanup exactly once after an authority-rotated direct-SSH spawn is disconnected and its delayed connect settles"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
"assertions": [
"rejects a deferred split while inherited CWD is pending without mutation",
"continues rejecting after CWD resolves until PTY bind",
"carries resolved CWD as startupCwd for an allowed unbound detach",
"preserves persisted and live remote PTY detach handoff"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts",
"assertions": [
"concurrent flush calls share one worker and preserve mixed input order",
"clear settles an in-flight acknowledged write before its late resolve or reject",
"in-flight acknowledged input remains charged to entry and code-unit caps"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts",
"assertions": [
"ordinary, acknowledged, and immediate pre-connect input flushes in byte order",
"pending acknowledged input settles on connect, destroy, and spawn failure",
"disconnect, destroy, and natural exit cancel an in-flight acknowledged write without blocking connect",
"live acknowledged input blocks later ordinary and immediate writes at its invocation position",
"the preconnect-to-live transition preserves the same input FIFO",
"disconnect and detach retire a late fresh spawn and suppress late failures before they reach current callbacks",
"natural exit fences queued ordinary and acknowledged chunks across same-id reuse",
"buffered exit and attach failure clear retained input",
"ordinary and acknowledged write failures drop later input without leaving promises pending",
"entry and code-unit ceilings bound pre-connect input retention",
"local recovery metadata observes the resolved split CWD",
"a stale fresh-spawn completion cannot retire a newer same-ID owner"
]
},
{
"file": "src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts",
"assertions": [
"hands the same cwd promise and buffered input to a remounted leaf in order",
"keeps input across repeated remounts and fences stale owners",
"releases an unmounted owner without dropping its pending handoff",
"retains input within the shared preconnect entry and code-unit caps",
"evicts the oldest handoff when the record cap is reached",
"expires an abandoned handoff after the bounded remount window"
]
},
{
"file": "tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts",
"assertions": [
"writes a passing benchmark report to the requested artifact path",
"fails when the benchmark artifact path cannot be written"
]
},
{
"file": "tests/e2e/terminal-split-activation-latency-main-probe.ts",
"assertions": [
"attributes CWD and PTY spawn request/settlement events to the source and child PTYs",
"captures the fixture unlock carriage return on both ordinary and acknowledged IPC channels",
"restores the intercepted IPC handlers and listener when the probe is disposed"
]
},
{
"file": "tests/e2e/terminal-split-activation-latency-phases.ts",
"assertions": [
"merges main-process events by operation and PTY identity without cross-cycle attribution",
"requires every activation, fixture, input, echo, pane, PTY, and cleanup observation for success",
"reports each attributed phase distribution with non-negative cross-clock durations"
]
},
{
"file": "tests/e2e/terminal-split-activation-latency-report.unit.test.ts",
"assertions": [
"attributes main-process phases to the matching source and child PTYs",
"embeds schema-v2 revision identity and summarizes the attributed phases",
"invalidates a sample when the actual fixture-unlock IPC write is missing"
]
},
{
"file": "tests/e2e/terminal-split-activation-latency.spec.ts",
"assertions": [
"requires a visible BrowserWindow and visible document before sampling",
"records schema-v2 revision identity plus attributed CWD, spawn, bind, fixture-ready, input, and echo phases",
"records 3 warmups, then 20 measured real-shortcut cycles after cold inherited-CWD intervals",
"requires every split to focus, bind a PTY distinct from its source, and echo immediate input",
"observes each closed child PTY exit before starting the next cycle",
"publishes headline latency only for a fully successful 3-warmup/20-measured run"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-30",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts --reporter=dot",
"durationSeconds": 56.59,
"summary": "Seven focused files and 78 tests passed. Vitest reported 49.92 seconds and the measured wall time was 56.59 seconds; coverage includes split creation and focus ordering, nested CWD lineage, promise-identity and SSH authority-rotation cleanup fencing, full pre-bind detach fencing, resolved-CWD detach handoff, close-cancellation, single-FIFO ordering, late-spawn retirement and error suppression, preservation of a newer same-ID owner, generation fencing, in-flight settlement across explicit teardown and natural exit, attach cleanup, bounded retention, and existing input-write contracts."
},
{
"date": "2026-08-31",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts --reporter=dot",
"durationSeconds": 44.43,
"summary": "Nine focused files and 96 tests passed. Vitest reported 37.78 seconds and measured wall time was 44.43 seconds; the run adds stable-pane CWD/input handoff, repeated-remount stale-owner fencing, bounded 64-record/15-second retention, seeded-input caps, ordered ordinary/acknowledged/immediate replay, predecessor acknowledged-promise settlement, capture-callback failure containment, and benchmark-artifact write-failure coverage to the existing split, detach, CWD, and local transport contracts."
},
{
"date": "2026-08-31",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts src/renderer/src/lib/pane-manager/pane-split-close.test.ts src/renderer/src/components/terminal-pane/resolve-split-cwd.test.ts src/renderer/src/components/terminal-pane/pty-connection-split-cwd-resolution.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/pty-preconnect-input-buffer.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/renderer/src/components/terminal-pane/deferred-split-pane-handoff.test.ts tests/e2e/terminal-split-activation-latency-artifact.unit.test.ts tests/e2e/terminal-split-activation-latency-main-probe.ts tests/e2e/terminal-split-activation-latency-phases.ts tests/e2e/terminal-split-activation-latency-report.unit.test.ts --reporter=dot",
"durationSeconds": 4.14,
"summary": "The updated twelve-path focused command passed 99 tests (10 runnable test files plus 2 benchmark support modules), including schema-v2 main-process phase attribution, report revision identity, fixture IPC-write validation, stable-pane handoff, ordered pre-connect/live input, cleanup, detach, failure, and same-ID ownership contracts."
},
{
"date": "2026-08-30",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-baseline-df14d1a2983d8339e788d0e521f1c4affd9c6d5f-headful-run1.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"durationSeconds": 72,
"summary": "At baseline df14d1a2983d8339e788d0e521f1c4affd9c6d5f, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 65.7/88.7/102.6 ms, PTY bind was 122.8/154.0/159.7 ms, and first echo was 203.8/264.2/332.7 ms. Artifact SHA-256: 6d860cd0cd210f55f2349a197318248af488042117b20c08b6831160950c3277."
},
{
"date": "2026-08-30",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-candidate-d453ffcdb704764daced1b2917fddee7224389f0-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"durationSeconds": 72,
"summary": "At candidate d453ffcdb704764daced1b2917fddee7224389f0, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 12.8/14.4/16.5 ms, PTY bind was 177.0/316.1/333.3 ms, and first echo was 268.6/627.4/710.7 ms. Artifact SHA-256: 9aef7fa842c732eb74f0066a77b2a0336e8f956a06ca61387f9999d6e76213cc."
},
{
"date": "2026-08-31",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-current ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"durationSeconds": 72,
"summary": "At exact HEAD 962faacec8c, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 12.6/13.7/13.7 ms, PTY bind was 140.5/399.1/464.1 ms, and first echo was 192.0/519.3/2343.1 ms. Artifact SHA-256: 875e9d37dc711472a81438e4bbdbc8cbc7aada8c961d14195c024d8da350b9e2."
},
{
"date": "2026-08-31",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-962faacec8c-headful-run2 ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-962faacec8c-headful-run2.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"durationSeconds": 72,
"summary": "At exact HEAD 962faacec8c, the visible BrowserWindow and document completed 3/3 warmups followed by 20/20 measured cold-CWD cycles with every event present, distinct child PTYs, and observed child exits. Shortcut-to-focus p50/p95/max was 12.8/14.0/14.3 ms, PTY bind was 236.5/654.0/687.3 ms, and first echo was 566.8/1191.5/1219.7 ms. Artifact SHA-256: 4f66b93e5c936ade05f880010f4ec027385d5c89893430169af91c7fdfbe1d06."
},
{
"date": "2026-08-31",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "ORCA_TERMINAL_SPLIT_LATENCY_BENCH=1 ORCA_TERMINAL_SPLIT_LATENCY_LABEL=candidate-073e6c7b0eb-headful-clean ORCA_TERMINAL_SPLIT_LATENCY_OUTPUT=/private/tmp/orca-terminal-split-activation-073e6c7b0eb-headful-clean.json pnpm exec playwright test tests/e2e/terminal-split-activation-latency.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"durationSeconds": 87.6,
"summary": "At exact clean HEAD 073e6c7b0eb1c0ccbb115db1528b641901997c73, the schema-v2 artifact records dirty=false, a visible BrowserWindow/document, and 3/3 warmups plus 20/20 measured cycles with every missing-event counter at zero, distinct child PTYs, and observed child exits. Measured focus p50/p95/max was 12.0/13.6/14.7 ms; attributed CWD lookup was 40/97/103 ms, CWD-settle to spawn request 1/4/4 ms, spawn request to result 48/120/122 ms, and spawn result to bind 2.1/2.5/2.5 ms. Fixture unlock request to IPC write was 0.5/0.8/0.9 ms, IPC write to fixture-ready parse was 35.1/87.4/141.8 ms, and input to first echo was 1.3/3.5/3.9 ms. Total shortcut-to-bind was 113.5/161.3/162.7 ms and shortcut-to-first-echo was 158.5/240.7/291.2 ms. Artifact SHA-256: 1e7ff9e658b717056273d64ecdf662cc6b5776bb6dae1827ed213b7647e4a5fb."
}
],
"evidenceProcedure": "Run the benchmark spec in a visible macOS Electron project from a clean primary worktree, complete 3 warmups followed by 20 measured cold-CWD cycles, require zero missing events and successful cleanup, save the schema-v2 JSON report, and record its SHA-256 plus embedded revision identity. The four older records are schema-v1 historical audit records whose labels do not verify the checkout or include phase attribution; the clean schema-v2 record is the current candidate evidence. A paired baseline/final rerun with one revision-verifying harness remains required before promotion or a readiness comparison claim.",
"runtimeBudget": {
"p95Seconds": 240,
"scope": "the full listed gate command set: one current twelve-path focused invocation (ten runnable test files plus two benchmark support modules), one historical nine-file unit invocation, one historical seven-file unit invocation, and five opt-in 3-warmup/20-measured visible Electron benchmark invocations (four schema-v1 historical records plus one clean schema-v2 record)"
},
"flakeHistory": {
"status": "not-started",
"evidence": "The focused promise-barrier, remount-handoff, benchmark-artifact, and schema-v2 attribution suite passes locally in 99 tests; the clean visible benchmark passes 3/3 warmups and 20/20 measured cycles with zero missing events. Its first attempt hit a transient warmup cleanup-dialog click timeout, then the exact command passed on retry without a launch, profile, or port workaround. Routed CI and soak history have not started. Historical benchmark labels do not verify the product checkout or embed revision identity."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Before the production seam landed, the split assertion failed with zero manager calls while CWD was pending, and the transport assertion rejected the first pre-connect input. Before the detach fence, a deferred split could be removed after CWD resolved but before PTY bind, dropping its pre-connect input. Before the single-flight hardening, the concurrent-flush oracle delivered ordinary input before the earlier acknowledged write and clear left the in-flight promise pending. Before stale-spawn ownership fencing, the combined deferred-connect and newer same-ID attach fixture called kill on the current PTY. An intentional one-line revert of deferred-CWD cleanup in the stale direct-SSH claim branch failed its focused callback assertion with zero calls instead of one; restoring it passed the prior focused tests. The remount-handoff, transport, artifact-write, and schema-v2 attribution regressions are green in the 99-test twelve-path run, but isolated intentional-revert evidence for each cleanup branch remains outstanding."
},
"performanceBudget": {
"required": true,
"evidence": "Pane creation and focus add no timer, polling, provider inventory, or subprocess work. CWD resolution remains one existing bounded request off the visible activation path, and detach admission adds only bounded map and record lookups. The remount handoff adds one module-level map lookup per pane lifecycle, a 64-record cap, and a 15-second expiry; it retains no unbounded payload. Pre-connect input, including an in-flight acknowledged write and remount seed replay, is capped at 1,024 entries and a conservative UTF-16 ceiling derived from the existing terminal-input byte limit, drains through one worker in order, and clears on teardown or failed connect. The historical schema-v1 same-mode pair recorded shortcut-to-focus p50/p95/max changing from 65.7/88.7/102.6 ms to 12.8/14.4/16.5 ms; those labels do not embed revision identity, so the comparison is directional evidence only. The clean schema-v2 candidate attributes focus at 12.0/13.6/14.7 ms while CWD lookup takes 40/97/103 ms and spawn request-to-result takes 48/120/122 ms, demonstrating that provider/process startup follows activation rather than blocking it. In that clean run, shortcut-to-bind is 113.5/161.3/162.7 ms, fixture IPC-write-to-ready is 35.1/87.4/141.8 ms, and input-to-echo is 1.3/3.5/3.9 ms; these readiness phases are diagnostic, one-host descriptive measurements, and no clean schema-v2 baseline exists to support a readiness improvement or regression claim. The n=20 empirical p95 values are descriptive, are not a distribution guarantee, and are not CI-enforced."
},
"promotionCriteria": [
"Record complete red/green evidence for close, remount/rehome handoff, mixed-input ordering, metadata, and failure cleanup.",
"Collect 100 consecutive focused CI passes or 14 days without an unexplained flake.",
"Run the committed real-shortcut Electron benchmark in routed CI or soak before enforcing a latency budget.",
"Collect physical local-daemon, SSH or WSL plus Linux, Windows, and folder-workspace evidence before claiming provider-complete coverage."
],
"knownGaps": [
"The clean schema-v2 candidate run and the historical schema-v1 comparison records ran on one Apple-silicon macOS host with a synthetic POSIX echo shell and a git-backed workspace; their n=20 empirical p95 values are descriptive and not CI-enforced.",
"No physical local-daemon, SSH, WSL, Linux, Windows, or folder-workspace latency journey has run; the synthetic fixture is currently skipped on Windows because it requires a POSIX shell.",
"Remote-runtime split creation remains host-delegated and its transport does not consume the local pre-connect seed/capture options; CWD handoff is covered, but remote-runtime pre-connect input replay has no implementation or evidence.",
"The four stored schema-v1 artifacts predate the final harness attribution/reporting and do not embed revision identity; the clean schema-v2 candidate artifact is revision-verified, but both product revisions still need a paired schema-v2 rerun with one committed harness before promotion.",
"No forced-failure visible benchmark artifact has been recorded; the focused artifact-write and missing-event report contracts verify local failure handling, while failure-report serialization remains unverified by a full visible run.",
"No clean schema-v2 baseline phase artifact exists, so the attributed CWD, spawn, fixture-ready, bind, and echo timings diagnose where time is spent but do not establish a shell-readiness improvement or regression."
],
"demotionRule": "Keep experimental or demote if pane activation waits on CWD, a deferred split can detach before PTY bind, a remount or rehome loses its stable CWD/input handoff, stale owners mutate a successor record, detached cwd is lost, input reorders or remains pending after cleanup, a closed pane can spawn, stale retirement kills a newer same-ID owner, remote-runtime delegation creates a competing local pane, or the focused suite flakes without an identified product or harness cause."
},
{
"id": "terminal-session.kill-all-surface-cleanup",
"title": "Kill all sessions removes only the confirmed terminal surfaces and current bindings",
@@ -8194,9 +8479,7 @@
"assertionRefs": [
{
"file": "tests/e2e/persisted-session-production-upgrade.spec.ts",
"assertions": [
"upgrades a legacy daemon session and keeps it stable after relaunch"
]
"assertions": ["upgrades a legacy daemon session and keeps it stable after relaunch"]
}
],
"evidenceRuns": [
@@ -13580,6 +13863,90 @@
"knownGaps": ["No manifest command yet.", "No Windows CJK/emoji repaint command is wired."],
"demotionRule": "Cannot promote if the oracle is screenshot-only or environment-skipped."
},
{
"id": "terminal-render.foreground-repair-span",
"title": "A forced foreground repaint covers every row the write changed",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-rendering",
"layer": "renderer-unit",
"surfaces": [
"foreground PTY output",
"in-place agent redraws",
"erase-in-line/display",
"alternate screen",
"scroll",
"wide glyphs"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": [],
"coverageNotes": "Renderer-unit convergence corpus over a real xterm parser, plus manual CDP pixel evidence on the macOS WebGL renderer. The repaint span is provider-independent because it is computed from xterm's parse, not from the transport; SSH/WSL/remote were not exercised live.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/2669",
"https://github.com/stablyai/orca/pull/4669",
"https://github.com/stablyai/orca/pull/8178"
],
"invariant": "The row span Orca asks xterm to repaint after a forced foreground refresh must cover every viewport row whose rendered content changed during that write, plus the cursor row before and after it; when the span cannot be established — unobservable parse, viewport scroll, or a normal/alternate buffer flip — the whole viewport must be repainted.",
"oracle": "A real @xterm/headless parser replays an adversarial corpus (in-place bottom-row redraws, standalone CR overwrite, backspace, erase-in-line, erase-in-display above and below the cursor, full clear, wide CJK, emoji, combining marks, ZWJ sequences, scroll-region insert/delete, reverse index, DEC 2026 frames, alternate-screen enter and exit, viewport scroll, narrow panes). Each viewport row is serialized cell-by-cell with its attributes before and after the write, and every row that differs must fall inside the span the settle path requested. A vacuity guard asserts each case actually moves the screen.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts"
],
"testFiles": [
"src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts",
"assertions": [
"every viewport row whose serialized cells changed lies inside the requested repaint span",
"the cursor row before and after the write is inside the requested repaint span",
"viewport scroll and alternate-screen transitions still request the whole grid",
"an unobservable parse span falls back to the whole grid",
"an in-place bottom-row redraw narrows well below the full grid"
]
}
],
"evidenceRuns": [
{
"date": "2026-09-02",
"runner": "local",
"platform": "macos",
"result": "passed",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts",
"durationSeconds": 1,
"summary": "25 cases passed against a real xterm parser; paired CDP run on a 4-pane macOS WebGL dev build produced screenshots byte-identical to a forced full model rebuild."
}
],
"runtimeBudget": {
"p95Seconds": 15,
"scope": "Renderer-unit convergence corpus"
},
"flakeHistory": {
"status": "not-started",
"evidence": "New deterministic gate; no soak history yet."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "Narrowing the span to the cursor rows alone (dropping xterm's parse span) fails the claude-style in-place redraw and erase-in-display-above cases; reading buffer indices instead of viewport rows made the corpus vacuous and is now blocked by the changed-row guard."
},
"performanceBudget": {
"required": true,
"evidence": "Measured on a focused, visible 4-pane macOS dev build under an agent-style in-place redraw load: rendered cells/s 157,708 -> 30,139 and forEachDecorationAtCell 320,868/s -> 60,652/s with render frames/s unchanged (59.8 -> 60.5)."
},
"promotionCriteria": [
"Add Windows DOM-renderer coverage for the synchronous repair branch.",
"Wire pixel or cell evidence for the alternate-screen and reflow paths into CI rather than manual CDP runs.",
"Keep a full-grid fallback assertion for every new span-narrowing condition."
],
"knownGaps": [
"No CI-wired pixel oracle; WebGL convergence evidence was collected manually over CDP.",
"Windows ConPTY synchronous repair path is covered only by the shared corpus, not on a Windows runner.",
"SSH/WSL/remote providers were not exercised live; the span is transport-independent by construction."
],
"demotionRule": "Demote or block if a narrowing condition is added without a matching convergence case, if the corpus stops asserting that each case changes at least one row, or if a repaint regression is reported for in-place agent redraws."
},
{
"id": "terminal-shell.windows-resolution-parity",
"title": "Windows local and daemon providers resolve shells and startup commands consistently",
@@ -13970,14 +14337,14 @@
"providers": ["local", "daemon", "ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh"],
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, one-reply-per-write delivery for OSC, DA1, and CPR replies, real xterm OSC reply generation, drain-failure containment, and clear/reuse generation fencing. Remote-runtime tests preserve separate query-reply writes across pending input, async validation, and viewport-claim buffering. Host-contract tests prove a later DA1/CPR reply cannot overtake a deferred OSC reply, including a coalesced legacy-client payload. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves an upstream-node-pty Linux relay keeps OSC/DA1 replies out of the next fish child's stdin. No live daemon, paired-runtime, WSL, physical Linux/Windows client, or binary mixed-version run is registered.",
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, acknowledged-write FIFO barriers, single-worker drain reentrancy, one-reply-per-write delivery for OSC, DA1, and CPR replies, real xterm OSC reply generation, drain-failure containment, teardown settlement, and clear/reuse generation fencing. Remote-runtime tests preserve separate query-reply writes across pending input, async validation, and viewport-claim buffering. Host-contract tests prove a later DA1/CPR reply cannot overtake a deferred OSC reply, including a coalesced legacy-client payload. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves an upstream-node-pty Linux relay keeps OSC/DA1 replies out of the next fish child's stdin. No live daemon, paired-runtime, WSL, physical Linux/Windows client, or binary mixed-version run is registered.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/13137",
"https://github.com/stablyai/orca/issues/7329",
"https://github.com/stablyai/orca/issues/13892"
],
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, and the host writes each reply the moment it accepts it, so replies reach the PTY in the order they were produced with no queue that could reorder them. A reply's own echo is contained on the output side by projecting its known echo shapes; the ESC-initial verbatim shape is matched only when complete, never held as a partial, so a query torn at its own ESC is still answered. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and drain failures cannot clear a newer queue generation.",
"oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending terminal query replies and 4096 UTF-16 code units. Every retained reply reaches the provider as one atomic write, and ordinary, acknowledged, and reply input share one invocation-ordered FIFO so no later write overtakes an acknowledged write. Reentrant write callbacks cannot start a second drain worker or strand input admitted after clear/reuse. A reply's own echo is contained on the output side by projecting its known echo shapes; the ESC-initial verbatim shape is matched only when complete, never held as a partial, so a query torn at its own ESC is still answered. Overflow removes only the oldest query replies, never ordinary input except the documented modified-F3/CPR byte collision, and failure or teardown cannot clear a newer queue generation or strand an acknowledged promise.",
"oracle": "Synchronously enqueue separate 10,000-entry OSC and DA1 reply floods before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. Stall an acknowledged write between earlier and later ordinary/reply input, then require teardown to settle it and same-id reuse to receive no stale tail. Reenter the queue synchronously from an acknowledged write with both enqueue and clear/reuse, requiring one drain and fresh input delivery only after the stale acknowledged write settles false. At the host boundary, defer an OSC reply and assert that separate or legacy-coalesced DA1/CPR replies flush after it in observed query order. At the remote-runtime boundary, preserve separate writes around pending ordinary input, async validation, and viewport-claim buffering. Repeat behind 10,000 ordinary inputs and exercise the text ceiling, real xterm generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-query-reply-immediate.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-input-coalescing.test.ts",
@@ -14009,14 +14376,20 @@
"real xterm OSC 10/11 query handlers remain subject to the same retention ceiling",
"retained OSC, DA1, and CPR replies stay one provider write each and both OSC and DA1 floods remain bounded",
"provider-write and yield failures settle without unhandled rejection, repeated same-generation admission, or stale-generation clearing",
"clear releases saturated reply accounting and fences in-flight validation before later input"
"clear releases saturated reply accounting and fences in-flight validation before later input",
"acknowledged input is serialized between earlier and later ordinary/reply writes",
"clear settles active and pending acknowledged input before same-id queue reuse",
"reentrant enqueue cannot start a second drain worker past an unacknowledged write",
"reentrant clear captures the stale cancellation and continues draining fresh input"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts",
"assertions": [
"sendInputImmediate applies the reply ceiling while sendInput preserves a reply-shaped ordinary payload in exact IPC write order",
"a thrown renderer write triggers one owning-transport recovery callback and rejects later input in that queue generation"
"a thrown renderer write triggers one owning-transport recovery callback and rejects later input in that queue generation",
"live and preconnect acknowledged writes remain FIFO barriers for later ordinary and immediate input",
"disconnect, detach, and natural exit settle acknowledged writes and fence same-id stale chunks"
]
},
{
@@ -14072,13 +14445,13 @@
],
"evidenceRuns": [
{
"date": "2026-08-25",
"date": "2026-08-30",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport-input-write.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts src/shared/pty-startup-reply-echo-shapes.test.ts",
"result": "passed",
"durationSeconds": 1.05,
"summary": "Five files and 92 tests passed, including the live-pty echo-shape transcript, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing."
"durationSeconds": 15,
"summary": "Five files and 149 tests passed in 15.16 seconds, including explicit IPC reply-source routing, the 10,000-reply count and text ceilings, 10,000-entry ordinary backlog preservation, acknowledged-write FIFO barriers, synchronous reentrancy fencing, prompt teardown settlement, same-id generation fencing, real xterm OSC query floods, single-shot drain-failure recovery, and one-reply-per-write echo containment."
},
{
"date": "2026-08-09",
@@ -14127,7 +14500,7 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Without the branch's admission cap, the 10,000-reply fixture writes all replies before the trailing keystroke. Before the source-routing repair, the queue had no API capable of distinguishing reply-shaped ordinary input; before failure containment, a thrown provider write rejected waitForDrain and Vitest recorded an unhandled rejection; the first containment pass retried a failed generation and invoked recovery twice; before generation fencing, a rejected stale yield cleared fresh input. No saved intentional-break artifact is attached yet."
"evidence": "Without the branch's admission cap, the 10,000-reply fixture writes all replies before the trailing keystroke. Before the source-routing repair, the queue had no API capable of distinguishing reply-shaped ordinary input; before failure containment, a thrown provider write rejected waitForDrain and Vitest recorded an unhandled rejection; the first containment pass retried a failed generation and invoked recovery twice; before generation fencing, a rejected stale yield cleared fresh input. Before reentrancy fencing, a synchronous accepted-write callback started a second drain that falsely accepted the pending write; clear/reuse also captured the replacement generation's cancellation and stranded fresh input. No saved intentional-break artifact is attached yet."
},
"performanceBudget": {
"required": true,
@@ -16412,16 +16785,17 @@
"providers": ["local-daemon"],
"coveredPlatforms": ["linux"],
"coveredProviders": ["local-daemon"],
"coverageNotes": "An Ubuntu 26.04 amd64 container extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, then exercises terminal-style foreground-process-group SIGINT and the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same foreground AppRun identity contract.",
"coverageNotes": "An Ubuntu 26.04 amd64 container first launches the original AppImage through dbus-run-session and xvfb-run with startup diagnostics, then extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, and exercises terminal-style foreground-process-group SIGINT plus the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same startup and foreground-AppRun identity contracts.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/14109",
"https://linear.app/stably/issue/STA-4051"
],
"invariant": "After packaged foreground headless serve publishes structured readiness, one SIGINT or SIGTERM exits successfully without an Electron fatal trap or core evidence, releases the exact listener and owned Xvfb/process tree, and leaves an unrelated process identity untouched.",
"oracle": "For each signal, start a fresh unprivileged Ubuntu 26.04 container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.",
"oracle": "First start the original, readable-and-executable AppImage once in a fresh restricted Ubuntu 26.04 container through dbus-run-session -- xvfb-run -a --appimage-extract-and-run with ORCA_STARTUP_DIAGNOSTICS=1, and require the exact updater-setup-done marker within 90 seconds while fencing the launcher and owned Xvfb by PID start ticks. For each signal, start a separate unprivileged container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/startup/ensure-virtual-display.test.ts config/scripts/headless-serve-shutdown-workflow.test.mjs --reporter=dot",
"shellcheck config/docker/headless-serve-shutdown/run-signal-case.sh",
"shellcheck config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh",
"node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage",
"node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64"
],
@@ -16429,7 +16803,8 @@
"src/main/startup/ensure-virtual-display.test.ts",
"config/scripts/headless-serve-shutdown-workflow.test.mjs",
"config/scripts/run-headless-serve-shutdown-docker.mjs",
"config/docker/headless-serve-shutdown/run-signal-case.sh"
"config/docker/headless-serve-shutdown/run-signal-case.sh",
"config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh"
],
"assertionRefs": [
{
@@ -16446,15 +16821,19 @@
"file": "config/scripts/headless-serve-shutdown-workflow.test.mjs",
"assertions": [
"PR CI builds an x64 AppImage before invoking the packaged shutdown oracle",
"the original AppImage desktop startup oracle is wired before extraction and signal cases",
"the bound AppImage is readable and executable before desktop launch and extraction",
"the documented systemd unit uses KillMode=mixed so graceful TERM targets Orca before its owned Xvfb"
]
},
{
"file": "config/scripts/run-headless-serve-shutdown-docker.mjs",
"assertions": [
"the original AppImage startup runs through dbus-run-session and xvfb-run with a bounded diagnostics marker",
"SIGINT and SIGTERM run in separate disposable containers",
"both signal failures are reported before the oracle exits",
"the exact AppImage SHA-256, entrypoint, and signal target are published",
"the read-only AppImage bind is checked for read and execute permissions before extraction",
"the launcher exec overlay isolates the related STA-4017 signal boundary"
]
},
@@ -16465,6 +16844,14 @@
"SIGTERM reaches the exact AppRun PID under the documented systemd KillMode=mixed policy",
"target and descendant identities are fenced by PID start ticks before signaling and residue checks"
]
},
{
"file": "config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh",
"assertions": [
"the original AppImage emits the exact updater-setup-done startup marker within 90 seconds",
"launcher and owned Xvfb identities are fenced by PID start ticks",
"cleanup sends bounded TERM then KILL signals and preserves failure logs"
]
}
],
"evidenceRuns": [
@@ -16485,11 +16872,20 @@
"result": "passed",
"durationSeconds": 48,
"summary": "The extracted candidate AppRun passed process-group SIGINT and systemd-mixed main-PID SIGTERM under Ubuntu 26.04 amd64 emulation with status zero, no fatal evidence, full listener/Xvfb/tree cleanup, and an unchanged canary identity."
},
{
"date": "2026-08-31",
"runner": "ci",
"platform": "linux",
"command": "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64",
"result": "passed",
"durationSeconds": 1336,
"summary": "Native-amd64 PR package job https://github.com/stablyai/orca/actions/runs/33360129768/job/99389831915 built AppImage SHA-256 999d43bfe123e87a77fe917a5f46be1efd5c45a5205f99f02998a75136d8a793 and ran the restricted original-AppImage startup oracle before each of the three signal matrices. Each startup reached the exact updater-setup-done marker with stable launcher/Xvfb PID-start-tick identities and bounded TERM/KILL cleanup; SIGINT and SIGTERM then returned wait status 0 with no fatal evidence, listener, descendant, Xvfb, or canary residue. This is CI evidence only; no fresh local Docker oracle is claimed."
}
],
"runtimeBudget": {
"p95Seconds": 240,
"scope": "two fresh Ubuntu 26.04 containers, one per foreground signal"
"p95Seconds": 1800,
"scope": "three AppImage startup/extraction matrices, each with fresh Ubuntu 26.04 INT and TERM containers"
},
"flakeHistory": {
"status": "not-started",
@@ -16516,6 +16912,105 @@
],
"demotionRule": "Keep experimental or demote if either signal traps, returns nonzero, retains its listener/Xvfb/run-owned process identity, touches the unrelated canary, or the focused gate flakes without an identified product or harness defect."
},
{
"id": "runtime.linux-cli-launch-contract",
"title": "Packaged Linux CLI commands run without FUSE, user namespaces, or a display",
"maturity": "experimental",
"protection": "partial",
"owner": "runtime-platform",
"layer": "appimage-cli-entrypoint",
"surfaces": [
"packaged Linux AppImage",
"bundled CLI launcher",
"extracted direct binary",
"desktop launch diagnosis"
],
"platforms": ["linux"],
"providers": ["local-daemon"],
"coveredPlatforms": ["linux"],
"coveredProviders": ["local-daemon"],
"coverageNotes": "A restricted Ubuntu container stages the extracted AppImage payload with no /dev/fuse and with unprivileged user namespaces denied, then runs eight CLI cases across the bundled launcher and the extracted direct binary. x64 only, because PR CI builds only --x64.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/13719",
"https://github.com/stablyai/orca/issues/14229"
],
"invariant": "On a host without FUSE and without unprivileged user namespaces, every packaged CLI entrypoint either completes its command or reports a diagnosis, and never terminates on a signal.",
"oracle": "Build the image, stage the AppImage payload, and run each case in the restricted container. Assert the preconditions first: unshare -Ur must fail and /dev/fuse must be absent, so a relaxed runner fails the job rather than silently skipping. For each case require the exact expected exit status and an expected substring of the command's own output, with the harness RESULT/CRASHED/PRECONDITION_FAILED control lines excluded so a case name can never satisfy its own assertion. Any status of 128 or above is a crash and fails immediately. The per-case timeout is a failure deadline, never a success condition.",
"commands": [
"node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage",
"shellcheck config/docker/cli-launch-contract/run-cli-case.sh"
],
"testFiles": [
"config/scripts/run-linux-cli-launch-contract-docker.mjs",
"config/docker/cli-launch-contract/run-cli-case.sh"
],
"assertionRefs": [
{
"file": "config/scripts/run-linux-cli-launch-contract-docker.mjs",
"assertions": [
"the bundled launcher serves --help, --version, status, skills --help, and worktree list without Chromium",
"a direct binary launch reaching JavaScript runs the command instead of booting a GUI",
"a desktop launch with no display reports the missing-display diagnosis instead of trapping",
"a stale DISPLAY is diagnosed rather than trusted",
"expected output is matched against the command's own output, not the harness control lines"
]
},
{
"file": "config/docker/cli-launch-contract/run-cli-case.sh",
"assertions": [
"the container refuses to run unless unprivileged user namespaces are denied and /dev/fuse is absent",
"an exit status of 128 or above is reported as a crash rather than compared to the expected status"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-31",
"runner": "ci",
"platform": "linux",
"command": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage",
"result": "passed",
"durationSeconds": 40,
"summary": "PR package job https://github.com/stablyai/orca/actions/runs/33360129768/job/99389831915 ran all eight cases to ok on ubuntu-latest. The unshare and /dev/fuse preconditions held under moby's default seccomp profile rather than tripping."
},
{
"date": "2026-09-01",
"runner": "local",
"platform": "linux",
"command": "node config/scripts/run-linux-cli-launch-contract-docker.mjs --appimage dist/orca-linux.AppImage",
"result": "passed",
"durationSeconds": 60,
"summary": "All eight cases passed on Ubuntu 24.04 amd64 hardware against an AppImage built from the stack tip, invoked against a copy of that artifact outside dist/."
}
],
"runtimeBudget": {
"p95Seconds": 300,
"scope": "eight CLI launch cases in one restricted Ubuntu container"
},
"flakeHistory": {
"status": "not-started",
"evidence": "The harness is new; soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The same harness run against a stock release AppImage failed four of eight cases: nofuse-userns-bundled-version at status 93, and all three direct-binary cases crashed at status 133 (SIGTRAP, the uv_close abort of #13719 and #14229). The stack-tip AppImage passed all eight. Corroborated at artifact level: the stack-tip runtime is a static-pie ELF with no PT_INTERP, the stock runtime is dynamically linked. Caveat: the two skills cases previously asserted a substring that the harness's own RESULT line contained, so they passed independently of command output; both now assert the rendered help header, and the green runs above predate that change."
},
"performanceBudget": {
"required": false,
"evidence": "The gate is CI-only and adds no product code path."
},
"promotionCriteria": [
"Collect 30 consecutive CI passes or 14 days without an unexplained flake.",
"Extend the matrix to arm64 once PR CI builds that architecture.",
"Re-run red/green against a stock AppImage after any change to the launcher entrypoint."
],
"knownGaps": [
"The preconditions depend on moby's default seccomp profile denying unshare(CLONE_NEWUSER) and on /dev/fuse being absent. A runner with a relaxed profile or a mounted /dev/fuse trips PRECONDITION_FAILED and fails the job rather than skipping.",
"x64 only: PR CI builds only --x64, so the arm64 launcher path is unexercised.",
"The harness covers CLI entrypoints only; it does not exercise a full desktop session."
],
"demotionRule": "Keep experimental or demote if any case terminates on a signal, the preconditions stop holding on the CI runner, or an assertion can be satisfied by anything other than the command's own output."
},
{
"id": "ssh-managed-hooks.node18-runtime-compatibility",
"title": "SSH managed-hook companions load and install hooks on Node 18",
@@ -0,0 +1,799 @@
/**
* Copyright (c) 2012-2015, Christopher Jeffrey (MIT License)
* Copyright (c) 2017, Daniel Imms (MIT License)
*
* pty.cc:
* This file is responsible for starting processes
* with pseudo-terminal file descriptors.
*
* See:
* man pty
* man tty_ioctl
* man termios
* man forkpty
*/
/**
* Includes
*/
#define NODE_ADDON_API_DISABLE_DEPRECATED
#include <napi.h>
#include <assert.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <thread>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <signal.h>
/* forkpty */
/* http://www.gnu.org/software/gnulib/manual/html_node/forkpty.html */
#if defined(__linux__)
#include <pty.h>
#elif defined(__APPLE__)
#include <util.h>
#elif defined(__FreeBSD__)
#include <libutil.h>
#include <termios.h>
#elif defined(__OpenBSD__)
#include <util.h>
#include <termios.h>
#endif
/* Some platforms name VWERASE and VDISCARD differently */
#if !defined(VWERASE) && defined(VWERSE)
#define VWERASE VWERSE
#endif
#if !defined(VDISCARD) && defined(VDISCRD)
#define VDISCARD VDISCRD
#endif
/* for pty_getproc */
#if defined(__linux__)
#include <stdio.h>
#include <stdint.h>
#elif defined(__APPLE__)
#include <libproc.h>
#include <os/availability.h>
#include <paths.h>
#include <spawn.h>
#include <sys/event.h>
#include <sys/sysctl.h>
#include <termios.h>
#endif
/* NSIG - macro for highest signal + 1, should be defined */
#ifndef NSIG
#define NSIG 32
#endif
/* macOS 10.14 back does not define this constant */
#ifndef POSIX_SPAWN_SETSID
#define POSIX_SPAWN_SETSID 1024
#endif
/* environ for execvpe */
/* node/src/node_child_process.cc */
#if !defined(__APPLE__)
extern char **environ;
#endif
#if defined(__APPLE__)
extern "C" {
// Changes the current thread's directory to a path or directory file
// descriptor. libpthread only exposes a syscall wrapper starting in
// macOS 10.12, but the system call dates back to macOS 10.5. On older OSes,
// the syscall is issued directly.
int pthread_chdir_np(const char* dir) API_AVAILABLE(macosx(10.12));
int pthread_fchdir_np(int fd) API_AVAILABLE(macosx(10.12));
}
#define HANDLE_EINTR(x) ({ \
int eintr_wrapper_counter = 0; \
decltype(x) eintr_wrapper_result; \
do { \
eintr_wrapper_result = (x); \
} while (eintr_wrapper_result == -1 && errno == EINTR && \
eintr_wrapper_counter++ < 100); \
eintr_wrapper_result; \
})
#endif
struct ExitEvent {
int exit_code = 0, signal_code = 0;
};
void SetupExitCallback(Napi::Env env, Napi::Function cb, pid_t pid) {
std::thread *th = new std::thread;
// Don't use Napi::AsyncWorker which is limited by UV_THREADPOOL_SIZE.
auto tsfn = Napi::ThreadSafeFunction::New(
env,
cb, // JavaScript function called asynchronously
"SetupExitCallback_resource", // Name
0, // Unlimited queue
1, // Only one thread will use this initially
[th](Napi::Env) { // Finalizer used to clean threads up
th->join();
delete th;
});
*th = std::thread([tsfn = std::move(tsfn), pid] {
auto callback = [](Napi::Env env, Napi::Function cb, ExitEvent *exit_event) {
cb.Call({Napi::Number::New(env, exit_event->exit_code),
Napi::Number::New(env, exit_event->signal_code)});
delete exit_event;
};
int ret;
int stat_loc;
#if defined(__APPLE__)
// Based on
// https://source.chromium.org/chromium/chromium/src/+/main:base/process/kill_mac.cc;l=35-69?
int kq = HANDLE_EINTR(kqueue());
struct kevent change = {0};
EV_SET(&change, pid, EVFILT_PROC, EV_ADD, NOTE_EXIT, 0, NULL);
ret = HANDLE_EINTR(kevent(kq, &change, 1, NULL, 0, NULL));
if (ret == -1) {
if (errno == ESRCH) {
// At this point, one of the following has occurred:
// 1. The process has died but has not yet been reaped.
// 2. The process has died and has already been reaped.
// 3. The process is in the process of dying. It's no longer
// kqueueable, but it may not be waitable yet either. Mark calls
// this case the "zombie death race".
ret = HANDLE_EINTR(waitpid(pid, &stat_loc, WNOHANG));
if (ret == 0) {
ret = kill(pid, SIGKILL);
if (ret != -1) {
HANDLE_EINTR(waitpid(pid, &stat_loc, 0));
}
}
}
} else {
struct kevent event = {0};
ret = HANDLE_EINTR(kevent(kq, NULL, 0, &event, 1, NULL));
if (ret == 1) {
if ((event.fflags & NOTE_EXIT) &&
(event.ident == static_cast<uintptr_t>(pid))) {
// The process is dead or dying. This won't block for long, if at
// all.
HANDLE_EINTR(waitpid(pid, &stat_loc, 0));
}
}
}
#else
while (true) {
errno = 0;
if ((ret = waitpid(pid, &stat_loc, 0)) != pid) {
if (ret == -1 && errno == EINTR) {
continue;
}
if (ret == -1 && errno == ECHILD) {
// XXX node v0.8.x seems to have this problem.
// waitpid is already handled elsewhere.
;
} else {
assert(false);
}
}
break;
}
#endif
ExitEvent *exit_event = new ExitEvent;
if (WIFEXITED(stat_loc)) {
exit_event->exit_code = WEXITSTATUS(stat_loc); // errno?
}
if (WIFSIGNALED(stat_loc)) {
exit_event->signal_code = WTERMSIG(stat_loc);
}
auto status = tsfn.BlockingCall(exit_event, callback); // In main thread
switch (status) {
case napi_closing:
break;
case napi_queue_full:
Napi::Error::Fatal("SetupExitCallback", "Queue was full");
case napi_ok:
if (tsfn.Release() != napi_ok) {
Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.Release() failed");
}
break;
default:
Napi::Error::Fatal("SetupExitCallback", "ThreadSafeFunction.BlockingCall() failed");
}
});
}
/**
* Methods
*/
Napi::Value PtyFork(const Napi::CallbackInfo& info);
Napi::Value PtyOpen(const Napi::CallbackInfo& info);
Napi::Value PtyResize(const Napi::CallbackInfo& info);
Napi::Value PtyGetProc(const Napi::CallbackInfo& info);
/**
* Functions
*/
static int
pty_nonblock(int);
#if defined(__APPLE__)
static char *
pty_getproc(int);
#else
static char *
pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
static void
pty_posix_spawn(char** argv, char** env,
const struct termios *termp,
const struct winsize *winp,
int* master,
pid_t* pid,
int* err);
#endif
struct DelBuf {
int len;
DelBuf(int len) : len(len) {}
void operator()(char **p) {
if (p == nullptr)
return;
for (int i = 0; i < len; i++)
free(p[i]);
delete[] p;
}
};
Napi::Value PtyFork(const Napi::CallbackInfo& info) {
Napi::Env napiEnv(info.Env());
Napi::HandleScope scope(napiEnv);
if (info.Length() != 11 ||
!info[0].IsString() ||
!info[1].IsArray() ||
!info[2].IsArray() ||
!info[3].IsString() ||
!info[4].IsNumber() ||
!info[5].IsNumber() ||
!info[6].IsNumber() ||
!info[7].IsNumber() ||
!info[8].IsBoolean() ||
!info[9].IsString() ||
!info[10].IsFunction()) {
throw Napi::Error::New(napiEnv, "Usage: pty.fork(file, args, env, cwd, cols, rows, uid, gid, utf8, helperPath, onexit)");
}
// file
std::string file = info[0].As<Napi::String>();
// args
Napi::Array argv_ = info[1].As<Napi::Array>();
// env
Napi::Array env_ = info[2].As<Napi::Array>();
int envc = env_.Length();
std::unique_ptr<char *, DelBuf> env_unique_ptr(new char *[envc + 1], DelBuf(envc + 1));
char **env = env_unique_ptr.get();
env[envc] = NULL;
for (int i = 0; i < envc; i++) {
std::string pair = env_.Get(i).As<Napi::String>();
env[i] = strdup(pair.c_str());
}
// cwd
std::string cwd_ = info[3].As<Napi::String>();
// size
struct winsize winp;
winp.ws_col = info[4].As<Napi::Number>().Int32Value();
winp.ws_row = info[5].As<Napi::Number>().Int32Value();
winp.ws_xpixel = 0;
winp.ws_ypixel = 0;
#if !defined(__APPLE__)
// uid / gid
int uid = info[6].As<Napi::Number>().Int32Value();
int gid = info[7].As<Napi::Number>().Int32Value();
#endif
// termios
struct termios t = termios();
struct termios *term = &t;
term->c_iflag = ICRNL | IXON | IXANY | IMAXBEL | BRKINT;
if (info[8].As<Napi::Boolean>().Value()) {
#if defined(IUTF8)
term->c_iflag |= IUTF8;
#endif
}
term->c_oflag = OPOST | ONLCR;
term->c_cflag = CREAD | CS8 | HUPCL;
term->c_lflag = ICANON | ISIG | IEXTEN | ECHO | ECHOE | ECHOK | ECHOKE | ECHOCTL;
term->c_cc[VEOF] = 4;
term->c_cc[VEOL] = -1;
term->c_cc[VEOL2] = -1;
term->c_cc[VERASE] = 0x7f;
term->c_cc[VWERASE] = 23;
term->c_cc[VKILL] = 21;
term->c_cc[VREPRINT] = 18;
term->c_cc[VINTR] = 3;
term->c_cc[VQUIT] = 0x1c;
term->c_cc[VSUSP] = 26;
term->c_cc[VSTART] = 17;
term->c_cc[VSTOP] = 19;
term->c_cc[VLNEXT] = 22;
term->c_cc[VDISCARD] = 15;
term->c_cc[VMIN] = 1;
term->c_cc[VTIME] = 0;
#if (__APPLE__)
term->c_cc[VDSUSP] = 25;
term->c_cc[VSTATUS] = 20;
#endif
cfsetispeed(term, B38400);
cfsetospeed(term, B38400);
// helperPath
std::string helper_path = info[9].As<Napi::String>();
pid_t pid;
int master;
#if defined(__APPLE__)
int argc = argv_.Length();
int argl = argc + 4;
std::unique_ptr<char *, DelBuf> argv_unique_ptr(new char *[argl], DelBuf(argl));
char **argv = argv_unique_ptr.get();
argv[0] = strdup(helper_path.c_str());
argv[1] = strdup(cwd_.c_str());
argv[2] = strdup(file.c_str());
argv[argl - 1] = NULL;
for (int i = 0; i < argc; i++) {
std::string arg = argv_.Get(i).As<Napi::String>();
argv[i + 3] = strdup(arg.c_str());
}
int err = -1;
pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err);
if (err != 0) {
throw Napi::Error::New(napiEnv, "posix_spawnp failed.");
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
}
#else
int argc = argv_.Length();
int argl = argc + 2;
std::unique_ptr<char *, DelBuf> argv_unique_ptr(new char *[argl], DelBuf(argl));
char** argv = argv_unique_ptr.get();
argv[0] = strdup(file.c_str());
argv[argl - 1] = NULL;
for (int i = 0; i < argc; i++) {
std::string arg = argv_.Get(i).As<Napi::String>();
argv[i + 1] = strdup(arg.c_str());
}
sigset_t newmask, oldmask;
struct sigaction sig_action;
// temporarily block all signals
// this is needed due to a race condition in openpty
// and to avoid running signal handlers in the child
// before exec* happened
sigfillset(&newmask);
pthread_sigmask(SIG_SETMASK, &newmask, &oldmask);
pid = forkpty(&master, nullptr, static_cast<termios*>(term), static_cast<winsize*>(&winp));
if (!pid) {
// remove all signal handler from child
sig_action.sa_handler = SIG_DFL;
sig_action.sa_flags = 0;
sigemptyset(&sig_action.sa_mask);
for (int i = 0 ; i < NSIG ; i++) { // NSIG is a macro for all signals + 1
sigaction(i, &sig_action, NULL);
}
}
// reenable signals
pthread_sigmask(SIG_SETMASK, &oldmask, NULL);
switch (pid) {
case -1:
throw Napi::Error::New(napiEnv, "forkpty(3) failed.");
case 0:
if (strlen(cwd_.c_str())) {
if (chdir(cwd_.c_str()) == -1) {
perror("chdir(2) failed.");
_exit(1);
}
}
if (uid != -1 && gid != -1) {
if (setgid(gid) == -1) {
perror("setgid(2) failed.");
_exit(1);
}
if (setuid(uid) == -1) {
perror("setuid(2) failed.");
_exit(1);
}
}
{
char **old = environ;
environ = env;
execvp(argv[0], argv);
environ = old;
perror("execvp(3) failed.");
_exit(1);
}
default:
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
}
}
#endif
Napi::Object obj = Napi::Object::New(napiEnv);
obj.Set("fd", Napi::Number::New(napiEnv, master));
obj.Set("pid", Napi::Number::New(napiEnv, pid));
obj.Set("pty", Napi::String::New(napiEnv, ptsname(master)));
// Set up process exit callback.
Napi::Function cb = info[10].As<Napi::Function>();
SetupExitCallback(napiEnv, cb, pid);
return obj;
}
Napi::Value PtyOpen(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 2 ||
!info[0].IsNumber() ||
!info[1].IsNumber()) {
throw Napi::Error::New(env, "Usage: pty.open(cols, rows)");
}
// size
struct winsize winp;
winp.ws_col = info[0].As<Napi::Number>().Int32Value();
winp.ws_row = info[1].As<Napi::Number>().Int32Value();
winp.ws_xpixel = 0;
winp.ws_ypixel = 0;
// pty
int master, slave;
int ret = openpty(&master, &slave, nullptr, NULL, static_cast<winsize*>(&winp));
if (ret == -1) {
throw Napi::Error::New(env, "openpty(3) failed.");
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(env, "Could not set master fd to nonblocking.");
}
if (pty_nonblock(slave) == -1) {
throw Napi::Error::New(env, "Could not set slave fd to nonblocking.");
}
Napi::Object obj = Napi::Object::New(env);
obj.Set("master", Napi::Number::New(env, master));
obj.Set("slave", Napi::Number::New(env, slave));
obj.Set("pty", Napi::String::New(env, ptsname(master)));
return obj;
}
Napi::Value PtyResize(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
if (info.Length() != 3 ||
!info[0].IsNumber() ||
!info[1].IsNumber() ||
!info[2].IsNumber()) {
throw Napi::Error::New(env, "Usage: pty.resize(fd, cols, rows)");
}
int fd = info[0].As<Napi::Number>().Int32Value();
struct winsize winp;
winp.ws_col = info[1].As<Napi::Number>().Int32Value();
winp.ws_row = info[2].As<Napi::Number>().Int32Value();
winp.ws_xpixel = 0;
winp.ws_ypixel = 0;
if (ioctl(fd, TIOCSWINSZ, &winp) == -1) {
switch (errno) {
case EBADF:
throw Napi::Error::New(env, "ioctl(2) failed, EBADF");
case EFAULT:
throw Napi::Error::New(env, "ioctl(2) failed, EFAULT");
case EINVAL:
throw Napi::Error::New(env, "ioctl(2) failed, EINVAL");
case ENOTTY:
throw Napi::Error::New(env, "ioctl(2) failed, ENOTTY");
}
throw Napi::Error::New(env, "ioctl(2) failed");
}
return env.Undefined();
}
/**
* Foreground Process Name
*/
Napi::Value PtyGetProc(const Napi::CallbackInfo& info) {
Napi::Env env(info.Env());
Napi::HandleScope scope(env);
#if defined(__APPLE__)
if (info.Length() != 1 ||
!info[0].IsNumber()) {
throw Napi::Error::New(env, "Usage: pty.process(pid)");
}
int fd = info[0].As<Napi::Number>().Int32Value();
char *name = pty_getproc(fd);
#else
if (info.Length() != 2 ||
!info[0].IsNumber() ||
!info[1].IsString()) {
throw Napi::Error::New(env, "Usage: pty.process(fd, tty)");
}
int fd = info[0].As<Napi::Number>().Int32Value();
std::string tty_ = info[1].As<Napi::String>();
char *tty = strdup(tty_.c_str());
char *name = pty_getproc(fd, tty);
free(tty);
#endif
if (name == NULL) {
return env.Undefined();
}
Napi::String name_ = Napi::String::New(env, name);
free(name);
return name_;
}
/**
* Nonblocking FD
*/
static int
pty_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
/**
* pty_getproc
* Taken from tmux.
*/
// Taken from: tmux (http://tmux.sourceforge.net/)
// Copyright (c) 2009 Nicholas Marriott <nicm@users.sourceforge.net>
// Copyright (c) 2009 Joshua Elsasser <josh@elsasser.org>
// Copyright (c) 2009 Todd Carson <toc@daybefore.net>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF MIND, USE, DATA OR PROFITS, WHETHER
// IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING
// OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#if defined(__linux__)
static char *
pty_getproc(int fd, char *tty) {
FILE *f;
char *path, *buf;
size_t len;
int ch;
pid_t pgrp;
int r;
if ((pgrp = tcgetpgrp(fd)) == -1) {
return NULL;
}
r = asprintf(&path, "/proc/%lld/cmdline", (long long)pgrp);
if (r == -1 || path == NULL) return NULL;
if ((f = fopen(path, "r")) == NULL) {
free(path);
return NULL;
}
free(path);
len = 0;
buf = NULL;
while ((ch = fgetc(f)) != EOF) {
if (ch == '\0') break;
buf = (char *)realloc(buf, len + 2);
if (buf == NULL) return NULL;
buf[len++] = ch;
}
if (buf != NULL) {
buf[len] = '\0';
}
fclose(f);
return buf;
}
#elif defined(__APPLE__)
static char *
pty_getproc(int fd) {
int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, 0 };
size_t size;
struct kinfo_proc kp;
if ((mib[3] = tcgetpgrp(fd)) == -1) {
return NULL;
}
size = sizeof kp;
if (sysctl(mib, 4, &kp, &size, NULL, 0) == -1) {
return NULL;
}
if (size != (sizeof kp) || *kp.kp_proc.p_comm == '\0') {
return NULL;
}
return strdup(kp.kp_proc.p_comm);
}
#else
static char *
pty_getproc(int fd, char *tty) {
return NULL;
}
#endif
#if defined(__APPLE__)
static void
pty_posix_spawn(char** argv, char** env,
const struct termios *termp,
const struct winsize *winp,
int* master,
pid_t* pid,
int* err) {
int low_fds[3];
size_t count = 0;
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
if (low_fds[count] >= STDERR_FILENO)
break;
}
int flags = POSIX_SPAWN_CLOEXEC_DEFAULT |
POSIX_SPAWN_SETSIGDEF |
POSIX_SPAWN_SETSIGMASK |
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
return;
}
int res = grantpt(*master) || unlockpt(*master);
if (res == -1) {
return;
}
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
int slave;
char slave_pty_name[128];
res = ioctl(*master, TIOCPTYGNAME, slave_pty_name);
if (res == -1) {
return;
}
slave = open(slave_pty_name, O_RDWR | O_NOCTTY);
if (slave == -1) {
return;
}
if (termp) {
res = tcsetattr(slave, TCSANOW, termp);
if (res == -1) {
return;
};
}
if (winp) {
res = ioctl(slave, TIOCSWINSZ, winp);
if (res == -1) {
return;
}
}
posix_spawn_file_actions_t acts;
posix_spawn_file_actions_init(&acts);
posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO);
posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO);
posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO);
posix_spawn_file_actions_addclose(&acts, slave);
posix_spawn_file_actions_addclose(&acts, *master);
posix_spawnattr_t attrs;
posix_spawnattr_init(&attrs);
*err = posix_spawnattr_setflags(&attrs, flags);
if (*err != 0) {
goto done;
}
sigset_t signal_set;
/* Reset all signal the child to their default behavior */
sigfillset(&signal_set);
*err = posix_spawnattr_setsigdefault(&attrs, &signal_set);
if (*err != 0) {
goto done;
}
/* Reset the signal mask for all signals */
sigemptyset(&signal_set);
*err = posix_spawnattr_setsigmask(&attrs, &signal_set);
if (*err != 0) {
goto done;
}
do
*err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
while (*err == EINTR);
done:
posix_spawn_file_actions_destroy(&acts);
posix_spawnattr_destroy(&attrs);
for (; count > 0; count--) {
close(low_fds[count]);
}
}
#endif
/**
* Init
*/
Napi::Object init(Napi::Env env, Napi::Object exports) {
exports.Set("fork", Napi::Function::New(env, PtyFork));
exports.Set("open", Napi::Function::New(env, PtyOpen));
exports.Set("resize", Napi::Function::New(env, PtyResize));
exports.Set("process", Napi::Function::New(env, PtyGetProc));
return exports;
}
NODE_API_MODULE(NODE_GYP_MODULE_NAME, init)
@@ -0,0 +1,14 @@
# Files allowed to construct a `ws` server that binds a port without pinning `host`.
#
# `ws` accepts `{ port }` alone and silently binds the wildcard address. A server
# reached over 127.0.0.1 must pin `host: '127.0.0.1'`, or a foreign loopback
# listener can hold the same port and answer in its place -- which is how
# relay-control-client.test.ts came to fail with a real HTTP 401 in a test that
# was simulating silence.
#
# This list only shrinks. Adding a line also requires raising the pin in
# websocket-server-loopback-bind.test.ts, which is deliberate friction.
# Deliberate, not drift: this mock is dialled by a phone on the LAN, so it has to
# be reachable on a real interface. A loopback bind would make it unreachable.
mobile/scripts/mock-server.ts
@@ -95,6 +95,66 @@ describe('benchmark artifact comparison', () => {
})
})
it('compares terminal split headline metrics in milliseconds', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'split-baseline.json', {
label: 'split baseline',
headlineMs: {
shortcutToFocusP50: 284.2,
shortcutToFocusP95: 676.3
}
})
const candidatePath = writeArtifact(dir, 'split-candidate.json', {
label: 'split candidate',
headlineMs: {
shortcutToFocusP50: 12.7,
shortcutToFocusP95: 13.7
}
})
const comparison = comparePaths(baselinePath, candidatePath)
expect(comparison.baseline.kind).toBe('terminal-split-activation')
expect(comparison.metrics).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'shortcutToFocusP50',
unit: 'ms',
baseline: 284.2,
candidate: 12.7,
status: 'improved'
}),
expect.objectContaining({
key: 'shortcutToFocusP95',
unit: 'ms',
baseline: 676.3,
candidate: 13.7,
status: 'improved'
})
])
)
})
it('rejects invalid benchmark artifacts before comparing partial metrics', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'split-invalid.json', {
label: 'invalid split',
status: 'failed',
valid: false,
headlineMs: { shortcutToFocusP50: 0 }
})
const candidatePath = writeArtifact(dir, 'split-valid.json', {
label: 'valid split',
status: 'passed',
valid: true,
headlineMs: { shortcutToFocusP50: 10 }
})
expect(() => comparePaths(baselinePath, candidatePath)).toThrow(
'split-invalid.json: benchmark artifact is marked invalid'
)
})
it('compares numeric Playwright annotation metrics and omits metadata fields', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline-playwright.json', {
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { resolve } from 'node:path'
const SUPPORTED_ARCHES = new Set(['x64', 'arm64'])
/** Select the local Linux package architecture without relying on builder defaults. */
export function resolveLinuxBuildArch({
platform = process.platform,
hostArch = process.arch,
requestedArch = process.env.ORCA_LINUX_BUILD_ARCH
} = {}) {
const arch = requestedArch ?? (platform === 'linux' ? hostArch : 'x64')
if (!SUPPORTED_ARCHES.has(arch)) {
throw new Error(
`Unsupported Linux build architecture: ${arch}. Use ORCA_LINUX_BUILD_ARCH=x64|arm64.`
)
}
return arch
}
export function buildLinuxElectronBuilderArgs(arch, extraArgs = []) {
if (!SUPPORTED_ARCHES.has(arch)) {
throw new Error(`Unsupported Linux build architecture: ${arch}`)
}
return [
'exec',
'electron-builder',
'--config',
'config/electron-builder.config.cjs',
'--linux',
'AppImage',
'deb',
'rpm',
`--${arch}`,
...extraArgs
]
}
export function runLocalLinuxBuild({
arch = resolveLinuxBuildArch(),
extraArgs = [],
environment = process.env,
execFile = execFileSync,
platform = process.platform,
cwd = resolve(import.meta.dirname, '../..')
} = {}) {
const env = { ...environment }
if (arch === 'arm64') {
env.ORCA_LINUX_ARM64_RELEASE = '1'
} else {
delete env.ORCA_LINUX_ARM64_RELEASE
}
const pnpm = platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
execFile(pnpm, buildLinuxElectronBuilderArgs(arch, extraArgs), {
cwd,
env,
stdio: 'inherit'
})
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
runLocalLinuxBuild({ extraArgs: process.argv.slice(2) })
}
+85
View File
@@ -0,0 +1,85 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
buildLinuxElectronBuilderArgs,
resolveLinuxBuildArch,
runLocalLinuxBuild
} from './build-linux-local.mjs'
describe('local Linux build target', () => {
it('is the package script used by the local Linux build', () => {
const packageJson = JSON.parse(
readFileSync(resolve(import.meta.dirname, '../../package.json'), 'utf8')
)
expect(packageJson.scripts['build:linux']).toContain(
'node config/scripts/build-linux-local.mjs'
)
})
it('follows a native Linux host architecture', () => {
expect(resolveLinuxBuildArch({ platform: 'linux', hostArch: 'arm64' })).toBe('arm64')
expect(resolveLinuxBuildArch({ platform: 'linux', hostArch: 'x64' })).toBe('x64')
})
it('defaults cross-platform Linux builds to x64 and allows an explicit override', () => {
expect(resolveLinuxBuildArch({ platform: 'darwin', hostArch: 'arm64' })).toBe('x64')
expect(
resolveLinuxBuildArch({ platform: 'darwin', hostArch: 'arm64', requestedArch: 'arm64' })
).toBe('arm64')
})
it('rejects unsupported architectures', () => {
expect(() => resolveLinuxBuildArch({ platform: 'linux', hostArch: 'ia32' })).toThrow(
'Unsupported Linux build architecture'
)
expect(() => buildLinuxElectronBuilderArgs('ia32')).toThrow(
'Unsupported Linux build architecture'
)
})
it('passes an explicit target and matching artifact-name environment', () => {
const execFile = vi.fn()
runLocalLinuxBuild({
arch: 'arm64',
environment: { PATH: '/bin', ORCA_LINUX_ARM64_RELEASE: undefined },
execFile,
platform: 'linux',
cwd: '/workspace'
})
expect(execFile).toHaveBeenCalledWith(
'pnpm',
buildLinuxElectronBuilderArgs('arm64'),
expect.objectContaining({
cwd: '/workspace',
env: expect.objectContaining({ ORCA_LINUX_ARM64_RELEASE: '1' }),
stdio: 'inherit'
})
)
expect(buildLinuxElectronBuilderArgs('x64')).toEqual(
expect.arrayContaining(['--linux', 'AppImage', 'deb', 'rpm', '--x64'])
)
runLocalLinuxBuild({
arch: 'x64',
environment: { PATH: '/bin', ORCA_LINUX_ARM64_RELEASE: '1' },
execFile,
platform: 'linux',
cwd: '/workspace'
})
expect(execFile).toHaveBeenLastCalledWith(
'pnpm',
buildLinuxElectronBuilderArgs('x64'),
expect.objectContaining({
env: expect.not.objectContaining({ ORCA_LINUX_ARM64_RELEASE: expect.anything() })
})
)
})
it('uses the Windows pnpm command name when cross-host packaging', () => {
const execFile = vi.fn()
runLocalLinuxBuild({ arch: 'x64', execFile, platform: 'win32', cwd: 'C:\\workspace' })
expect(execFile.mock.calls[0]?.[0]).toBe('pnpm.cmd')
})
})
+4 -3
View File
@@ -177,10 +177,11 @@ function build() {
copyFileSync(builtBinary, join(slotDir, 'pty.node'))
console.log(`[orcad-prebuilds] stored ${slot}/pty.node`)
// Why spawn-helper ships too: on Unix node-pty posix_spawns build/Release/spawn-helper,
// Why spawn-helper ships too: on macOS node-pty posix_spawns build/Release/spawn-helper,
// so a slot without it installs cleanly and then fails ENOENT the first time a user
// opens a terminal. Windows has no spawn-helper.
if (process.platform !== 'win32') {
// opens a terminal. binding.gyp builds the helper only under OS=="mac"; every other
// platform forks directly, so demanding one there fails a healthy Linux slot build.
if (process.platform === 'darwin') {
const helperSource = join(dirname(builtBinary), 'spawn-helper')
if (!existsSync(helperSource)) {
throw new Error(`[orcad-prebuilds] spawn-helper missing at ${helperSource}`)
+11
View File
@@ -57,6 +57,13 @@ const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
'relay-assets',
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
)
const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs'
const NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE = join(
ROOT,
'config',
'relay-assets',
NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME
)
// Written by build-windows-process-tree-relay-addon.mjs, which only runs on a
// Windows machine.
const WINDOWS_PROCESS_TREE_BUILD_DIR = join(ROOT, '.build', 'windows-process-tree')
@@ -126,6 +133,10 @@ for (const platform of RELAY_BUILD_PLATFORMS) {
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
)
}
copyFileSync(
NODE_PTY_MASTER_CLOEXEC_PATCH_SOURCE,
join(outDir, NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)
)
stageWindowsProcessTreeAddon(platform, outDir)
await build({
+204
View File
@@ -0,0 +1,204 @@
/**
* Read the top-level option keys of a call's object-literal argument out of raw
* source text.
*
* Text rather than an AST because typescript@7 no longer ships the classic
* compiler API and every installed parser is a transitive dependency. The
* tradeoff is handled by refusing to guess: any shape this cannot read comes
* back as `unreadable` with a reason, and callers must treat that as a failure
* rather than as an absence of keys.
*/
export type CallOptionKeys =
| { readonly readable: true; readonly keys: readonly string[] }
| { readonly readable: false; readonly reason: string }
type ScanState = 'code' | 'line' | 'block' | 'single' | 'double' | 'template'
function closesString(state: ScanState, current: string): boolean {
return (
(state === 'single' && current === "'") ||
(state === 'double' && current === '"') ||
(state === 'template' && current === '`')
)
}
function opensNonCode(current: string, next: string | undefined): ScanState | null {
if (current === '/' && next === '/') {
return 'line'
}
if (current === '/' && next === '*') {
return 'block'
}
if (current === "'") {
return 'single'
}
if (current === '"') {
return 'double'
}
if (current === '`') {
return 'template'
}
return null
}
/**
* Text between an open paren and its match, tracking strings and comments so a
* brace inside either cannot unbalance the count. Null when it never closes.
*/
function balancedArguments(text: string, openIndex: number): string | null {
let depth = 0
let state: ScanState = 'code'
for (let index = openIndex; index < text.length; index++) {
const current = text[index]
const next = text[index + 1]
if (state === 'code') {
const opened = opensNonCode(current, next)
if (opened) {
state = opened
if (opened === 'line' || opened === 'block') {
index++
}
} else if (current === '(' || current === '{' || current === '[') {
depth++
} else if (current === ')' || current === '}' || current === ']') {
depth--
if (depth === 0) {
return text.slice(openIndex + 1, index)
}
if (depth < 0) {
return null
}
}
continue
}
if (state === 'line') {
if (current === '\n') {
state = 'code'
}
continue
}
if (state === 'block') {
if (current === '*' && next === '/') {
state = 'code'
index++
}
continue
}
if (current === '\\') {
index++
continue
}
// Brace tracking inside `${}` would need its own depth; templates never
// appear as options, so report one as unreadable instead of guessing.
if (state === 'template' && current === '$' && next === '{') {
return null
}
if (closesString(state, current)) {
state = 'code'
}
}
return null
}
/** Keys at depth 0 of an object literal body, with anything non-identifier kept verbatim. */
function objectLiteralKeys(body: string): string[] {
const keys: string[] = []
let depth = 0
let state: ScanState = 'code'
let inValue = false
let token = ''
const flush = (): void => {
const name = token.trim()
token = ''
if (name && depth === 0) {
keys.push(name)
}
}
for (let index = 0; index < body.length; index++) {
const current = body[index]
const next = body[index + 1]
if (state === 'code') {
const opened = opensNonCode(current, next)
if (opened) {
state = opened
if (opened === 'line' || opened === 'block') {
index++
}
} else if (current === '(' || current === '{' || current === '[') {
depth++
if (!inValue) {
token += current
}
} else if (current === ')' || current === '}' || current === ']') {
depth--
if (!inValue) {
token += current
}
} else if (current === ':' && depth === 0 && !inValue) {
flush()
inValue = true
} else if (current === ',' && depth === 0) {
// A shorthand or a spread ends here having never seen a colon.
if (inValue) {
inValue = false
token = ''
} else {
flush()
}
} else if (!inValue) {
token += current
}
continue
}
if (state === 'line') {
if (current === '\n') {
state = 'code'
}
continue
}
if (state === 'block') {
if (current === '*' && next === '/') {
state = 'code'
index++
}
continue
}
if (current === '\\') {
index++
continue
}
if (closesString(state, current)) {
state = 'code'
}
}
if (!inValue) {
flush()
}
return keys
}
/**
* Option keys of the call whose argument list opens at `parenIndex`, or the
* reason the shape could not be read. Spreads and computed keys land in the
* latter: either can carry a key this would otherwise report as absent.
*/
export function readCallOptionKeys(text: string, parenIndex: number): CallOptionKeys {
const args = balancedArguments(text, parenIndex)
if (args === null) {
return { readable: false, reason: 'argument list never closes' }
}
if (!args.trim()) {
return { readable: false, reason: 'called with no options argument' }
}
const trimmed = args.trim()
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
return { readable: false, reason: 'options are not an object literal' }
}
const keys = objectLiteralKeys(trimmed.slice(1, -1))
const unreadable = keys.find((key) => !/^[A-Za-z_$][\w$]*$/.test(key))
if (unreadable !== undefined) {
return { readable: false, reason: `unreadable option key \`${unreadable}\`` }
}
return { readable: true, keys }
}
+12 -3
View File
@@ -4,6 +4,7 @@ import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/
export const OXLINT_SCANS = [
@@ -24,6 +25,13 @@ export const OXLINT_SCANS = [
]
const SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS = new Map([
[
'react-doctor(no-adjust-state-on-prop-change)',
new Set([
'src/renderer/src/components/use-task-page-github-issue-draft.ts',
'src/renderer/src/components/use-task-page-jira-creation-state.ts'
])
],
[
'react-doctor(no-derived-state-effect)',
new Set([
@@ -278,11 +286,12 @@ function isSuppressedDiagnostic(diagnostic, root) {
}
function runOxlintScan(root, scan, files) {
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const result = spawnSync(pnpm, ['exec', 'oxlint', ...scan.args, '--format', 'json', ...files], {
const { command, prefixArgs } = resolveOxlintInvocation(root)
const result = spawnSync(command, [...prefixArgs, ...scan.args, '--format', 'json', ...files], {
cwd: root,
encoding: 'utf8',
maxBuffer: 128 * 1024 * 1024
maxBuffer: 128 * 1024 * 1024,
windowsHide: true
})
if (result.error) {
throw result.error
+17 -3
View File
@@ -1,16 +1,30 @@
import { spawnSync } from 'node:child_process'
import process from 'node:process'
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
const requestedBase =
process.argv.slice(2).find((argument) => argument !== '--') ??
process.env.ORCA_CODE_QUALITY_BASE ??
'origin/main'
const base = resolvePullRequestDiffBase(process.cwd(), requestedBase)
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
// Why validate rather than trust: `base` arrives from argv or the environment and
// below it can reach cmd.exe unquoted, because resolvePnpmCliInvocation still
// falls back to a shell when it cannot find a directly spawnable pnpm. It accepts
// SHAs, tags, ref paths and the ^ ~ .. suffixes -- not reflog syntax like HEAD@{1},
// because braces stay out of anything bound for cmd.exe. The error names the base.
const GIT_REVISION = /^[A-Za-z0-9._/@^~-]+$/
if (!GIT_REVISION.test(base)) {
throw new Error(`Refusing to pass an unsafe diff base to pnpm: ${base}`)
}
// Why the shim and not a direct binary: `dlx` fetches react-doctor on demand, so
// only the pnpm CLI can run it. resolvePnpmCliInvocation prefers whatever
// npm_execpath exposes -- pnpm 12's own pnpm.exe, spawned with no shell.
const { command, prefixArgs, shell } = resolvePnpmCliInvocation()
const result = spawnSync(
pnpm,
command,
[
...prefixArgs,
'dlx',
'react-doctor@0.9.1',
'.',
@@ -26,7 +40,7 @@ const result = spawnSync(
'--blocking',
'error'
],
{ stdio: 'inherit' }
{ stdio: 'inherit', shell, windowsHide: true }
)
if (result.error) {
@@ -0,0 +1,29 @@
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
import { describe, expect, it } from 'vitest'
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const script = path.join(repoRoot, 'config', 'scripts', 'check-react-doctor-changed.mjs')
function runWithBase(base) {
return spawnSync(process.execPath, [script, base], {
cwd: repoRoot,
encoding: 'utf8',
windowsHide: true
})
}
describe('check-react-doctor-changed diff base', () => {
// The pnpm invocation can still fall back to a shell, so an unvalidated base
// would reach cmd.exe unquoted. Rejection has to happen before the spawn.
it.each(['main & calc', 'main | whoami', 'main"x', '%PATH%', 'main $(id)'])(
'refuses %j',
(base) => {
const result = runWithBase(base)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('Refusing to pass an unsafe diff base')
}
)
})
+230
View File
@@ -0,0 +1,230 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
// Ratchet gate for the `@ts-nocheck` directive.
//
// TypeScript only honours `@ts-nocheck` in a comment before the first statement, and
// once present it disables type checking for the ENTIRE file. PR #17605 split a single
// 43,928-line class into ~172 modules whose linear mixin-inheritance chain cannot yet
// express forward references, so each carries a grandfathered `@ts-nocheck` header. This
// check freezes that set (the baseline) and fails CI when a NEW file adds the directive —
// the existing files are grandfathered; new ones must fix their types instead. The
// baseline may only shrink.
const BASELINE_PATH = 'config/ts-nocheck-baseline.txt'
// These two files legitimately contain the directive text as data (regex, fixtures),
// so scanning them would self-flag. The ratchet does not police itself.
const SELF_FILES = new Set([
'config/scripts/check-ts-nocheck-ratchet.mjs',
'config/scripts/check-ts-nocheck-ratchet.test.mjs'
])
// True if `@ts-nocheck` appears in a comment before the first statement, matching the
// TypeScript rule. Limitation: only the leading run of blank lines / line comments /
// block comments at the top of the file is scanned, so a directive-looking string deeper
// in a block comment that itself starts at the top is still checked — but anything after
// real code (or inside a string literal, which never opens the leading comment run) is not.
export function hasTsNoCheck(sourceText) {
let i = 0
const n = sourceText.length
while (i < n) {
const rest = sourceText.slice(i)
const blank = /^[ \t]*\r?\n/.exec(rest)
if (blank) {
i += blank[0].length
continue
}
if (rest.startsWith('//')) {
const end = sourceText.indexOf('\n', i)
const line = end === -1 ? sourceText.slice(i) : sourceText.slice(i, end)
if (/^\/\/\s*@ts-nocheck\b/.test(line)) {
return true
}
i = end === -1 ? n : end + 1
continue
}
if (rest.startsWith('/*')) {
const end = sourceText.indexOf('*/', i + 2)
const block = end === -1 ? sourceText.slice(i) : sourceText.slice(i, end + 2)
if (/^\/\*\s*@ts-nocheck\b/.test(block)) {
return true
}
i = end === -1 ? n : end + 2
continue
}
break
}
return false
}
export function parseBaseline(text) {
return new Set(
text
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
)
}
export function diffBaseline(current, baseline) {
const cur = new Set(current)
const base = baseline instanceof Set ? baseline : new Set(baseline)
const added = [...cur].filter((e) => !base.has(e)).sort()
const stale = [...base].filter((e) => !cur.has(e)).sort()
return { added, stale }
}
// Collect every currently tracked file that carries a `@ts-nocheck` header.
export function collectCurrentTsNoCheckFiles(root = process.cwd()) {
const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mts', '*.cts'], {
cwd: root,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024
})
.split('\n')
.filter(Boolean)
.filter((f) => !SELF_FILES.has(f))
const entries = []
for (const rel of tracked) {
let src
try {
src = fs.readFileSync(path.join(root, rel), 'utf8')
} catch {
continue
}
if (hasTsNoCheck(src)) {
entries.push(rel)
}
}
return entries.sort()
}
function printAddedFailure(added) {
for (const entry of added) {
console.error(`::error::New @ts-nocheck not allowed: ${entry}`)
}
console.error('')
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
console.error('│ ❌ ts-nocheck ratchet failed — a NEW file adds a @ts-nocheck directive. │')
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
console.error('')
console.error(` ${added.length} file(s) newly add a \`@ts-nocheck\` header:`)
console.error('')
for (const entry of added) {
console.error(` • ${entry}`)
}
console.error('')
console.error(' `@ts-nocheck` disables ALL type checking for the whole file, not just one line.')
console.error(
' The grandfathered entries exist only because the split runtime mixin chain cannot'
)
console.error(' express forward references yet — that is not a general license to suppress.')
console.error('')
console.error(' ✅ Fix it: fix the types instead of suppressing the whole file.')
console.error('')
console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable')
console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`)
console.error('')
}
function printStaleFailure(stale) {
for (const entry of stale) {
console.error(`::error::Stale ts-nocheck baseline entry (prune it): ${entry}`)
}
console.error('')
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
console.error('│ ⚠️ ts-nocheck baseline is out of date — nice work removing a suppression! │')
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
console.error('')
console.error(` ${stale.length} baseline entr(y/ies) no longer have a @ts-nocheck directive.`)
console.error(
' The baseline may only shrink, so these must be removed to keep re-adding blocked:'
)
console.error('')
for (const entry of stale) {
console.error(` • ${entry}`)
}
console.error('')
console.error(` ✅ Fix it (one command): pnpm check:ts-nocheck-ratchet --prune`)
console.error('')
}
export function main(root = process.cwd()) {
const baselineFile = path.join(root, BASELINE_PATH)
if (!fs.existsSync(baselineFile)) {
console.error(
`::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-ts-nocheck-ratchet.mjs --init`
)
return 1
}
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
const current = collectCurrentTsNoCheckFiles(root)
const { added, stale } = diffBaseline(current, baseline)
if (added.length > 0) {
printAddedFailure(added)
if (stale.length > 0) {
console.error(
` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)`
)
printStaleFailure(stale)
}
return 1
}
if (stale.length > 0) {
printStaleFailure(stale)
return 1
}
console.log(
`ts-nocheck ratchet OK — ${current.length} grandfathered file(s), no new suppressions.`
)
return 0
}
function writeBaseline(root, entries) {
const header = [
'# Files currently allowed to carry a `@ts-nocheck` header.',
'# This is a RATCHET: the list may only SHRINK. These exist only because the split',
'# runtime mixin chain cannot express forward references yet — do NOT add entries to',
'# get CI green; fix the types instead.',
'# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)',
''
].join('\n')
fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const root = process.cwd()
const arg = process.argv[2]
if (arg === '--init') {
// One-time bootstrap: capture the current @ts-nocheck set as the baseline.
const entries = collectCurrentTsNoCheckFiles(root)
writeBaseline(root, entries)
console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`)
process.exit(0)
}
if (arg === '--prune') {
// Remove baseline entries whose @ts-nocheck is gone (shrink only; never adds).
const current = new Set(collectCurrentTsNoCheckFiles(root))
const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8'))
const kept = [...baseline].filter((e) => current.has(e)).sort()
const newlyAdded = [...current].filter((e) => !baseline.has(e))
writeBaseline(root, kept)
console.log(
`Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).`
)
if (newlyAdded.length > 0) {
console.error(
`::error::--prune does not add entries; ${newlyAdded.length} new suppression(s) remain — fix those files' types.`
)
process.exit(1)
}
process.exit(0)
}
process.exit(main(root))
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { diffBaseline, hasTsNoCheck, parseBaseline } from './check-ts-nocheck-ratchet.mjs'
describe('hasTsNoCheck', () => {
it('detects a line-comment form', () => {
expect(hasTsNoCheck('// @ts-nocheck\nexport const a = 1\n')).toBe(true)
})
it('detects a block-comment form', () => {
expect(hasTsNoCheck('/* @ts-nocheck */\nexport const a = 1\n')).toBe(true)
})
it('detects the no-space form', () => {
expect(hasTsNoCheck('//@ts-nocheck\nexport const a = 1\n')).toBe(true)
})
it('detects a directive with a -- Why reason', () => {
expect(
hasTsNoCheck(
'// @ts-nocheck -- Why: mechanically split, covered by AST tests.\nimport x from "y"\n'
)
).toBe(true)
})
it('allows blank lines and other leading comments before the directive', () => {
const src =
'\n// Copyright notice.\n\n/* another leading comment */\n// @ts-nocheck\nexport const a = 1\n'
expect(hasTsNoCheck(src)).toBe(true)
})
it('does not match once a statement has started', () => {
const src = 'export const a = 1\n// @ts-nocheck\n'
expect(hasTsNoCheck(src)).toBe(false)
})
it('does not match inside a string literal', () => {
const src = 'export const a = "// @ts-nocheck"\n'
expect(hasTsNoCheck(src)).toBe(false)
})
it('returns false for ordinary source', () => {
expect(hasTsNoCheck('export function f() {\n return 42\n}\n')).toBe(false)
})
})
describe('parseBaseline', () => {
it('drops comments and blank lines', () => {
const b = parseBaseline('# header\n\nsrc/a.ts\nsrc/b.ts\n')
expect(b).toEqual(new Set(['src/a.ts', 'src/b.ts']))
})
})
describe('diffBaseline', () => {
it('reports added and stale entries', () => {
const { added, stale } = diffBaseline(
['src/b.ts', 'src/c.ts'],
new Set(['src/a.ts', 'src/b.ts'])
)
expect(added).toEqual(['src/c.ts']) // new suppression
expect(stale).toEqual(['src/a.ts']) // suppression removed
})
it('is clean when current matches baseline', () => {
const { added, stale } = diffBaseline(['src/a.ts'], new Set(['src/a.ts']))
expect(added).toEqual([])
expect(stale).toEqual([])
})
})
+13 -1
View File
@@ -74,6 +74,9 @@ export function readBenchmarkArtifact(path) {
}
export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifact(path)) {
if (artifact?.valid === false || artifact?.status === 'failed') {
throw new Error(`${path}: benchmark artifact is marked invalid`)
}
if (artifact?.summaryMedianMs != null) {
return normalizeNumericObject(path, artifact, 'startup', artifact.summaryMedianMs, () => 'ms')
}
@@ -82,6 +85,15 @@ export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifac
key.endsWith('Count') || key.endsWith('After') ? 'count' : 'ms'
)
}
if (artifact?.headlineMs != null) {
return normalizeNumericObject(
path,
artifact,
'terminal-split-activation',
artifact.headlineMs,
() => 'ms'
)
}
if (artifact?.suites != null) {
return normalizePlaywrightArtifact(path, artifact)
}
@@ -89,7 +101,7 @@ export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifac
return normalizeSummaryArtifact(path, artifact)
}
throw new Error(
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, Playwright suites, or top-level summary`
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, headlineMs, Playwright suites, or top-level summary`
)
}
@@ -12,11 +12,33 @@ const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
const bundledGuide = BUNDLED_SKILL_GUIDES.find((guide) => guide.name === 'computer-use')?.markdown
describe('computer-use skill guidance', () => {
it('keeps discovery scoped to desktop control and out of the embedded browser', () => {
const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(readFileSync(guidePath, 'utf8'))?.[1] ?? ''
const description = frontmatter.replace(/\s+/gu, ' ')
expect(description).toContain('OS/window-level inspection and input')
expect(description).toContain('external browser window')
expect(description).toContain("Do not use for Orca's embedded browser")
expect(description).toContain('page-only browser automation')
expect(description).toContain("`orca-cli` for Orca's embedded pages")
expect(description).toContain(
'page-automation tool such as Playwright or CDP for external pages'
)
expect(description).not.toContain('read Slack')
expect(description).not.toContain('get app state')
const orcaCli = readFileSync(join(projectDir, 'skill-guides', 'orca-cli.md'), 'utf8').replace(
/\s+/gu,
' '
)
expect(orcaCli).toContain('browser embedded inside the Orca app')
})
it('keeps web-app targeting on the computer-use surface', () => {
const skill = readFileSync(guidePath, 'utf8')
expect(skill).toContain('Use this skill for desktop UI through `orca computer`')
expect(skill).toContain('operate the desktop browser app/window that contains the page')
expect(skill).toContain('external desktop browser window that needs desktop-level control')
expect(skill).not.toContain('orca goto')
expect(skill).not.toContain('orca snapshot')
expect(skill).not.toContain('orca click')
@@ -1,3 +1,25 @@
import { execFileSync } from 'node:child_process'
/** Written once a bundle is fully built; its absence is what marks a build still in flight. */
export const DEV_BUNDLE_MARKER_FILENAME = 'orca-dev-electron-app.json'
export function getDevBundleProcessTable(execFile = execFileSync) {
// Not pgrep: macOS pgrep has no -a (a Linux procps extension) and silently prints bare PIDs,
// which reads as "nothing is running" and deletes a live bundle. -ww keeps the command column
// from being truncated. The raw text is searched directly; see isDevBundleInUse for why it is
// deliberately not parsed into paths.
try {
return execFile('/bin/ps', ['-Awwo', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000
})
} catch {
// Treating a failure as "nothing live" would risk deleting a running bundle, so skip pruning.
return null
}
}
// Why this module exists: `out/electron-dev` accumulates one ~270MB copy of Electron.app per
// (branch title x Electron version x bundle layout). The runner only ever clears the directory it is
// about to rebuild, so siblings from renamed branches and past upgrades are never reclaimed --
+44 -293
View File
@@ -1,4 +1,4 @@
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -10,17 +10,8 @@ const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main')
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
const FpmTarget = require('app-builder-lib/out/targets/FpmTarget').default
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
const {
createPackagedRuntimeNodeModuleResources,
findAsarEntry,
prunePackagedNodePty,
prunePackagedParcelWatcher,
prunePackagedSherpaOnnx,
prunePackagedRuntimeTypeAndSourceMapArtifacts,
prunePackagedZodSources,
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
describe('electron-builder config', () => {
it('keeps the packaged app identity aligned with local-build validation', () => {
@@ -280,8 +271,9 @@ describe('electron-builder config', () => {
expect(electronBuilderConfig.linux.desktop.entry.StartupWMClass).toBe('orca')
})
it('uses AppImage and deb as local Linux targets without changing existing artifact names', () => {
expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb'])
it('uses the release artifact set as local Linux targets without changing existing names', () => {
expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm'])
expect(electronBuilderConfig.toolsets).toEqual({ appimage: '1.0.3' })
expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}')
expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}')
expect(electronBuilderConfig.rpm).toMatchObject({
@@ -290,6 +282,33 @@ describe('electron-builder config', () => {
})
})
it('retains electron-builder runtime dependencies in deb and rpm packages', () => {
for (const target of ['deb', 'rpm']) {
const dependencies = electronBuilderConfig[target].depends
expect(dependencies).toEqual(
expect.arrayContaining(FpmTarget.prototype.getDefaultDepends(target))
)
expect(new Set(dependencies).size).toBe(dependencies.length)
}
})
it('validates each AppImage before electron-builder publishes it', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-appimage-'))
try {
const appImage = join(root, 'orca-linux.AppImage')
await writeFile(appImage, 'not an ELF')
await chmod(appImage, 0o755)
expect(() =>
electronBuilderConfig.artifactBuildCompleted({ file: appImage, arch: 1 })
).toThrow(/ELF header is outside/)
expect(() =>
electronBuilderConfig.artifactBuildCompleted({ file: join(root, 'orca-ide.deb') })
).not.toThrow()
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('uses a distinct AppImage name for Linux arm64 release uploads', () => {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = process.env.ORCA_LINUX_ARM64_RELEASE
@@ -367,286 +386,6 @@ describe('electron-builder config', () => {
expect(electronBuilderConfig.npmRebuild).toBe(true)
})
it('verifies packaged main runtime deps from Windows-style asar entries', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true })
const sources = new Map([
['out\\main\\index.js', 'const z = require("zod")'],
['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")']
])
const asar = {
listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`),
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('normalizes host-specific asar entry separators', () => {
expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe(
'\\out\\main\\index.js'
)
expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
})
it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
try {
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
const prebuildsDir = join(nodePtyDir, 'prebuilds')
const binDir = join(nodePtyDir, 'bin')
await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true })
await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true })
await mkdir(join(nodePtyDir, 'third_party', 'conpty'), {
recursive: true
})
await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true })
prunePackagedNodePty(resourcesDir, 'darwin', 3)
await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64'])
await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148'])
await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([])
await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([])
expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow(
'Unsupported packaged runtime architecture: 4'
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
for (const [arch, electronArch] of [
['x64', 1],
['arm64', 3]
]) {
const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
try {
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
const releaseDir = join(nodePtyDir, 'build', 'Release')
const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0')
await mkdir(releaseDir, { recursive: true })
await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8')
for (const sourceArch of ['x64', 'arm64']) {
const sourceDir = join(conptyRoot, `win10-${sourceArch}`)
await mkdir(sourceDir, { recursive: true })
await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8')
await writeFile(
join(sourceDir, 'OpenConsole.exe'),
`console payload ${sourceArch}`,
'utf8'
)
}
prunePackagedNodePty(resourcesDir, 'win32', electronArch)
await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
`dll payload ${arch}`
)
await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe(
`console payload ${arch}`
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
}
})
it('includes external main dependencies in the packaged runtime closure', () => {
// Why: the main process imports '@parcel/watcher' for filesystem change
// events; if it is absent from the packaged closure the serve host silently
// stops propagating file changes to clients (regression guard for #4851).
const packaged = createPackagedRuntimeNodeModuleResources()
const packagedTargets = packaged.map((resource) => resource.to)
expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher'))
expect(
packagedTargets.some((target) =>
target.startsWith(join('node_modules', '@parcel', 'watcher-'))
)
).toBe(true)
expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile'))
})
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
try {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
await mkdir(join(parcelDir, 'watcher'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-x64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64')
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'watcher',
'watcher-linux-arm64-glibc'
])
expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow(
'Unsupported packaged runtime architecture: universal'
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('leaves unrelated @parcel/* runtime deps untouched when pruning the watcher', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-unrelated-'))
try {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
await mkdir(join(parcelDir, 'watcher'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
// A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux', 1)
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'transformer-js',
'watcher',
'watcher-linux-x64-glibc'
])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes type declaration artifacts from packaged runtime node_modules', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'example-package')
await mkdir(join(packageDir, 'dist'), { recursive: true })
await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.mts'), 'export type Value = string', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.cts.map'), '{}', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8')
prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir)
await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs'])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64')
await mkdir(packageDir, { recursive: true })
await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8')
await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8')
await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8')
prunePackagedSherpaOnnx(resourcesDir, 'darwin')
await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([
'libonnxruntime.1.23.2.dylib',
'sherpa-onnx.node'
])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes zod TypeScript sources from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'zod')
await mkdir(join(packageDir, 'src'), { recursive: true })
await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8')
await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8')
prunePackagedZodSources(resourcesDir)
await expect(readdir(packageDir)).resolves.toEqual(['index.cjs'])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('fails when the packaged resources directory is missing', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
try {
await expect(
electronBuilderConfig.afterPack({
appOutDir: root,
electronPlatformName: 'win32'
})
).rejects.toThrow(/Missing packaged resources directory/)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'marks packaged Unix CLI launchers executable',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
try {
const resourcesDir = join(root, 'linux-unpacked', 'resources')
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
await cp(
join(process.cwd(), 'resources', 'plugins', 'launch'),
join(resourcesDir, 'plugins', 'launch'),
{ 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'
)
const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
await writeFile(
join(unpackedCliDir, 'index.js'),
[
'const args = process.argv.slice(2)',
"if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))",
"else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)",
'else console.log(JSON.stringify({ executed: false }))'
].join('\n'),
'utf8'
)
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
appOutDir: join(root, 'linux-unpacked'),
electronPlatformName: 'linux',
arch: 1
})
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
// Why: the .deb/.rpm update-recovery path keys entirely off the resources/package-type marker that
// app-builder-lib's FpmTarget writes. If packaging silently stops shipping an fpm target, or adds
// one the recovery path does not cover, getLinuxRootPackageType() returns null, autoInstallOnAppQuit
@@ -680,5 +419,17 @@ describe('electron-builder config', () => {
expect(source).toContain(`value === '${target}'`)
}
})
it('keeps the pinned FpmTarget overwrite for configured deb and rpm artifacts', async () => {
const source = await readFile(
require.resolve('app-builder-lib/out/targets/FpmTarget'),
'utf8'
)
expect(source).toContain('path.join(resourceDir, "package-type"), target')
for (const target of RECOVERABLE_TARGETS) {
expect(electronBuilderConfig[target]).toBeDefined()
}
})
})
})
@@ -0,0 +1,116 @@
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { basename } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx']
// The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("")
// value of Software\Classes\.<ext>. Additive `WriteRegNone ...\OpenWithProgids` must not
// match, or the guard below would be unfalsifiable.
const DEFAULT_HANDLER_WRITE = /WriteRegStr\s+SHELL_CONTEXT\s+"Software\\Classes\\\.[a-z]+"\s+""/i
// The hooks file documents the forbidden line in prose, so match executable script only.
const stripNsisCommentLines = (source) =>
source
.split('\n')
.filter((line) => !/^\s*[;#]/.test(line))
.join('\n')
const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8')
describe('electron-builder markdown file associations', () => {
// Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS
// packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value
// — silently taking .md from whichever editor owns it, for every existing user on their
// next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot
// prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must
// stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks.
it('never claims the Windows default markdown handler', () => {
expect(electronBuilderConfig.fileAssociations).toBeUndefined()
expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined()
})
it('joins the macOS Open With list for every markdown extension without owning it', () => {
const associations = electronBuilderConfig.mac.fileAssociations
// One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob.
expect([...associations].map((association) => association.ext).sort()).toEqual(
[...MARKDOWN_EXTENSIONS].sort()
)
for (const association of associations) {
expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' })
}
})
// Why mimeTypes and not linux.fileAssociations: shared-mime-info already maps markdown to
// text/markdown, so the desktop entry only adds a handler and mimeapps.list keeps owning
// the default. A fileAssociations entry would ship a redundant glob override instead.
it('reuses the existing shared-mime-info markdown type on Linux', () => {
expect(electronBuilderConfig.linux.mimeTypes).toContain('text/markdown')
expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined()
})
it('points the single NSIS include at the installer hooks file on disk', () => {
const includePath = electronBuilderConfig.nsis.include
expect(existsSync(includePath)).toBe(true)
expect(basename(includePath)).toBe('orca-installer-hooks.nsh')
})
// Guard for the guard: proves DEFAULT_HANDLER_WRITE really matches a takeover line, so
// the assertion below is a live check rather than a regex that can never fire.
it('recognizes an APP_ASSOCIATE-style default-handler write', () => {
for (const takeover of [
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "Orca.Markdown"',
'WriteRegStr SHELL_CONTEXT "Software\\Classes\\.markdown" "" "$0"'
]) {
expect(takeover).toMatch(DEFAULT_HANDLER_WRITE)
}
expect(
'WriteRegNone SHELL_CONTEXT "Software\\Classes\\.md\\OpenWithProgids" "Orca.Markdown"'
).not.toMatch(DEFAULT_HANDLER_WRITE)
// Comment stripping must drop prose that quotes the bad line without swallowing a real
// one that happens to carry a trailing comment.
const stripped = stripNsisCommentLines(
[
'; WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "<ProgID>"',
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "$0" ; oops'
].join('\n')
)
expect(stripped.split('\n')).toHaveLength(1)
expect(stripped).toMatch(DEFAULT_HANDLER_WRITE)
})
it('registers Windows markdown Open With additively, never as the default', async () => {
const hooks = await readInstallerHooks()
expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE)
// The additive hint that puts Orca in Explorer's "Open with" list.
expect(hooks).toMatch(
/WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/
)
expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/)
for (const ext of MARKDOWN_EXTENSIONS) {
expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
}
expect(hooks).toMatch(/!macro\s+customInstall\b/)
expect(hooks).toMatch(/!macro\s+customUnInstall\b/)
})
// Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown
// hooks too. electron-builder allows only one include, so a merge that drops the daemon
// sweep would silently orphan a running orca-terminal-daemon.exe on every uninstall.
it('keeps the daemon-host uninstall sweep across the include rename', async () => {
const hooks = await readInstallerHooks()
expect(hooks).toContain('orca-terminal-daemon.exe')
expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host')
// Without this guard, uninstallOldVersion would kill the daemon on every update —
// defeating the relocation that keeps terminals alive across updates.
expect(hooks).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/)
})
})
@@ -0,0 +1,308 @@
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const {
createPackagedRuntimeNodeModuleResources,
findAsarEntry,
prunePackagedNodePty,
prunePackagedParcelWatcher,
prunePackagedSherpaOnnx,
prunePackagedRuntimeTypeAndSourceMapArtifacts,
prunePackagedZodSources,
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
describe('packaged runtime resources', () => {
it('verifies packaged main runtime deps from Windows-style asar entries', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true })
const sources = new Map([
['out\\main\\index.js', 'const z = require("zod")'],
['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")']
])
const asar = {
listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`),
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('normalizes host-specific asar entry separators', () => {
expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe(
'\\out\\main\\index.js'
)
expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
})
it('prunes non-target node-pty architecture outputs from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
try {
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
const prebuildsDir = join(nodePtyDir, 'prebuilds')
const binDir = join(nodePtyDir, 'bin')
await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
await mkdir(join(binDir, 'darwin-arm64-148'), { recursive: true })
await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true })
await mkdir(join(nodePtyDir, 'third_party', 'conpty'), {
recursive: true
})
await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true })
prunePackagedNodePty(resourcesDir, 'darwin', 3)
await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64'])
await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148'])
await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([])
await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([])
expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow(
'Unsupported packaged runtime architecture: 4'
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
for (const [arch, electronArch] of [
['x64', 1],
['arm64', 3]
]) {
const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
try {
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
const releaseDir = join(nodePtyDir, 'build', 'Release')
const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0')
await mkdir(releaseDir, { recursive: true })
await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8')
for (const sourceArch of ['x64', 'arm64']) {
const sourceDir = join(conptyRoot, `win10-${sourceArch}`)
await mkdir(sourceDir, { recursive: true })
await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8')
await writeFile(
join(sourceDir, 'OpenConsole.exe'),
`console payload ${sourceArch}`,
'utf8'
)
}
prunePackagedNodePty(resourcesDir, 'win32', electronArch)
await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
`dll payload ${arch}`
)
await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe(
`console payload ${arch}`
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
}
})
it('includes external main dependencies in the packaged runtime closure', () => {
// Why: the main process imports '@parcel/watcher' for filesystem change
// events; if it is absent from the packaged closure the serve host silently
// stops propagating file changes to clients (regression guard for #4851).
const packaged = createPackagedRuntimeNodeModuleResources()
const packagedTargets = packaged.map((resource) => resource.to)
expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher'))
expect(
packagedTargets.some((target) =>
target.startsWith(join('node_modules', '@parcel', 'watcher-'))
)
).toBe(true)
expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile'))
})
it('prunes non-target @parcel/watcher architecture subpackages', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
try {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
await mkdir(join(parcelDir, 'watcher'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-x64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux', 'arm64')
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'watcher',
'watcher-linux-arm64-glibc'
])
expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow(
'Unsupported packaged runtime architecture: universal'
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('leaves unrelated @parcel/* runtime deps untouched when pruning the watcher', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-unrelated-'))
try {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
await mkdir(join(parcelDir, 'watcher'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
// A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux', 1)
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'transformer-js',
'watcher',
'watcher-linux-x64-glibc'
])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes type declaration artifacts from packaged runtime node_modules', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'example-package')
await mkdir(join(packageDir, 'dist'), { recursive: true })
await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8')
prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir)
await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs'])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64')
await mkdir(packageDir, { recursive: true })
await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8')
await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8')
await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8')
prunePackagedSherpaOnnx(resourcesDir, 'darwin')
await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([
'libonnxruntime.1.23.2.dylib',
'sherpa-onnx.node'
])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes zod TypeScript sources from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'zod')
await mkdir(join(packageDir, 'src'), { recursive: true })
await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8')
await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8')
prunePackagedZodSources(resourcesDir)
await expect(readdir(packageDir)).resolves.toEqual(['index.cjs'])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('fails when the packaged resources directory is missing', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
try {
await expect(
electronBuilderConfig.afterPack({
appOutDir: root,
electronPlatformName: 'win32'
})
).rejects.toThrow(/Missing packaged resources directory/)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'marks packaged Unix CLI launchers executable',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
try {
const resourcesDir = join(root, 'linux-unpacked', 'resources')
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
await cp(
join(process.cwd(), 'resources', 'plugins', 'launch'),
join(resourcesDir, 'plugins', 'launch'),
{ 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(
join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'),
`${JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true })}\n`,
'utf8'
)
const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
await writeFile(
join(unpackedCliDir, 'index.js'),
[
'const args = process.argv.slice(2)',
"if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))",
"else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)",
'else console.log(JSON.stringify({ executed: false }))'
].join('\n'),
'utf8'
)
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
appOutDir: join(root, 'linux-unpacked'),
electronPlatformName: 'linux',
arch: 1,
packager: { appInfo: { version: '9.9.9' } }
})
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
await expect(
readFile(join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'), 'utf8')
).resolves.toContain('"version": "9.9.9"')
await expect(readFile(join(resourcesDir, 'package-type'), 'utf8')).resolves.toBe('AppImage')
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
})
+16
View File
@@ -0,0 +1,16 @@
/** Where the Electron executable sits inside a `dist` tree, relative to it. */
export function getElectronPlatformPath(targetPlatform) {
switch (targetPlatform) {
case 'mas':
case 'darwin':
return 'Electron.app/Contents/MacOS/Electron'
case 'freebsd':
case 'openbsd':
case 'linux':
return 'electron'
case 'win32':
return 'electron.exe'
default:
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
}
}
@@ -87,7 +87,7 @@ const parent = `${head}~1`
const CANDIDATES = [
'src/main/git/status.ts',
'src/shared/agent-hook-listener.ts',
'src/renderer/src/components/TaskPage.tsx'
'src/renderer/src/components/task-page/TaskPage.tsx'
]
const files = []
@@ -5,6 +5,17 @@ import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const headlessLinuxGuide = readFileSync('docs/reference/headless-linux-server.md', 'utf8')
const signalCase = readFileSync('config/docker/headless-serve-shutdown/run-signal-case.sh', 'utf8')
const shutdownDockerRunner = readFileSync(
'config/scripts/run-headless-serve-shutdown-docker.mjs',
'utf8'
)
const shutdownDockerfile = readFileSync('config/docker/headless-serve-shutdown/Dockerfile', 'utf8')
const desktopStartupOracle = readFileSync(
'config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh',
'utf8'
)
const headlessLinuxProse = headlessLinuxGuide.replace(/\s+/g, ' ')
function readSystemdUnitBlocks(doc, unitName) {
const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -40,16 +51,112 @@ describe('headless serve shutdown PR gate', () => {
).toThrow('Missing closing code fence for orca-serve.service')
})
it('packages an x64 AppImage before running the Docker signal oracle', () => {
it('packages Linux artifacts before running the Docker signal oracle', () => {
const steps = workflow.jobs.package.steps
const packageStep = steps.find((step) => step.name === 'Package unpacked app')
const markerStep = steps.find((step) => step.name === 'Verify root-package marker payloads')
const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown')
const launcherShutdownStep = steps.find(
(step) => step.name === 'Verify extracted launcher serve signal shutdown'
)
const appImageShutdownStep = steps.find(
(step) => step.name === 'Verify AppImage CLI registration and serve signal shutdown'
)
expect(packageStep.run).toContain('--linux AppImage --x64 --publish never')
expect(workflow.jobs.package['timeout-minutes']).toBe(90)
expect(packageStep.run).toContain('--linux AppImage deb rpm --x64 --publish never')
expect(markerStep.run).toContain('dpkg-deb --fsys-tarfile')
expect(markerStep.run).toContain('rpm2cpio')
expect(steps.indexOf(markerStep)).toBeGreaterThan(steps.indexOf(packageStep))
expect(shutdownStep.run).toBe(
'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage'
)
expect(launcherShutdownStep.run).toContain(
'node config/scripts/run-headless-serve-shutdown-docker.mjs'
)
expect(launcherShutdownStep.run).toContain('--entrypoint launcher')
expect(appImageShutdownStep.run).toContain('--entrypoint appimage')
expect(appImageShutdownStep.run).toContain('--signal-target serving-electron')
expect(appImageShutdownStep.run).toContain('--int-delivery pid')
expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep))
expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(markerStep))
expect(steps.indexOf(launcherShutdownStep)).toBeGreaterThan(steps.indexOf(shutdownStep))
expect(steps.indexOf(appImageShutdownStep)).toBeGreaterThan(steps.indexOf(launcherShutdownStep))
})
it('keeps readiness polling finite and leak-free', () => {
expect(signalCase).toContain('read_ready_line()')
expect(signalCase).toContain("sed -u -n 's/^[^{]*//p'")
expect(signalCase).toContain('startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-180}')
expect(signalCase).toContain('startup_deadline=$((SECONDS + startup_timeout_seconds))')
expect(signalCase).toContain('while (( SECONDS < startup_deadline )); do')
expect(signalCase).toContain('kill -0 "$app_pid" 2>/dev/null || break')
expect(signalCase).toContain(
"jq's `inputs` waits for EOF even when wrapped in `first`, so a tail -F"
)
expect(signalCase).not.toContain('tail --pid=')
})
it('gives owned shutdown state a bounded cleanup grace', () => {
expect(signalCase).toContain('for shutdown_poll in {0..50}; do')
expect(signalCase).toContain('[[ -z "$listener_after" && -z "$owned_residue" ]]')
expect(signalCase).toContain('((${#survivors[@]} == 0))')
expect(signalCase).toContain('((shutdown_poll < 50)) && sleep 0.1')
})
it('checks that a serving-electron signal target owns the ready socket', () => {
const ssRecord =
'LISTEN 0 128 127.0.0.1:41235 0.0.0.0:* users:(("orca-ide",pid=23,fd=7),("orca-ide",pid=25,fd=8))'
expect([...ssRecord.matchAll(/pid=([0-9]+)/g)].map((match) => match[1])).toEqual(['23', '25'])
expect(signalCase).toContain(
'listener_before_pids=$(grep -oE \'pid=[0-9]+\' <<<"$listener_before" | cut -d= -f2 || true)'
)
expect(signalCase).toContain('signal_target_pid=$(head -n1 <<<"$listener_before_pids")')
expect(signalCase).toContain('outside the entrypoint process tree')
})
it('runs the original AppImage desktop startup oracle before extraction and signals', () => {
expect(shutdownDockerfile).toContain(
'COPY run-appimage-desktop-startup-case.sh /usr/local/bin/run-appimage-desktop-startup-case'
)
const startupCall = shutdownDockerRunner.indexOf(
'runDesktopStartupOracle({ image, appImage, platform })'
)
const extractionCall = shutdownDockerRunner.indexOf(
"'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract"
)
const signalLoop = shutdownDockerRunner.indexOf("for (const signal of ['INT', 'TERM'])")
expect(startupCall).toBeGreaterThan(-1)
expect(extractionCall).toBeGreaterThan(startupCall)
expect(signalLoop).toBeGreaterThan(startupCall)
expect(shutdownDockerRunner).toContain("'/usr/local/bin/run-appimage-desktop-startup-case'")
})
it('preserves startup logs when the launcher exits before its marker', () => {
expect(desktopStartupOracle).toContain('signal_process_group TERM || true')
expect(desktopStartupOracle).toContain('signal_process_group KILL || true')
expect(desktopStartupOracle).toContain('cat "$stdout_log" >&2 2>/dev/null || true')
expect(desktopStartupOracle).toContain('cat "$stderr_log" >&2 2>/dev/null || true')
expect(desktopStartupOracle).toContain(
'FAIL: desktop launcher exited before ${reason} (status=${observed_status})'
)
expect(desktopStartupOracle).toContain('ORCA_STARTUP_STATE_DIR_CLEANUP=1')
expect(desktopStartupOracle).toContain(
'[[ "$state_dir" =~ ^/tmp/orca-appimage-startup\\.[^/]+$ ]] || return 0'
)
})
it('requires the bound AppImage to be executable before launch and extraction', () => {
expect(desktopStartupOracle).toContain(
'[[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; }'
)
expect(shutdownDockerRunner).toContain(
'\'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }\''
)
})
it('gives the original AppImage enough bounded extraction space', () => {
expect(shutdownDockerRunner).toContain("'/tmp:rw,nosuid,nodev,exec,size=1g'")
})
it('keeps owned Xvfb alive during the documented systemd graceful stop', () => {
@@ -63,4 +170,37 @@ describe('headless serve shutdown PR gate', () => {
expect(managedXvfbUnits).toHaveLength(1)
expect(managedXvfbUnits[0]).not.toMatch(/^KillMode=/m)
})
it('distinguishes persisted state from live work during a service restart', () => {
expect(headlessLinuxProse).toContain(
'Every `systemctl stop` or `restart` therefore ends live terminals and agent processes'
)
expect(headlessLinuxProse).toContain(
'These guarantees do not preserve live processes. The service restart kills every terminal and agent in its cgroup'
)
expect(headlessLinuxProse).toContain(
'A separately paired runtime is outside that boundary; local execution and SSH hosts reached through this runtime are not. An affected or unknown omission, missing scope, failed request or lost connection is `unverifiable`'
)
expect(headlessLinuxGuide).toContain(
'sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json'
)
expect(headlessLinuxGuide).not.toContain('sudo -Hu orca orca-ide terminal list --json')
expect(headlessLinuxGuide).not.toContain('Two facts make this safe and predictable')
})
it('uses the registered CLI name from ordinary Linux shells', () => {
const commandRule =
'The registered Linux CLI command is `orca-ide`, not `orca`, to avoid shadowing the GNOME Orca screen reader.'
const substitutionRule =
"From an ordinary shell outside that service user's managed environment, substitute `orca-ide` for `orca` in commands below."
const censusCommand = '`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`'
expect(headlessLinuxProse).toContain(commandRule)
expect(headlessLinuxProse).toContain(substitutionRule)
expect(headlessLinuxProse).toContain(censusCommand)
expect(headlessLinuxGuide).toContain('best-effort dispatcher at `$HOME/.local/bin/orca`')
expect(headlessLinuxProse.indexOf(substitutionRule)).toBeLessThan(
headlessLinuxProse.indexOf(censusCommand)
)
})
})
@@ -0,0 +1,210 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
const sourceScriptPath = fileURLToPath(
new URL('./install-electron-package-binary.mjs', import.meta.url)
)
/** Matches the fake package version and the platform/arch runInstallScript installs for. */
export const sharedEntryName = '41.5.0-linux-x64'
export const sharedEntryNameFor = (version) => `${version}-linux-x64`
export function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-install-electron-'))
copyScriptWithLocalModules(sourceScriptPath, join(projectDir, 'config', 'scripts'))
return projectDir
}
export function runInstallScript(projectDir, extraEnv = {}) {
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
cwd: projectDir,
encoding: 'utf8',
env: {
...process.env,
ELECTRON_CACHE: undefined,
ORCA_ELECTRON_PACKAGE_CACHE_ROOT: undefined,
npm_config_platform: 'linux',
npm_config_arch: 'x64',
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
...extraEnv
}
})
}
export function writeFakeElectronPackage(
projectDir,
{ lazyRequireMarker = null, version = '41.5.0' } = {}
) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(electronDir, { recursive: true })
writeFileSync(join(electronDir, 'package.json'), JSON.stringify({ name: 'electron', version }))
writeFileSync(join(electronDir, 'checksums.json'), '{}')
writeFileSync(
join(electronDir, 'index.js'),
`
const fs = require('node:fs')
const path = require('node:path')
${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''}
const pathFile = path.join(__dirname, 'path.txt')
if (!fs.existsSync(pathFile)) {
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
}
module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
`
)
}
export function writeFakeElectronDist(
projectDir,
{ version = 'v41.5.0', executableContents = '', pathContents } = {}
) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(join(electronDir, 'dist'), { recursive: true })
writeFileSync(join(electronDir, 'dist/version'), version)
writeFileSync(join(electronDir, 'dist/electron'), executableContents)
if (pathContents !== undefined) {
writeFileSync(join(electronDir, 'path.txt'), pathContents)
}
}
export function writeFakeElectronGet(
projectDir,
{
downloadNeverSettles = false,
downloadFailures = 0,
downloadErrorCode = 'ECONNRESET',
downloadHttpStatus = null
} = {}
) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
writeFileSync(
join(getDir, 'index.js'),
`
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
const { join } = require('node:path')
let downloadAttempt = 0
exports.downloadArtifact = async function downloadArtifact(details) {
downloadAttempt += 1
appendFileSync(
'electron-get.log',
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + ' force=' + details.force + '\\n'
)
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
}
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
if (${JSON.stringify(downloadHttpStatus)} != null) {
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
throw error
}
const cause = Object.assign(new Error('download failed'), {
code: ${JSON.stringify(downloadErrorCode)}
})
throw Object.assign(new TypeError('fetch failed'), { cause })
}
mkdirSync(details.cacheRoot, { recursive: true })
const artifactPath = join(details.cacheRoot, 'electron.zip')
writeFileSync(artifactPath, 'fake zip')
return artifactPath
}
`
)
}
export function writeFakeExtractor(projectDir, { createExecutable, version = '41.5.0' }) {
writeFileSync(
join(projectDir, 'fake-extractor.cjs'),
`
const { appendFileSync, mkdirSync, symlinkSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const extractDir = process.argv[3]
appendFileSync(join(__dirname, 'fake-extractor.log'), extractDir + '\\n')
mkdirSync(join(extractDir, 'locales'), { recursive: true })
if (${JSON.stringify(createExecutable)}) {
writeFileSync(join(extractDir, 'electron'), '')
writeFileSync(join(extractDir, 'electron.exe'), '')
writeFileSync(join(extractDir, 'electron.d.ts'), 'replacement types')
writeFileSync(join(extractDir, 'version'), ${JSON.stringify(`v${version}`)})
if (process.platform !== 'win32') {
symlinkSync('version', join(extractDir, 'version-link'))
}
}
`
)
}
export function writeTypeDefPublishFailurePreload(projectDir) {
const preloadPath = join(projectDir, 'type-def-publish-failure.cjs')
writeFileSync(
preloadPath,
`
const fs = require('node:fs')
const { syncBuiltinESMExports } = require('node:module')
const { basename, dirname } = require('node:path')
const renameSync = fs.renameSync
fs.renameSync = (source, target) => {
if (basename(source) === 'electron.d.ts' && basename(dirname(source)) === 'dist') {
const error = new Error('injected Electron type definition publish failure')
error.code = 'EACCES'
throw error
}
return renameSync(source, target)
}
syncBuiltinESMExports()
`
)
return preloadPath
}
export function initGitRepo(projectDir) {
runGit(projectDir, ['init', '--quiet', '--initial-branch=main'])
runGit(projectDir, ['config', 'user.email', 'orca-test@example.com'])
runGit(projectDir, ['config', 'user.name', 'Orca Test'])
runGit(projectDir, ['commit', '--quiet', '--allow-empty', '-m', 'init'])
}
export function addSiblingWorktree(projectDir, siblingDir) {
runGit(projectDir, ['worktree', 'add', '--quiet', '-b', 'sibling', siblingDir])
copyScriptWithLocalModules(sourceScriptPath, join(siblingDir, 'config', 'scripts'))
return siblingDir
}
function runGit(projectDir, args) {
execFileSync('git', ['-C', projectDir, ...args], { stdio: 'ignore' })
}
export function sharedCacheRoot(repoDir) {
return join(repoDir, '.git', 'orca-cache', 'electron')
}
export function readSharedDistMarker(projectDir) {
try {
return readFileSync(join(projectDir, 'node_modules/electron/.orca-shared-dist'), 'utf8')
} catch {
return null
}
}
export function readExtractorCallCount(projectDir) {
try {
return readFileSync(join(projectDir, 'fake-extractor.log'), 'utf8').trim().split('\n').length
} catch {
return 0
}
}
export function writeNonDarwinPlatformPreload(projectDir) {
const preloadPath = join(projectDir, 'non-darwin-platform.cjs')
writeFileSync(
preloadPath,
`
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
`
)
return preloadPath
}
@@ -15,6 +15,14 @@ import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { platform as osPlatform, tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { getElectronPlatformPath } from './electron-platform-path.mjs'
import {
shareElectronDistFromCache,
hasAdoptedSharedElectronDist,
publishSharedElectronDist,
recordAdoptedSharedElectronDist,
resolveSharedElectronDistEntry
} from './shared-electron-dist-cache.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
@@ -54,7 +62,18 @@ try {
async function main() {
repairElectronPathFile()
const sharedEntry = resolveSharedElectronDistEntry({
repoRoot: projectDir,
electronPackageDir,
version: electronVersion,
targetPlatform,
targetArch
})
if (electronPackageIsUsable()) {
if (sharedEntry !== null && !hasAdoptedSharedElectronDist(sharedEntry)) {
shareExistingElectronDist(sharedEntry)
}
return
}
@@ -62,7 +81,7 @@ async function main() {
// Node. Install only Electron's npm package binary here; do not run the full
// Electron native-module rebuild path, which would undo the Node ABI rebuild.
console.log('[electron-package] Electron package binary is missing; running Electron install.')
await installElectronPackageBinary()
await installElectronPackageBinary(sharedEntry)
repairElectronPathFile()
@@ -122,8 +141,11 @@ function repairElectronPathFile() {
}
}
async function installElectronPackageBinary() {
async function installElectronPackageBinary(sharedEntry) {
const electronDistDir = resolve(electronPackageDir, 'dist')
if (sharedEntry !== null && adoptSharedElectronDist(sharedEntry, electronDistDir)) {
return
}
const tempDir = mkdtempSync(resolve(tmpdir(), 'orca-electron-'))
const persistentCacheRoot =
process.env.ORCA_ELECTRON_PACKAGE_CACHE_ROOT || process.env.ELECTRON_CACHE || null
@@ -158,11 +180,73 @@ async function installElectronPackageBinary() {
}
moveExtractedElectronDist(extractDir, electronDistDir)
if (sharedEntry !== null) {
publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir)
}
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
}
/**
* Point this worktree's dist at the copy its siblings already share, so the ~295MB tree costs one
* allocation per repository instead of one per worktree.
*
* Staged inside node_modules/electron on purpose: clonefile only shares blocks within a volume, and
* staging elsewhere would silently downgrade the publish rename to a cross-device byte copy.
*/
function adoptSharedElectronDist(sharedEntry, electronDistDir) {
const stageRoot = mkdtempSync(resolve(electronPackageDir, '.dist-clone-'))
try {
const stagePath = join(stageRoot, 'dist')
if (
!shareElectronDistFromCache(sharedEntry, stagePath, {
version: electronVersion,
platformPath
})
) {
return false
}
moveExtractedElectronDist(stagePath, electronDistDir)
recordAdoptedSharedElectronDist(sharedEntry, writeFileSync)
console.log(
`[electron-package] Shared Electron ${electronVersion} from ${sharedEntry.entryPath}`
)
return true
} catch (error) {
// The download path below is always a correct fallback, so sharing never fails an install.
console.warn(`[electron-package] Shared Electron dist unavailable: ${formatShareError(error)}`)
return false
} finally {
rmSync(stageRoot, { recursive: true, force: true })
}
}
/** An already-installed dist joins the cache: clone from it if it exists, seed it otherwise. */
function shareExistingElectronDist(sharedEntry) {
const electronDistDir = resolve(electronPackageDir, 'dist')
if (!adoptSharedElectronDist(sharedEntry, electronDistDir)) {
publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir)
}
}
function publishElectronDistForSiblingWorktrees(sharedEntry, electronDistDir) {
const published = publishSharedElectronDist(electronDistDir, sharedEntry, {
version: electronVersion,
platformPath
})
if (published) {
console.log(
`[electron-package] Published Electron ${electronVersion} to ${sharedEntry.entryPath}`
)
recordAdoptedSharedElectronDist(sharedEntry, writeFileSync)
}
}
function formatShareError(error) {
return error instanceof Error ? error.message : String(error)
}
async function downloadElectronArtifactWithRetry(downloadOptions, { cacheRootIsPersistent }) {
const retryDelays = getDownloadRetryDelays()
@@ -438,19 +522,3 @@ function getElectronTargetPlatform() {
function getElectronTargetArch() {
return process.env.ELECTRON_INSTALL_ARCH || process.env.npm_config_arch || process.arch
}
function getElectronPlatformPath(targetPlatform) {
switch (targetPlatform) {
case 'mas':
case 'darwin':
return 'Electron.app/Contents/MacOS/Electron'
case 'freebsd':
case 'openbsd':
case 'linux':
return 'electron'
case 'win32':
return 'electron.exe'
default:
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
}
}
@@ -1,22 +1,32 @@
import {
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceScriptPath = fileURLToPath(
new URL('./install-electron-package-binary.mjs', import.meta.url)
)
import {
addSiblingWorktree,
initGitRepo,
mkTempProject,
readExtractorCallCount,
readSharedDistMarker,
runInstallScript,
sharedCacheRoot,
sharedEntryName,
sharedEntryNameFor,
writeFakeElectronDist,
writeFakeElectronGet,
writeFakeElectronPackage,
writeFakeExtractor,
writeNonDarwinPlatformPreload,
writeTypeDefPublishFailurePreload
} from './install-electron-package-binary-test-fixtures.mjs'
describe('install-electron-package-binary', () => {
it('installs Electron from an isolated cache and repairs path.txt', () => {
@@ -250,7 +260,9 @@ describe('install-electron-package-binary', () => {
expect(result.status, result.stderr).toBe(0)
expect(existsSync(join(cacheRoot, 'preserved.marker'))).toBe(true)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')).toHaveLength(2)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(2)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -401,6 +413,199 @@ describe('install-electron-package-binary', () => {
}
})
// The shared cache is macOS-only: it exists to avoid a second copy via APFS clonefile.
it('publishes a shared Electron dist entry after a fresh download', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, { CI: '' })
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
expect(result.status, result.stderr).toBe(0)
expect(lstatSync(entryPath).isDirectory()).toBe(true)
expect(lstatSync(entryPath).isSymbolicLink()).toBe(false)
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
expect(existsSync(join(entryPath, 'electron'))).toBe(true)
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
expect(result.stdout).toMatch(/Published Electron 41\.5\.0 to .*41\.5\.0-linux-x64$/m)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('shares the Electron dist into a sibling worktree without downloading', () => {
const projectDir = mkTempProject()
const siblingDir = `${projectDir}-sibling`
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
expect(runInstallScript(projectDir, { CI: '' }).status).toBe(0)
addSiblingWorktree(projectDir, siblingDir)
writeFakeElectronPackage(siblingDir)
writeFakeElectronGet(siblingDir)
writeFakeExtractor(siblingDir, { createExecutable: true })
const result = runInstallScript(siblingDir, { CI: '' })
const siblingDistDir = join(siblingDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(readExtractorCallCount(siblingDir)).toBe(0)
expect(existsSync(join(siblingDir, 'electron-get.log'))).toBe(false)
expect(lstatSync(siblingDistDir).isDirectory()).toBe(true)
expect(lstatSync(siblingDistDir).isSymbolicLink()).toBe(false)
expect(readFileSync(join(siblingDistDir, 'version'), 'utf8')).toBe('v41.5.0')
expect(existsSync(join(siblingDistDir, 'electron'))).toBe(true)
expect(readFileSync(join(siblingDir, 'node_modules/electron/path.txt'), 'utf8')).toBe(
'electron'
)
expect(readSharedDistMarker(siblingDir)).toBe(sharedEntryName)
expect(result.stdout).toContain('Shared Electron 41.5.0 from')
} finally {
rmSync(siblingDir, { recursive: true, force: true })
rmSync(projectDir, { recursive: true, force: true })
}
})
it('publishes an already installed Electron dist that predates the shared cache', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
writeFakeElectronDist(projectDir, {
executableContents: 'existing executable',
pathContents: 'electron'
})
const result = runInstallScript(projectDir, { CI: '' })
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
const distDir = join(projectDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(readExtractorCallCount(projectDir)).toBe(0)
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
expect(readFileSync(join(entryPath, 'electron'), 'utf8')).toBe('existing executable')
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
expect(readFileSync(join(distDir, 'electron'), 'utf8')).toBe('existing executable')
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('replaces a corrupt shared Electron dist entry instead of re-downloading forever', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
mkdirSync(entryPath, { recursive: true })
writeFileSync(join(entryPath, 'version'), 'v40.0.0')
writeFileSync(join(entryPath, 'electron'), 'stale executable')
const result = runInstallScript(projectDir, { CI: '' })
const distDir = join(projectDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Failed to install Electron package binary')
expect(readExtractorCallCount(projectDir)).toBe(1)
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
expect(readFileSync(join(projectDir, 'node_modules/electron/path.txt'), 'utf8')).toBe(
'electron'
)
// Why not just fall back: an entry left corrupt makes every sibling worktree download again.
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryName)
expect(readdirSync(sharedCacheRoot(projectDir))).toEqual([sharedEntryName])
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('hardlinks the shared Electron dist on a host without copy-on-write', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const preloadPath = writeNonDarwinPlatformPreload(projectDir)
const nonDarwinEnv = {
CI: '',
NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${preloadPath}`]
.filter(Boolean)
.join(' ')
}
const result = runInstallScript(projectDir, nonDarwinEnv)
const entryPath = join(sharedCacheRoot(projectDir), sharedEntryName)
const distDir = join(projectDir, 'node_modules/electron/dist')
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(join(distDir, 'version'), 'utf8')).toBe('v41.5.0')
expect(readFileSync(join(entryPath, 'version'), 'utf8')).toBe('v41.5.0')
// Why read-only: these are the same inodes, so an extract over dist would otherwise rewrite
// the cache and every sibling worktree at once.
expect(statSync(join(entryPath, 'electron')).mode & 0o222).toBe(0)
expect(statSync(join(entryPath, 'electron')).ino).toBe(
statSync(join(distDir, 'electron')).ino
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('gives an Electron upgrade its own cache entry and leaves the old one for other branches', () => {
const projectDir = mkTempProject()
try {
initGitRepo(projectDir)
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
expect(runInstallScript(projectDir, { CI: '' }).status).toBe(0)
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryNameFor('41.5.0'))
// Upgrade the pinned Electron, exactly as a branch bumping the dependency would.
writeFakeElectronPackage(projectDir, { version: '42.0.0' })
writeFakeExtractor(projectDir, { createExecutable: true, version: '42.0.0' })
const upgraded = runInstallScript(projectDir, { CI: '' })
const cacheRoot = sharedCacheRoot(projectDir)
expect(upgraded.status, upgraded.stderr).toBe(0)
expect(readFileSync(join(projectDir, 'node_modules/electron/dist/version'), 'utf8')).toBe(
'v42.0.0'
)
expect(readSharedDistMarker(projectDir)).toBe(sharedEntryNameFor('42.0.0'))
// Why the old entry stays: sibling worktrees on the previous branch still share it.
expect(readdirSync(cacheRoot).sort()).toEqual([
sharedEntryNameFor('41.5.0'),
sharedEntryNameFor('42.0.0')
])
expect(readFileSync(join(cacheRoot, sharedEntryNameFor('41.5.0'), 'version'), 'utf8')).toBe(
'v41.5.0'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('does not exit successfully when Electron download never settles', () => {
const projectDir = mkTempProject()
@@ -419,155 +624,3 @@ describe('install-electron-package-binary', () => {
}
})
})
function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-install-electron-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
copyFileSync(
sourceScriptPath,
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
)
return projectDir
}
function runInstallScript(projectDir, extraEnv = {}) {
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
cwd: projectDir,
encoding: 'utf8',
env: {
...process.env,
ELECTRON_CACHE: undefined,
ORCA_ELECTRON_PACKAGE_CACHE_ROOT: undefined,
npm_config_platform: 'linux',
npm_config_arch: 'x64',
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
...extraEnv
}
})
}
function writeFakeElectronPackage(projectDir, { lazyRequireMarker = null } = {}) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(electronDir, { recursive: true })
writeFileSync(
join(electronDir, 'package.json'),
JSON.stringify({ name: 'electron', version: '41.5.0' })
)
writeFileSync(join(electronDir, 'checksums.json'), '{}')
writeFileSync(
join(electronDir, 'index.js'),
`
const fs = require('node:fs')
const path = require('node:path')
${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''}
const pathFile = path.join(__dirname, 'path.txt')
if (!fs.existsSync(pathFile)) {
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
}
module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
`
)
}
function writeFakeElectronDist(
projectDir,
{ version = 'v41.5.0', executableContents = '', pathContents } = {}
) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(join(electronDir, 'dist'), { recursive: true })
writeFileSync(join(electronDir, 'dist/version'), version)
writeFileSync(join(electronDir, 'dist/electron'), executableContents)
if (pathContents !== undefined) {
writeFileSync(join(electronDir, 'path.txt'), pathContents)
}
}
function writeFakeElectronGet(
projectDir,
{
downloadNeverSettles = false,
downloadFailures = 0,
downloadErrorCode = 'ECONNRESET',
downloadHttpStatus = null
} = {}
) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
writeFileSync(
join(getDir, 'index.js'),
`
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
const { join } = require('node:path')
let downloadAttempt = 0
exports.downloadArtifact = async function downloadArtifact(details) {
downloadAttempt += 1
appendFileSync(
'electron-get.log',
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + ' force=' + details.force + '\\n'
)
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
}
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
if (${JSON.stringify(downloadHttpStatus)} != null) {
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
throw error
}
const cause = Object.assign(new Error('download failed'), {
code: ${JSON.stringify(downloadErrorCode)}
})
throw Object.assign(new TypeError('fetch failed'), { cause })
}
mkdirSync(details.cacheRoot, { recursive: true })
const artifactPath = join(details.cacheRoot, 'electron.zip')
writeFileSync(artifactPath, 'fake zip')
return artifactPath
}
`
)
}
function writeFakeExtractor(projectDir, { createExecutable }) {
writeFileSync(
join(projectDir, 'fake-extractor.cjs'),
`
const { mkdirSync, symlinkSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const extractDir = process.argv[3]
mkdirSync(join(extractDir, 'locales'), { recursive: true })
if (${JSON.stringify(createExecutable)}) {
writeFileSync(join(extractDir, 'electron'), '')
writeFileSync(join(extractDir, 'electron.exe'), '')
writeFileSync(join(extractDir, 'electron.d.ts'), 'replacement types')
writeFileSync(join(extractDir, 'version'), 'v41.5.0')
if (process.platform !== 'win32') {
symlinkSync('version', join(extractDir, 'version-link'))
}
}
`
)
}
function writeTypeDefPublishFailurePreload(projectDir) {
const preloadPath = join(projectDir, 'type-def-publish-failure.cjs')
writeFileSync(
preloadPath,
`
const fs = require('node:fs')
const { syncBuiltinESMExports } = require('node:module')
const { basename, dirname } = require('node:path')
const renameSync = fs.renameSync
fs.renameSync = (source, target) => {
if (basename(source) === 'electron.d.ts' && basename(dirname(source)) === 'dist') {
const error = new Error('injected Electron type definition publish failure')
error.code = 'EACCES'
throw error
}
return renameSync(source, target)
}
syncBuiltinESMExports()
`
)
return preloadPath
}
+5 -4
View File
@@ -1,5 +1,6 @@
import { existsSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
const SOURCE_FILE_PATTERN = /\.(?:[cm]?[jt]sx?)$/
@@ -31,11 +32,11 @@ if (lintTargets.length === 0) {
process.exit(0)
}
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const { command, prefixArgs } = resolveOxlintInvocation()
const result = spawnSync(
pnpm,
['exec', 'oxlint', '--config', 'config/oxlint-react-doctor.json', ...lintTargets],
{ stdio: 'inherit' }
command,
[...prefixArgs, '--config', 'config/oxlint-react-doctor.json', ...lintTargets],
{ stdio: 'inherit', windowsHide: true }
)
if (result.error) {
@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
describe('Linux package maintainer scripts', () => {
it('keeps upgrades from removing the installed CLI', () => {
const script = readFileSync(
new URL('../../resources/linux/packaging/after-remove.sh', import.meta.url),
'utf8'
)
const unlinkStart = script.indexOf('link="/usr/bin/orca-ide"')
const upgradeGuard = script.slice(0, unlinkStart)
expect(unlinkStart).toBeGreaterThan(-1)
expect(upgradeGuard).toContain('case "${1-}" in')
expect(upgradeGuard).toContain('0 | remove | purge) ;;')
expect(upgradeGuard).toContain('*) exit 0 ;;')
})
})
@@ -3,11 +3,10 @@ import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
const pluginPath = path.resolve('config/oxlint-plugins/mobile-pairing-qrcode-import.mjs')
const oxlintPath = path.resolve(
process.platform === 'win32' ? 'node_modules/.bin/oxlint.cmd' : 'node_modules/.bin/oxlint'
)
const oxlint = resolveOxlintInvocation()
function lintSource(source) {
const directory = mkdtempSync(path.join(tmpdir(), 'orca-qrcode-import-lint-'))
@@ -22,9 +21,11 @@ function lintSource(source) {
rules: { 'mobile-pairing/no-eager-qrcode-import': 'error' }
})
)
const result = spawnSync(oxlintPath, ['--config', configPath, '--format', 'json', sourcePath], {
encoding: 'utf8'
})
const result = spawnSync(
oxlint.command,
[...oxlint.prefixArgs, '--config', configPath, '--format', 'json', sourcePath],
{ encoding: 'utf8', windowsHide: true }
)
if (result.error) {
throw result.error
}
@@ -0,0 +1,229 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
SKIP_MARKER_FILENAME,
applyNodePtyMasterCloexecPatch,
assertPatchedNodePtyMasterCloexecSource,
patchNodePtyMasterCloexecSource,
revertNodePtyMasterCloexecSource
} = require('../relay-assets/node-pty-1.1.0-master-cloexec-patch.cjs')
// Byte-exact src/unix/pty.cc from the npm tarball the relay installs. The patch is keyed by its
// sha256, so a fixture that drifted from what npm ships would make every assertion below vacuous.
const STOCK_SOURCE = readFileSync(
resolve(import.meta.dirname, '__fixtures__', 'node-pty-1.1.0-unix-pty.cc'),
'utf8'
)
const projectDir = resolve(import.meta.dirname, '..', '..')
const cleanupDirs = []
afterEach(() => {
for (const dir of cleanupDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
describe('SSH relay node-pty pty-master close-on-exec patch', () => {
it('adds the forkpty close-on-exec call and reverts to the published bytes', () => {
const fixture = writeRelayFixture()
expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(true)
const patched = readFileSync(fixture.sourcePath, 'utf8')
expect(patched).toContain('pty_cloexec(int fd)')
expect(patched).toContain('if (pty_cloexec(master) == -1)')
expect(() => assertPatchedNodePtyMasterCloexecSource(fixture.root)).not.toThrow()
expect(patchNodePtyMasterCloexecSource(fixture.root)).toBe(false)
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(patched)
expect(revertNodePtyMasterCloexecSource(fixture.root)).toBe(true)
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
})
it('refuses a different node-pty version or an unrecognized source', () => {
const wrongVersion = writeRelayFixture({ version: '1.2.0-beta.4' })
expect(() => patchNodePtyMasterCloexecSource(wrongVersion.root)).toThrow('expected 1.1.0')
const drifted = writeRelayFixture({
source: `${STOCK_SOURCE}\n// drift\n`
})
expect(() => patchNodePtyMasterCloexecSource(drifted.root)).toThrow('unexpected node-pty')
const tampered = writeRelayFixture()
patchNodePtyMasterCloexecSource(tampered.root)
writeFileSync(tampered.sourcePath, `${readFileSync(tampered.sourcePath, 'utf8')}\n// drift\n`)
expect(() => assertPatchedNodePtyMasterCloexecSource(tampered.root)).toThrow('not installed')
})
it('keeps the rebuilt addon once a later child no longer inherits the master', () => {
const fixture = writeRelayFixture()
const calls = []
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => {
calls.push('rebuild')
writeBuild(fixture, 'patched-build')
},
verify: () => 'isolated'
})
expect(status).toBe('patched')
expect(calls).toEqual(['rebuild'])
expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build')
expect(readFileSync(fixture.sourcePath, 'utf8')).not.toBe(STOCK_SOURCE)
expect(existsSync(fixture.backupDir)).toBe(false)
expect(existsSync(fixture.skipMarkerPath)).toBe(false)
})
it('keeps a rebuilt addon whose flag /proc could not confirm', () => {
const fixture = writeRelayFixture()
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => writeBuild(fixture, 'patched-build'),
verify: () => 'unverified'
})
expect(status).toBe('patched-unverified')
expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build')
})
it('restores the working build when the compile fails, and never retries it', () => {
const fixture = writeRelayFixture()
const calls = []
const failed = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => {
calls.push('rebuild')
throw new Error('npm rebuild node-pty exited 1: no C++ toolchain')
},
verify: () => 'isolated'
})
expect(failed).toContain('failed:')
expect(failed).toContain('no C++ toolchain')
expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build')
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
expect(existsSync(fixture.backupDir)).toBe(false)
expect(existsSync(fixture.skipMarkerPath)).toBe(true)
// Bounded, not backed off: a relay directory gets one compile attempt, ever.
const again = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => calls.push('rebuild'),
verify: () => 'isolated'
})
expect(again).toBe('skipped:earlier-attempt-failed')
expect(calls).toEqual(['rebuild'])
})
it('restores the working build when the rebuilt addon still leaks the master', () => {
const fixture = writeRelayFixture()
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => writeBuild(fixture, 'still-leaky-build'),
verify: () => {
throw new Error('rebuilt node-pty still leaks the pty master into later children')
}
})
expect(status).toContain('still leaks')
expect(readFileSync(fixture.buildPath, 'utf8')).toBe('stock-build')
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
})
it('never compiles on a platform that does not leak', () => {
for (const platform of ['darwin', 'win32']) {
const fixture = writeRelayFixture()
const calls = []
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform,
rebuild: () => calls.push('rebuild'),
verify: () => 'isolated'
})
expect(status).toBe('skipped:not-linux')
expect(calls).toEqual([])
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
}
})
it('leaves an already patched install alone', () => {
const fixture = writeRelayFixture()
patchNodePtyMasterCloexecSource(fixture.root)
const calls = []
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => calls.push('rebuild'),
verify: () => 'isolated'
})
expect(status).toBe('already-patched')
expect(calls).toEqual([])
})
it('will not rebuild an install that has no compiled addon to fall back on', () => {
const fixture = writeRelayFixture({ build: false })
const calls = []
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => calls.push('rebuild'),
verify: () => 'isolated'
})
expect(status).toBe('skipped:no-compiled-build')
expect(calls).toEqual([])
expect(readFileSync(fixture.sourcePath, 'utf8')).toBe(STOCK_SOURCE)
})
it('discards a backup stranded by an interrupted rebuild', () => {
const fixture = writeRelayFixture()
mkdirSync(fixture.backupDir, { recursive: true })
writeFileSync(join(fixture.backupDir, 'pty.node'), 'stranded-build')
const status = applyNodePtyMasterCloexecPatch(fixture.root, {
platform: 'linux',
rebuild: () => writeBuild(fixture, 'patched-build'),
verify: () => 'isolated'
})
expect(status).toBe('patched')
expect(existsSync(fixture.backupDir)).toBe(false)
expect(readFileSync(fixture.buildPath, 'utf8')).toBe('patched-build')
})
})
function writeRelayFixture({ version = '1.1.0', source = STOCK_SOURCE, build = true } = {}) {
const root = mkdtempSync(join(projectDir, '.node-pty-cloexec-patch-test-'))
cleanupDirs.push(root)
const nodePtyDir = join(root, 'node_modules', 'node-pty')
const sourcePath = join(nodePtyDir, 'src', 'unix', 'pty.cc')
const buildPath = join(nodePtyDir, 'build', 'Release', 'pty.node')
mkdirSync(join(nodePtyDir, 'src', 'unix'), { recursive: true })
writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version }))
writeFileSync(sourcePath, source)
const fixture = {
root,
sourcePath,
buildPath,
backupDir: join(nodePtyDir, '.orca-cloexec-prepatch-release'),
skipMarkerPath: join(root, SKIP_MARKER_FILENAME)
}
if (build) {
writeBuild(fixture, 'stock-build')
}
return fixture
}
function writeBuild(fixture, contents) {
mkdirSync(resolve(fixture.buildPath, '..'), { recursive: true })
writeFileSync(fixture.buildPath, contents)
}
@@ -18,6 +18,24 @@ function readSkill(path = guidePath) {
}
describe('orca CLI skill guidance', () => {
it('keeps external browser routing at the OS/page boundary', () => {
const skill = readSkill(guidePath)
const description = skill.replace(/\s+/gu, ' ')
expect(description).toContain(
'Use Computer Use for external browser windows, webviews, or desktop UI only when the task requires OS/window-level control such as focus, menus, dialogs, coordinates, or screenshots.'
)
expect(description).toContain(
"`orca-cli` for Orca's embedded pages and a page-automation tool such as Playwright or CDP for external pages."
)
expect(skill).toContain(
'For external Chrome/Safari/webviews or Orca app chrome/settings, use the Computer Use skill/tool only when the task requires OS/window-level control'
)
expect(skill).toContain(
"Use `orca-cli` for Orca's embedded pages and a page-automation tool such as Playwright or CDP for external pages"
)
})
it('keeps independent worktree lineage separate from Git base selection', () => {
const skill = readSkill()
@@ -0,0 +1,42 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const operationsGuide = readFileSync('docs/reference/orcad-operations.md', 'utf8')
const operationsProse = operationsGuide.replace(/\s+/g, ' ')
describe('orcad operations restart safety', () => {
it('distinguishes PID-scoped preservation from systemd cgroup teardown', () => {
expect(operationsProse).toContain(
'This makes a PID-scoped update, rollback or restart non-destructive to live work'
)
expect(operationsProse).toContain(
'The successor adopts the current endpoint and routes supported previous protocol versions through legacy adapters'
)
expect(operationsProse).toContain('`KillMode=mixed` does **not** preserve them')
expect(operationsProse).toContain(
'`KillMode=process` leaves service-owned processes unmanaged and is not a supported preservation mechanism'
)
})
it('fails closed before cgroup-wide maintenance', () => {
expect(operationsProse).toContain(
'A safe empty census is untruncated, has an explicit `hostScope`, covers every execution host affected by the stop, and lists no terminals on those hosts'
)
expect(operationsProse).toContain(
"Every `omittedHostIds` entry must be explicitly accounted for outside the target service's execution boundary"
)
expect(operationsProse).toContain(
'`sudo -Hu orca /home/orca/.local/bin/orca-ide terminal list --json`'
)
expect(operationsGuide).not.toContain('sudo -Hu orca orca-ide terminal list --json')
expect(operationsProse).toContain(
'A separately paired runtime is outside that boundary; local execution and SSH hosts reached through this runtime are not. An affected or unknown omission, missing scope, truncation, a failed request or lost contact makes the result `unverifiable`'
)
expect(operationsProse).toContain('Orca does not yet provide an atomic census-and-stop fence')
})
it('does not refer to the unavailable shipping design', () => {
expect(operationsGuide).not.toContain('docs/design/shipping-orcad.html')
})
})
@@ -25,6 +25,17 @@ function getSection(markdown, heading) {
}
describe('orchestration skill guidance', () => {
it('keeps external browser routing at the OS/page boundary', () => {
const description = readFileSync(guidePath, 'utf8').replace(/\s+/gu, ' ')
expect(description).toContain(
"Use Computer Use for external browser windows, webviews, Orca app UI, or desktop UI outside Orca's embedded browser only when the task requires OS/window-level control such as focus, menus, dialogs, coordinates, or screenshots."
)
expect(description).toContain(
"`orca-cli` for Orca's embedded pages and a page-automation tool such as Playwright or CDP for external pages."
)
})
it('requires Orca runtime state before claiming a worker was orchestrated', () => {
const skill = readSkill()
const toolBoundary = getSection(skill, 'Tool Boundary')
+23
View File
@@ -0,0 +1,23 @@
import { createRequire } from 'node:module'
import path from 'node:path'
import process from 'node:process'
// Why not `pnpm exec oxlint` / `node_modules/.bin/oxlint.cmd`: both land on a
// Windows .cmd shim, and Node >= 20 refuses to spawn one without `shell: true`
// (the CVE-2024-27980 mitigation), so every lint gate died with EINVAL before
// linting anything. Oxlint's bin is a plain Node script, so run it under this
// process's own node — no shim, no shell, no quoting question.
export function resolveOxlintInvocation(root = process.cwd()) {
const requireFromRoot = createRequire(path.join(root, 'package.json'))
// Oxlint's "exports" hides ./bin, so read the manifest and walk to its bin entry.
const manifestPath = requireFromRoot.resolve('oxlint/package.json')
const binField = requireFromRoot('oxlint/package.json').bin
const binEntry = typeof binField === 'string' ? binField : binField?.oxlint
if (!binEntry) {
throw new Error('oxlint package.json declares no "oxlint" bin entry.')
}
return {
command: process.execPath,
prefixArgs: [path.resolve(path.dirname(manifestPath), binEntry)]
}
}
@@ -0,0 +1,33 @@
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { describe, expect, it } from 'vitest'
import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
describe('resolveOxlintInvocation', () => {
it('runs oxlint under this process node, never through a shim', () => {
const { command, prefixArgs } = resolveOxlintInvocation(repoRoot)
expect(command).toBe(process.execPath)
expect(prefixArgs).toHaveLength(1)
// The EINVAL that killed the changed-code gate came from spawning a .cmd.
expect(prefixArgs[0]).not.toMatch(/\.(cmd|bat)$/i)
expect(existsSync(prefixArgs[0])).toBe(true)
})
it('spawns without a shell and produces Oxlint JSON', () => {
const { command, prefixArgs } = resolveOxlintInvocation(repoRoot)
const result = spawnSync(
command,
[...prefixArgs, '--help'],
// shell:false is the point: the shim form throws EINVAL here on Windows.
{ cwd: repoRoot, encoding: 'utf8', shell: false, windowsHide: true }
)
expect(result.error).toBeUndefined()
expect(result.stdout).toContain('oxlint')
})
})
@@ -363,6 +363,10 @@ describe('Electron runtime package contract', () => {
expect(afterInstallScript).toContain('chrome-sandbox')
expect(afterInstallScript).toContain('chmod 4755 "$sandbox"')
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
expect(afterInstallScript).toContain('is_owned_link()')
expect(afterInstallScript).toContain('readlink -f -- "$link"')
expect(afterInstallScript).toContain('[ ! -e "$link" ] && [ ! -L "$link" ]')
expect(afterInstallScript).not.toContain('[ ! -e "$link" ] || [ -L "$link" ]')
})
it('advances only the skill release ledger in a taggable release-cut commit', () => {
@@ -651,6 +655,8 @@ describe('Electron runtime package contract', () => {
expect(releaseWindowsRunStep.run).toContain(
'pnpm run --if-present test:e2e:windows-fresh-startup-golden'
)
expect(releaseWindowsRunStep.run).not.toContain('test:e2e:workspace-session-golden')
expect(releaseWindowsRunStep.run).not.toContain('test:e2e:source-control-golden')
expect(releaseEvidenceJob['continue-on-error']).toBe(true)
expect(
releaseEvidenceJob.strategy.matrix.include.map(({ platform }) => platform).sort()
+7
View File
@@ -167,6 +167,7 @@ const SHARED_PACKAGE_PREFIXES = [
'config/scripts/smoke-packaged',
'config/scripts/install-electron-package-binary',
'config/scripts/verify-packaged',
'config/scripts/verify-skills-cli-runtime',
'config/scripts/verify-linux-glibc',
'config/scripts/run-electron-vite',
'skills/',
@@ -180,6 +181,12 @@ const SHARED_PACKAGE_PREFIXES = [
const LINUX_PACKAGE_PREFIXES = [
...SHARED_PACKAGE_PREFIXES,
'config/docker/cli-launch-contract/',
'config/docker/headless-pairing/',
'config/docker/headless-serve-shutdown/',
'config/scripts/run-linux-cli-launch-contract',
'config/scripts/run-headless-linux-pairing-docker',
'config/scripts/static-appimage-package-contract',
'native/computer-use-linux/',
'resources/linux/',
'config/scripts/run-headless-serve'
@@ -181,6 +181,28 @@ describe('per-job path classification', () => {
expectClassification(['native/computer-use-macos/Package.swift'], {})
})
it('runs Linux packaging when an artifact contract changes', () => {
for (const file of [
'config/docker/cli-launch-contract/Dockerfile',
'config/docker/cli-launch-contract/run-cli-case.sh',
'config/docker/headless-pairing/Dockerfile',
'config/docker/headless-pairing/run-appimage-case.sh',
'config/docker/headless-serve-shutdown/Dockerfile',
'config/scripts/run-linux-cli-launch-contract-docker.mjs',
'config/scripts/run-headless-linux-pairing-docker.mjs',
'config/scripts/static-appimage-package-contract.cjs'
]) {
expectClassification([file], { package: true })
}
})
it('runs both package jobs when the shared skills runtime verifier changes', () => {
expectClassification(['config/scripts/verify-skills-cli-runtime.cjs'], {
package: true,
package_windows: true
})
})
it('runs shell contracts when live-shell inputs change', () => {
expectClassification(['src/main/daemon/shell-ready.ts'], {
shell_contracts: true,
+5 -1
View File
@@ -114,6 +114,7 @@ describe('PR E2E gate contract', () => {
expect(prWorkflow.jobs['e2e-paths'].outputs.test_files).toBe(
'${{ steps.filter.outputs.test_files }}'
)
expect(prWorkflow.jobs.e2e.with.ref).toBe('${{ github.event.pull_request.head.sha }}')
expect(prWorkflow.jobs.e2e.with.test_files).toBe('${{ needs.e2e-paths.outputs.test_files }}')
})
@@ -266,6 +267,7 @@ describe('PR E2E gate contract', () => {
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
@@ -316,7 +318,9 @@ describe('PR E2E gate contract', () => {
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
).toEqual([])
expect(
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-target-sync-test-harness.ts'])
selectPrE2eSpecs([
'src/renderer/src/hooks/__tests__/remote-workspace-target-sync-test-harness.ts'
])
).toEqual([])
})
+3
View File
@@ -26,7 +26,10 @@ export const PR_E2E_SOURCE_ROUTES = [
specs: [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-half-open-link.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-docker-resource-accumulation.spec.ts',
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
@@ -102,8 +102,13 @@ describe('PR workflow parallelism', () => {
.split(/\s+/)
.filter((token) => !['apt-get', 'install', 'sudo', ''].includes(token))
.filter((token) => !token.startsWith('-'))
const jobsInstallingPackages = Object.entries(workflow.jobs)
.filter(([, job]) => (job.steps ?? []).some((step) => aptPackages(step).length > 0))
const requiredShells = ['zsh', 'fish']
const jobsInstallingShells = Object.entries(workflow.jobs)
.filter(([, job]) =>
(job.steps ?? []).some((step) =>
aptPackages(step).some((packageName) => requiredShells.includes(packageName))
)
)
.map(([name]) => name)
expect(shellStep).toBeDefined()
@@ -111,11 +116,11 @@ describe('PR workflow parallelism', () => {
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
// Why the whole workflow, not just the general shards: any other lane installing
// these shells would silently start running the real-shell tests twice.
expect(jobsInstallingPackages).toEqual(['shell_contracts'])
expect(jobsInstallingShells).toEqual(['shell_contracts'])
// Why each shell is asserted: the live tests skip themselves when the binary is
// missing, so a dropped package silently empties this lane instead of failing it.
const shellPackages = workflow.jobs.shell_contracts.steps.flatMap(aptPackages)
for (const shell of ['zsh', 'fish']) {
for (const shell of requiredShells) {
expect(shellPackages).toContain(shell)
}
expect(shellInstall.with['native-runtime']).toBe('node')
@@ -1,4 +1,5 @@
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
@@ -44,7 +45,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
)
expect(existsSync(rebuildLogPath)).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -80,7 +81,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
)
).toBe('// napi.h\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -104,7 +105,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
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 })
removeTreeSync(projectDir)
}
})
@@ -132,7 +133,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
expect(rebuildCall.onlyModules).toEqual(['windows-native-registry'])
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -162,7 +163,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim())
expect(rebuildCall.onlyModules).toEqual(['node-pty'])
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -193,7 +194,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
expect(rebuildCall.ignoreModules).toEqual(['cpu-features'])
expect(rebuildCall.force).toBe(true)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -221,7 +222,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
)
expect(existsSync(rebuildLogPath)).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -251,7 +252,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
expect(rebuildCall.onlyModules).toEqual(['node-pty'])
expect(rebuildCall.force).toBe(true)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
}
)
@@ -3,6 +3,7 @@ import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from '
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', import.meta.url))
const sourceInstallScriptPath = fileURLToPath(
@@ -19,10 +20,7 @@ export function mkTempProject() {
const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-'))
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
copyFileSync(sourceScriptPath, join(projectDir, 'config', 'scripts', 'rebuild-native-deps.mjs'))
copyFileSync(
sourceInstallScriptPath,
join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs')
)
copyScriptWithLocalModules(sourceInstallScriptPath, join(projectDir, 'config', 'scripts'))
copyFileSync(
sourceNodePtyJobOwnershipPath,
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
+10 -9
View File
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal.ts'
import {
mkTempProject,
@@ -36,7 +37,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'download attempted\n'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -60,7 +61,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'Continuing postinstall because Electron binary installation failed'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -81,7 +82,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'Continuing postinstall because Electron binary installation failed'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -117,7 +118,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'stale-path'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -141,7 +142,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'platform=linux arch=arm64\ndownload attempted\n'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -162,7 +163,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
expect(result.status, result.stderr).toBe(0)
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -188,7 +189,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'electron.exe'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -209,7 +210,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
'platform=linux arch=x64'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
@@ -230,7 +231,7 @@ describe('rebuild-native-deps Electron install fallback', () => {
expect(result.stdout).toContain('Repaired Electron path.txt -> electron')
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
removeTreeSync(projectDir)
}
})
})
@@ -0,0 +1,131 @@
#!/usr/bin/env node
// Removes idle `out/electron-dev` bundles across every worktree of a repository.
//
// The dev runner already prunes these, but only within the worktree it is starting and only when
// that worktree holds more than one bundle -- and a worktree almost always holds exactly one. So
// nothing ever reclaims a bundle belonging to a worktree you are not currently running, and one
// ~275MB copy per branch accumulates indefinitely.
//
// Bundles are pure build output: `pnpm dev` rebuilds one on demand, and since the Electron dist is
// now shared, rebuilding is cheap.
import { execFileSync } from 'node:child_process'
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
import path from 'node:path'
import {
DEV_BUNDLE_MARKER_FILENAME,
getDevBundleProcessTable,
selectStaleDevBundleDirs
} from './dev-electron-bundle-cache.mjs'
const apply = process.argv.includes('--apply')
const repoRoot = process.argv.includes('--repo')
? path.resolve(process.argv[process.argv.indexOf('--repo') + 1])
: process.cwd()
function listWorktrees(root) {
const raw = execFileSync('git', ['-C', root, 'worktree', 'list', '--porcelain'], {
encoding: 'utf8'
})
return raw
.split('\n')
.filter((line) => line.startsWith('worktree '))
.map((line) => line.slice('worktree '.length).trim())
}
function measure(targetPath) {
let total = 0
let entries
try {
entries = readdirSync(targetPath, { withFileTypes: true })
} catch {
return 0
}
for (const entry of entries) {
const entryPath = path.join(targetPath, entry.name)
if (entry.isDirectory()) {
total += measure(entryPath)
} else if (!entry.isSymbolicLink()) {
total += statSync(entryPath, { throwIfNoEntry: false })?.size ?? 0
}
}
return total
}
export function collectDevBundles(worktree) {
const root = path.join(worktree, 'out', 'electron-dev')
try {
return readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const dir = path.join(root, entry.name)
return {
dir,
hasMarker: existsSync(path.join(dir, DEV_BUNDLE_MARKER_FILENAME)),
mtimeMs: statSync(dir, { throwIfNoEntry: false })?.mtimeMs ?? 0
}
})
} catch {
return []
}
}
function main() {
// The patched dev bundle is only built on macOS; elsewhere the dev app runs from dist directly.
if (process.platform !== 'darwin') {
console.log('No dev Electron bundles on this platform; nothing to reclaim.')
return
}
const processTable = getDevBundleProcessTable()
if (processTable === null) {
// Same rule the dev runner uses: no process table means we cannot prove a bundle is idle.
console.error('Could not read the process table; refusing to guess which bundles are idle.')
process.exitCode = 1
return
}
const bundles = listWorktrees(repoRoot).flatMap((worktree) => collectDevBundles(worktree))
// currentDir is null on purpose: unlike the dev runner, this sweep is not about to launch anything,
// so the only thing protecting a bundle is a live process or an in-flight build.
const stale = selectStaleDevBundleDirs({
bundles,
currentDir: null,
processTable,
nowMs: Date.now()
})
let reclaimed = 0
let removed = 0
for (const dir of stale) {
const size = measure(dir)
if (!apply) {
console.log(`would remove ${dir} ${(size / 1024 ** 3).toFixed(2)} GiB`)
reclaimed += size
removed += 1
continue
}
try {
rmSync(dir, { recursive: true, force: true })
reclaimed += size
removed += 1
console.log(`removed ${dir} ${(size / 1024 ** 3).toFixed(2)} GiB`)
} catch (error) {
console.warn(`skip ${dir} (${error instanceof Error ? error.message : String(error)})`)
}
}
const inUse = bundles.length - stale.length
console.log(
`\n${apply ? 'Removed' : 'Would remove'} ${removed} bundle(s); ` +
`${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` +
`${inUse > 0 ? `; left ${inUse} in use or still building` : ''}` +
`${apply ? '' : '\nRe-run with --apply to do it.'}`
)
}
// Guarded so importing this module for tests does not sweep the whole repository.
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
main()
}
@@ -0,0 +1,97 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
DEV_BUNDLE_MARKER_FILENAME,
getDevBundleProcessTable,
selectStaleDevBundleDirs
} from './dev-electron-bundle-cache.mjs'
import { collectDevBundles } from './reclaim-dev-electron-bundles.mjs'
const roots: string[] = []
afterEach(() => {
while (roots.length > 0) {
rmSync(roots.pop()!, { recursive: true, force: true })
}
})
function makeWorktree(bundles: { name: string; marker: boolean }[]): string {
const worktree = mkdtempSync(path.join(tmpdir(), 'orca-dev-bundles-'))
roots.push(worktree)
for (const bundle of bundles) {
const dir = path.join(worktree, 'out', 'electron-dev', bundle.name)
mkdirSync(dir, { recursive: true })
if (bundle.marker) {
writeFileSync(path.join(dir, DEV_BUNDLE_MARKER_FILENAME), '{}')
}
}
return worktree
}
describe('collectDevBundles', () => {
it('reports each bundle and whether its build finished', () => {
const worktree = makeWorktree([
{ name: 'aaaa', marker: true },
{ name: 'bbbb', marker: false }
])
const bundles = collectDevBundles(worktree).sort((a, b) => a.dir.localeCompare(b.dir))
expect(bundles).toHaveLength(2)
expect(bundles[0].hasMarker).toBe(true)
expect(bundles[1].hasMarker).toBe(false)
expect(bundles[0].mtimeMs).toBeGreaterThan(0)
})
it('returns nothing for a worktree that has never run the dev app', () => {
const worktree = mkdtempSync(path.join(tmpdir(), 'orca-dev-bundles-'))
roots.push(worktree)
expect(collectDevBundles(worktree)).toEqual([])
})
})
describe('sweeping across worktrees', () => {
it('spares a bundle a live process is running from, and takes the idle ones', () => {
const worktree = makeWorktree([
{ name: 'live', marker: true },
{ name: 'idle', marker: true }
])
const bundles = collectDevBundles(worktree)
const live = bundles.find((bundle) => bundle.dir.endsWith('live'))!
// Why currentDir is null here: unlike the dev runner, the sweep is not about to launch
// anything, so only a live process or an in-flight build may protect a bundle.
const stale = selectStaleDevBundleDirs({
bundles,
currentDir: null,
processTable: `/usr/bin/foo ${live.dir}/Orca.app/Contents/MacOS/Electron`,
nowMs: Date.now()
})
expect(stale).toEqual([bundles.find((bundle) => bundle.dir.endsWith('idle'))!.dir])
})
it('spares a build still in flight, which has no marker yet', () => {
const worktree = makeWorktree([{ name: 'building', marker: false }])
const stale = selectStaleDevBundleDirs({
bundles: collectDevBundles(worktree),
currentDir: null,
processTable: '',
nowMs: Date.now()
})
expect(stale).toEqual([])
})
})
describe('getDevBundleProcessTable', () => {
it('returns null rather than an empty table when ps fails', () => {
expect(
getDevBundleProcessTable(() => {
throw new Error('ps unavailable')
})
).toBeNull()
})
it('reads the real process table on this host', () => {
const table = getDevBundleProcessTable()
expect(typeof table === 'string' || table === null).toBe(true)
})
})
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
// Converts worktrees that already have their own Electron dist over to the shared cache.
// A normal install only shares when Electron is (re)installed, and an existing healthy worktree
// never reaches that path -- so without this, sharing only arrives at the next Electron upgrade.
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import {
existsSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import path from 'node:path'
import {
hasAdoptedSharedElectronDist,
isUsableElectronDist,
publishSharedElectronDist,
recordAdoptedSharedElectronDist,
resolveSharedElectronDistEntry,
shareElectronDistFromCache
} from './shared-electron-dist-cache.mjs'
import { getElectronPlatformPath } from './electron-platform-path.mjs'
const apply = process.argv.includes('--apply')
const repoRoot = process.argv.includes('--repo')
? path.resolve(process.argv[process.argv.indexOf('--repo') + 1])
: process.cwd()
function listWorktrees(root) {
const raw = execFileSync('git', ['-C', root, 'worktree', 'list', '--porcelain'], {
encoding: 'utf8'
})
return raw
.split('\n')
.filter((line) => line.startsWith('worktree '))
.map((line) => line.slice('worktree '.length).trim())
}
/** Apparent size, walked in Node: `du` does not exist on Windows, where it silently reported 0. */
function measure(targetPath) {
let total = 0
let entries
try {
entries = readdirSync(targetPath, { withFileTypes: true })
} catch {
return 0
}
for (const entry of entries) {
const entryPath = path.join(targetPath, entry.name)
if (entry.isDirectory()) {
total += measure(entryPath)
} else if (!entry.isSymbolicLink()) {
total += statSync(entryPath, { throwIfNoEntry: false })?.size ?? 0
}
}
return total
}
/** Swap in a shared copy behind a rename, so an interrupted run never leaves a partial dist. */
function adoptInto(distPath, entry, identity) {
const stagePath = `${distPath}.reclaim-${process.pid}-${randomUUID()}`
if (!shareElectronDistFromCache(entry, stagePath, identity)) {
rmSync(stagePath, { recursive: true, force: true })
return false
}
const previousPath = `${distPath}.previous-${process.pid}-${randomUUID()}`
renameSync(distPath, previousPath)
try {
renameSync(stagePath, distPath)
} catch (error) {
renameSync(previousPath, distPath)
rmSync(stagePath, { recursive: true, force: true })
throw error
}
rmSync(previousPath, { recursive: true, force: true })
return true
}
function main() {
let reclaimed = 0
let converted = 0
let skipped = 0
for (const worktree of listWorktrees(repoRoot)) {
const electronPackageDir = path.join(worktree, 'node_modules', 'electron')
const distPath = path.join(electronPackageDir, 'dist')
if (!existsSync(path.join(electronPackageDir, 'package.json')) || !existsSync(distPath)) {
continue
}
if (statSync(distPath, { throwIfNoEntry: false })?.isDirectory() !== true) {
continue
}
let version
try {
version = JSON.parse(
readFileSync(path.join(electronPackageDir, 'package.json'), 'utf8')
).version
} catch {
continue
}
const targetPlatform = process.platform
const targetArch = process.arch
let platformPath
try {
platformPath = getElectronPlatformPath(targetPlatform)
} catch {
continue
}
if (!isUsableElectronDist(distPath, version, platformPath)) {
console.log(`skip ${worktree} (dist is not a complete Electron ${version})`)
skipped += 1
continue
}
const entry = resolveSharedElectronDistEntry({
repoRoot: worktree,
electronPackageDir,
version,
targetPlatform,
targetArch
})
if (entry === null) {
continue
}
if (hasAdoptedSharedElectronDist(entry)) {
continue
}
const size = measure(distPath)
if (!apply) {
console.log(`would share ${worktree} ${(size / 1024 ** 3).toFixed(2)} GiB (${version})`)
reclaimed += size
converted += 1
continue
}
try {
if (!existsSync(entry.entryPath)) {
if (publishSharedElectronDist(distPath, entry, { version, platformPath })) {
recordAdoptedSharedElectronDist(entry, writeFileSync)
console.log(`seeded ${worktree} -> ${entry.entryPath}`)
converted += 1
}
continue
}
if (adoptInto(distPath, entry, { version, platformPath })) {
recordAdoptedSharedElectronDist(entry, writeFileSync)
reclaimed += size
converted += 1
console.log(`shared ${worktree} reclaimed ${(size / 1024 ** 3).toFixed(2)} GiB`)
}
} catch (error) {
// A worktree that fails is left exactly as it was; it still has its own working dist.
console.warn(`skip ${worktree} (${error instanceof Error ? error.message : String(error)})`)
skipped += 1
}
}
console.log(
`\n${apply ? 'Shared' : 'Would share'} ${converted} worktree(s); ` +
`${apply ? 'reclaimed' : 'reclaimable'} ~${(reclaimed / 1024 ** 3).toFixed(2)} GiB` +
`${skipped > 0 ? `; skipped ${skipped}` : ''}` +
`${apply ? '' : '\nRe-run with --apply to do it.'}`
)
}
// Guarded so importing this module for tests does not sweep the whole repository.
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
main()
}
@@ -41,14 +41,24 @@ describe('release-cut source map publication', () => {
expect(publish.with.command).toContain('runner.temp')
})
it('fails the release when no source maps were emitted', () => {
// Why: a silent regression of build.sourcemap would ship an undecodable
// release rather than an obviously broken one.
it('fails only when a map-enabled cut emits no source maps', () => {
// Why: legacy tags predate source-map publication, but a newer tag that
// enables hidden maps must still fail loudly if the build regresses.
const bundle = buildSteps[stepIndex('Bundle main-process source maps')]
expect(bundle.id).toBe('bundle-main-sourcemaps')
expect(bundle.run).toContain('grep -Eq')
expect(bundle.run).toContain('sourcemap:[[:space:]]*')
expect(bundle.run).toContain('has_maps=false')
expect(bundle.run).toContain('has_maps=true')
expect(bundle.run).toContain('::error::')
expect(bundle.run).toContain('exit 1')
})
it('skips publication for legacy cut refs without hidden source maps', () => {
const publish = buildSteps[stepIndex('Publish main-process source maps')]
expect(publish.if).toContain("steps.bundle-main-sourcemaps.outputs.has_maps == 'true'")
})
it('bundles maps after the build and before packaging strips them', () => {
const bundle = stepIndex('Bundle main-process source maps')
expect(bundle).toBeGreaterThan(stepIndex('Build app'))
@@ -31,6 +31,7 @@ const EXPECTED_MATRIX = {
},
[`${RELEASE_WORKFLOW}#post-release-e2e`]: { actions: 'write' },
[`${RELEASE_WORKFLOW}#publish-release`]: { contents: 'write' },
[`${RELEASE_WORKFLOW}#release-preflight`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#skill-sharing-linux-floor-release-gate`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#skill-sharing-release-gate`]: { contents: 'read' },
[`${RELEASE_WORKFLOW}#terminal-rendering-golden`]: { contents: 'read' },
+13 -24
View File
@@ -1,7 +1,6 @@
import { execFileSync, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
@@ -16,8 +15,15 @@ import {
import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
import { isDevBundleInUse, selectStaleDevBundleDirs } from './dev-electron-bundle-cache.mjs'
import {
DEV_BUNDLE_MARKER_FILENAME,
getDevBundleProcessTable,
isDevBundleInUse,
selectStaleDevBundleDirs
} from './dev-electron-bundle-cache.mjs'
import { copyPrivateTree } from './space-sharing-copy.mjs'
import {
DEV_BUNDLE_ID,
getDevBundlePlistPatches,
@@ -116,23 +122,6 @@ function sanitizeMacAppBundleName(value) {
)
}
function getDevBundleProcessTable() {
// Not pgrep: macOS pgrep has no -a (a Linux procps extension) and silently prints bare PIDs,
// which reads as "nothing is running" and deletes a live bundle. -ww keeps the command column
// from being truncated. The raw text is searched directly; see dev-electron-bundle-cache.mjs
// for why it is deliberately not parsed into paths.
try {
return execFileSync('/bin/ps', ['-Awwo', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000
})
} catch {
// Treating a failure as "nothing live" would risk deleting a running bundle, so skip pruning.
return null
}
}
function pruneStaleDevBundles(distDir) {
const root = path.dirname(distDir)
let bundles
@@ -143,7 +132,7 @@ function pruneStaleDevBundles(distDir) {
const dir = path.join(root, entry.name)
return {
dir,
hasMarker: existsSync(path.join(dir, 'orca-dev-electron-app.json')),
hasMarker: existsSync(path.join(dir, DEV_BUNDLE_MARKER_FILENAME)),
mtimeMs: getMtimeMs(dir)
}
})
@@ -203,7 +192,7 @@ function prepareMacDevElectronApp() {
// and it sits outside the code signature, so varying it does not disturb the cdhash.
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
const appPath = path.join(distDir, appBundleName)
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
const markerPath = path.join(distDir, DEV_BUNDLE_MARKER_FILENAME)
// 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
@@ -298,9 +287,9 @@ function prepareMacDevElectronApp() {
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
// Why: Electron.framework uses relative symlinks for its bundle resources;
// resolving them to pnpm-store absolutes breaks Chromium's bundle lookup.
cpSync(sourceAppPath, appPath, { recursive: true, verbatimSymlinks: true })
// Why clone-first: this ~280MB copy is made per branch title x Electron version, and only the
// plist/helper/codesign bytes patched below ever diverge from the source.
copyPrivateTree(sourceAppPath, appPath)
restoreElectronFrameworkSymlinks(appPath)
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
@@ -17,7 +17,7 @@ if (!appImageArg) {
if (!['app', 'serving-electron'].includes(signalTarget)) {
fail(`Unsupported --signal-target: ${signalTarget}`)
}
if (!['app', 'launcher'].includes(entrypoint)) {
if (!['app', 'appimage', 'launcher'].includes(entrypoint)) {
fail(`Unsupported --entrypoint: ${entrypoint}`)
}
if (!['pid', 'foreground-process-group'].includes(intDelivery)) {
@@ -54,11 +54,19 @@ try {
shutdownDockerDirectory
])
docker(['volume', 'create', artifactVolume])
runDesktopStartupOracle({ image, appImage, platform })
docker([
'run',
'--rm',
'--platform',
platform,
'--network',
'none',
'--read-only',
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--entrypoint',
'bash',
'-v',
@@ -68,11 +76,17 @@ try {
image,
'-lc',
[
'7z x /input/orca.AppImage -o/artifacts/root -y >/dev/null',
'trap \'status=$?; if [ "$status" -ne 0 ]; then cat /artifacts/appimage-help.log /artifacts/appimage-extract.log 2>/dev/null || true; fi; exit "$status"\' EXIT',
'test -r /input/orca.AppImage && test -x /input/orca.AppImage || { echo "FAIL: AppImage bind must be readable and executable" >&2; exit 1; }',
'timeout --kill-after=5s 15s /input/orca.AppImage --appimage-help > /artifacts/appimage-help.log 2>&1',
'cd /artifacts',
'timeout --kill-after=10s 120s /input/orca.AppImage --appimage-extract > /artifacts/appimage-extract.log 2>&1',
'mv squashfs-root root',
launcherExecOverlay
? "sed -i 's/^ELECTRON_RUN_AS_NODE=1 /export ELECTRON_RUN_AS_NODE=1\\nexec /' /artifacts/root/resources/bin/orca-ide"
: ':',
'chmod -R a+rX /artifacts/root'
'chmod -R a+rX /artifacts/root',
'rm /artifacts/appimage-help.log /artifacts/appimage-extract.log'
].join(' && ')
])
@@ -108,6 +122,8 @@ try {
'-e',
`ORCA_INT_DELIVERY=${intDelivery}`,
'-v',
`${appImage}:/input/orca.AppImage:ro`,
'-v',
`${artifactVolume}:/artifacts:ro`,
image,
signal
@@ -129,6 +145,38 @@ try {
docker(['image', 'rm', image], { allowFailure: true })
}
function runDesktopStartupOracle({ image, appImage, platform }) {
console.log('Running original AppImage desktop startup oracle...')
docker([
'run',
'--rm',
'--init',
'--platform',
platform,
'--network',
'none',
'--read-only',
'--tmpfs',
'/tmp:rw,nosuid,nodev,exec,size=1g',
'--shm-size',
'256m',
'--cap-drop',
'ALL',
'--security-opt',
'no-new-privileges',
'--user',
'orca',
'--entrypoint',
'/usr/local/bin/run-appimage-desktop-startup-case',
'-e',
'ORCA_STARTUP_DIAGNOSTICS=1',
'-v',
`${appImage}:/input/orca.AppImage:ro`,
image,
'/input/orca.AppImage'
])
}
function valueAfter(flag) {
const index = args.indexOf(flag)
return index === -1 ? null : (args[index + 1] ?? null)
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env node
// Exercise packaged CLI paths under the hostile Linux conditions from #11609/#12530/#13719/#14229.
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
const commandArgs = process.argv.slice(2)
const appImageArg = valueAfter('--appimage')
const appImage = appImageArg ? resolve(appImageArg) : null
const platform = valueAfter('--platform')
const dockerPlatformArgs = platform ? ['--platform', platform] : []
const suffix = `${process.pid}-${Date.now()}`
const artifactVolume = `orca-cli-contract-artifact-${suffix}`
const tagArchitecture = platform?.split('/')[1] ?? process.arch
const tag = `orca-cli-launch-contract:ubuntu-24.04-${tagArchitecture}-${suffix}`
const base = 'ubuntu@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90'
const containers = new Set()
let artifactVolumeCreated = false
const CASE_TIMEOUT_MS = 90_000
const BUILD_TIMEOUT_MS = 10 * 60_000
const STAGING_TIMEOUT_MS = 5 * 60_000
const DOCKER_TIMEOUT_MS = 2 * 60_000
const CLEANUP_TIMEOUT_MS = 30_000
// Exact statuses reject silent no-op launches as well as crashes.
const CASES = [
{
name: 'nofuse-userns-bundled-help',
expectStatus: 0,
expectOutput: 'Usage: orca <command>',
why: 'The bundled launcher must run with no FUSE, no display, and userns restricted (#11609, #12530).'
},
{
name: 'nofuse-userns-bundled-version',
expectStatus: 0,
expectOutput: /^\d+\.\d+\.\d+/m,
why: 'A deployment must be able to read the installed version without a display (#13719).'
},
{
name: 'nofuse-userns-bundled-status',
// No runtime is running; the CLI must report that itself.
expectStatus: 1,
expectOutput: 'appRunning',
why: 'A command that needs the runtime must report its absence, not abort.'
},
{
name: 'nofuse-userns-bundled-skills',
expectStatus: 0,
// Why: the rendered help header, not a bare 'skills' — the case name contains that word.
expectOutput: 'Usage: orca skills',
why: 'skills is a pure-text command that must never need Chromium (#14229).'
},
{
name: 'nofuse-userns-bundled-worktree',
expectStatus: 1,
expectOutput: "Orca is not running. Run 'orca open' first.",
why: 'A runtime-dependent command must report the missing runtime, not abort.'
},
{
name: 'nofuse-nosandbox-direct-binary-skills',
expectStatus: 0,
expectOutput: 'Usage: orca skills',
why: 'A direct binary launch that reaches JavaScript must run the command, not boot a GUI (#14229).'
},
{
name: 'nofuse-nosandbox-direct-binary-gui',
// A missing display is an expected diagnosis, not a crash.
expectStatus: 1,
expectOutput: 'needs a usable display server',
why: 'A desktop launch with no display must diagnose it instead of dying in uv_close (#13719).'
},
{
name: 'stale-display-nosandbox-direct-binary-gui',
expectStatus: 1,
expectOutput: 'needs a usable display server',
why: 'A stale DISPLAY value must diagnose the unreachable endpoint instead of dying in uv_close (#13719).'
}
]
try {
if (!appImage) {
fail(
'Usage: run-linux-cli-launch-contract-docker.mjs --appimage /path/to/orca-linux.AppImage [--platform linux/amd64|linux/arm64]'
)
}
if (commandArgs.includes('--platform') && !platform) {
fail('Missing value for --platform')
}
if (platform !== null && platform !== 'linux/amd64' && platform !== 'linux/arm64') {
fail(`Unsupported --platform: ${platform}`)
}
if (!existsSync(appImage)) {
fail(`AppImage not found: ${appImage}`)
}
docker(['volume', 'create', artifactVolume], { timeoutMs: DOCKER_TIMEOUT_MS })
artifactVolumeCreated = true
buildImage()
stageArtifacts()
runContract()
console.log('\nLinux CLI launch contract passed.')
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
} finally {
for (const container of containers) {
docker(['rm', '-f', container], { allowFailure: true, timeoutMs: CLEANUP_TIMEOUT_MS })
}
if (artifactVolumeCreated) {
docker(['volume', 'rm', artifactVolume], {
allowFailure: true,
timeoutMs: CLEANUP_TIMEOUT_MS
})
}
docker(['image', 'rm', tag], { allowFailure: true, timeoutMs: CLEANUP_TIMEOUT_MS })
}
function runContract() {
const failures = []
for (const testCase of CASES) {
const output = runCase(testCase.name)
const statusMatch = /^RESULT status=(\d+)/m.exec(output)
if (!statusMatch) {
failures.push(`${testCase.name}: ${firstLine(output)}\n ${testCase.why}`)
console.log(` FAIL ${testCase.name} — ${firstLine(output)}`)
continue
}
const status = Number(statusMatch[1])
// Why: the harness echoes `RESULT status=N case=<name>`, so a case whose name contains the
// expected substring would assert against the harness's own line instead of the CLI's output.
const commandOutput = output
.split('\n')
.filter((line) => !/^(?:RESULT|CRASHED|PRECONDITION_FAILED) /.test(line))
.join('\n')
const matchesOutput =
typeof testCase.expectOutput === 'string'
? commandOutput.includes(testCase.expectOutput)
: testCase.expectOutput.test(commandOutput)
if (status !== testCase.expectStatus || !matchesOutput) {
failures.push(
`${testCase.name}: expected status ${testCase.expectStatus} and ${testCase.expectOutput}, ` +
`got status ${status}\n ${testCase.why}`
)
console.log(` FAIL ${testCase.name} — status ${status}`)
continue
}
console.log(` ok ${testCase.name} (status ${status})`)
}
if (failures.length > 0) {
fail(`Linux CLI launch contract failed:\n - ${failures.join('\n - ')}`)
}
}
function runCase(caseName) {
const container = `orca-cli-contract-${caseName}-${suffix}`
containers.add(container)
// FUSE and extra capabilities would invalidate the test conditions.
return docker(
[
'run',
...dockerPlatformArgs,
'--name',
container,
'--rm',
'-v',
`${artifactVolume}:/artifacts`,
tag,
caseName
],
{ allowFailure: true, capture: true, timeoutMs: CASE_TIMEOUT_MS }
)
}
function buildImage() {
console.log(`Building ${tag}…`)
docker(
[
'build',
...dockerPlatformArgs,
'--build-arg',
`BASE_IMAGE=${base}`,
'-f',
'config/docker/cli-launch-contract/Dockerfile',
'-t',
tag,
'config/docker/cli-launch-contract'
],
{ timeoutMs: BUILD_TIMEOUT_MS }
)
}
// Extract unprivileged so chrome-sandbox is not root-owned setuid.
function stageArtifacts() {
console.log('Staging the AppImage payload…')
const container = `orca-cli-contract-stage-${suffix}`
containers.add(container)
docker(
[
'run',
...dockerPlatformArgs,
'--name',
container,
'--rm',
'-v',
`${artifactVolume}:/artifacts`,
'-v',
`${appImage}:/input/orca-linux.AppImage:ro`,
'--entrypoint',
'bash',
tag,
'-lc',
[
'set -euo pipefail',
'cp /input/orca-linux.AppImage /artifacts/orca-linux.AppImage',
'chmod +x /artifacts/orca-linux.AppImage',
'chown -R orca:orca /artifacts',
// Use the AppImage runtime's no-FUSE extraction path.
'cd /artifacts && runuser --user orca -- ./orca-linux.AppImage --appimage-extract >/dev/null',
'test -x /artifacts/squashfs-root/resources/bin/orca-ide'
].join(' && ')
],
{ timeoutMs: STAGING_TIMEOUT_MS }
)
}
function docker(args, options = {}) {
try {
const output = execFileSync('docker', args, {
encoding: 'utf8',
stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
timeout: options.timeoutMs ?? DOCKER_TIMEOUT_MS,
killSignal: 'SIGTERM'
})
return output ?? ''
} catch (error) {
const timedOut = error instanceof Error && 'code' in error && error.code === 'ETIMEDOUT'
if (timedOut) {
const message = `docker ${args.join(' ')} timed out after ${options.timeoutMs ?? DOCKER_TIMEOUT_MS}ms`
if (!options.allowFailure) {
fail(message)
}
return message
}
if (!options.allowFailure) {
fail(
`docker ${args.join(' ')} failed: ${error instanceof Error ? error.message : String(error)}`
)
}
return `${error?.stdout ?? ''}${error?.stderr ?? ''}`
}
}
function firstLine(value) {
return (value ?? '').trim().split('\n')[0] || '(no output)'
}
function valueAfter(flag) {
const index = commandArgs.indexOf(flag)
return index === -1 ? null : (commandArgs[index + 1] ?? null)
}
function fail(message) {
throw new Error(message)
}
+29 -9
View File
@@ -33,15 +33,31 @@ if (runtime.status !== 0) {
// all. Recorded as a real gap, not as coverage living somewhere else.
// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying:
// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 /
// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate
// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above.
// (b) it is ROTTED: four call sites are out of date against terminal.ts's current
// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane
// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
// semantic decisions (which ptyId to capture, which split direction) that change
// what the repro measures. Tracked in stablyai/orca#16764.
// ssh-docker-bulk-open-freeze-repro.spec.ts — un-rotted and now measurable, and marked
// `test.fixme` because its oracle cannot gate. Absent from this list AND skipped, so the
// two cannot drift: it is also reachable from the changed-specs lane whenever the spec
// itself is edited, and a wall-clock oracle that fails there is worth no more than one
// that fails here.
// The rot (#16764) is fixed: the stale call sites are repaired, it connects after session
// restore instead of before, and readiness keys on the repeating flood marker rather than
// a one-shot READY line the flood buries within ~16ms. It runs end to end and prints a
// measurement instead of dying on a call site.
// What it is NOT is portable. Three runs of the same measurement path:
// developer workstation: hiddenFlood 2.1ms bulkOpen 41.5ms interaction 53.6ms
// GitHub ubuntu runner A: hiddenFlood 1.5ms bulkOpen 2575.6ms interaction 3464.2ms
// GitHub ubuntu runner B: hiddenFlood 0.2ms bulkOpen 397.4ms interaction 3386.7ms
// bulkOpen swings 6.5x between two CI runs of the same code, so a fixed threshold on it is
// a coin flip; interaction sits stably ~64x over the workstation figure because it times a
// view remount, not the renderer freeze the issue reports, and only shares the budget
// constant because both are milliseconds. Every failure so far is the soft budget; hard
// has never tripped, and the relay was still streaming each time — the budget failed, not
// the product. Same rule as ssh-docker-relay-perf above. Gating needs a distribution
// first, then a host-relative oracle; a bigger constant, or a ratio picked from three
// samples, is the same arbitrary number in different clothes.
// COVERAGE GAP, recorded as such: 5 simultaneously flooding SSH panes exercise writer
// saturation, ACK/credit accounting and per-pane polling together, and nothing else covers
// that combination. Flip `test.fixme` back to `test` to run it. Tracked in
// stablyai/orca#16764.
//
// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
// grep-inverts away.
@@ -71,7 +87,11 @@ const result = spawnSync(
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
'tests/e2e/ssh-docker-half-open-link.spec.ts',
'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-docker-resource-accumulation.spec.ts',
'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts',
'tests/e2e/ssh-external-image-preview.spec.ts',
'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts',
'tests/e2e/ssh-pi-compatible-agent-title.spec.ts',
@@ -0,0 +1,26 @@
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
/**
* Copy a script and every co-located module it imports into a fixture's `config/scripts`.
*
* Walked rather than listed: a module the script needs but the fixture never copied fails every
* test in the suite with a module-resolution error that looks nothing like the defect it hides.
*/
export function copyScriptWithLocalModules(sourceScriptPath, destinationScriptsDir) {
mkdirSync(destinationScriptsDir, { recursive: true })
for (const modulePath of collectScriptModules(sourceScriptPath)) {
copyFileSync(modulePath, join(destinationScriptsDir, basename(modulePath)))
}
}
function collectScriptModules(scriptPath, seen = new Set()) {
if (seen.has(scriptPath)) {
return seen
}
seen.add(scriptPath)
for (const [, specifier] of readFileSync(scriptPath, 'utf8').matchAll(/from '(\.\/[^']+)'/g)) {
collectScriptModules(join(dirname(scriptPath), specifier), seen)
}
return seen
}
@@ -0,0 +1,189 @@
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
import path from 'node:path'
import { makeTreeReadOnly, shareTree } from './space-sharing-copy.mjs'
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
// Sibling of path.txt, never inside dist: an install replaces dist wholesale.
const MARKER_FILENAME = '.orca-shared-dist'
/**
* Where sibling worktrees of one repository keep their shared extracted Electron.
*
* Null means "install normally" -- every caller treats a missing entry as "do what you did before".
*/
export function resolveSharedElectronDistEntry(options) {
const { repoRoot, version, targetPlatform, targetArch } = options
const env = options.env ?? process.env
// Packaging jobs get a fresh checkout per run, so a cache only adds a failure mode.
if (env.CI === '1' || env.CI === 'true') {
return null
}
if (![version, targetPlatform, targetArch].every((part) => IDENTITY_PATTERN.test(part ?? ''))) {
return null
}
let gitCommonDir
try {
gitCommonDir = resolveGitCommonDir(repoRoot, options.execFile ?? execFileSync)
} catch {
return null // Folder workspace, or no Git on PATH.
}
const cacheRoot = path.join(gitCommonDir, 'orca-cache', 'electron')
return {
cacheRoot,
entryPath: path.join(cacheRoot, `${version}-${targetPlatform}-${targetArch}`),
markerPath: path.join(options.electronPackageDir, MARKER_FILENAME)
}
}
export function resolveGitCommonDir(repoRoot, execFile = execFileSync) {
const rawPath = execFile('git', ['-C', repoRoot, 'rev-parse', '--git-common-dir'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
if (!rawPath) {
throw new Error('Git returned an empty common directory')
}
return path.resolve(repoRoot, rawPath)
}
/** True once this worktree's dist is already a clone of the current cache entry. */
export function hasAdoptedSharedElectronDist(entry) {
try {
return readFileSync(entry.markerPath, 'utf8') === path.basename(entry.entryPath)
} catch {
return false
}
}
export function recordAdoptedSharedElectronDist(entry, write) {
try {
write(entry.markerPath, path.basename(entry.entryPath))
} catch {
// The marker is only an optimization: a missing one costs one extra clone.
}
}
/**
* Share a validated cache entry into `stagePath`. Deliberately never falls back to a byte copy --
* with no storage to share the caller is better off with its normal install, which this must not
* slow down.
*/
export function shareElectronDistFromCache(entry, stagePath, options) {
const { version, platformPath } = options
if (!isUsableElectronDist(entry.entryPath, version, platformPath)) {
return false
}
try {
;(options.share ?? shareTree)(entry.entryPath, stagePath)
} catch {
return false
}
return isUsableElectronDist(stagePath, version, platformPath)
}
/**
* Publish this worktree's dist as the shared entry, best effort.
*
* No lock: staging names are unique and `rename` onto a populated directory fails with ENOTEMPTY,
* so a concurrent publisher either wins the rename or cleans up its own staging tree. Neither can
* observe a half-written entry, and a usable entry already in place is never overwritten.
*/
export function publishSharedElectronDist(distPath, entry, options = {}) {
const { version, platformPath } = options
const uuid = options.uuid ?? randomUUID
const canValidate = Boolean(version) && Boolean(platformPath)
// Without an identity to check against, "unusable" is unknowable -- never discard on a guess.
if (existsSync(entry.entryPath) && (!canValidate || isUsable(entry, version, platformPath))) {
return false
}
const stagePath = `${entry.entryPath}.staging-${process.pid}-${uuid()}`
try {
mkdirSync(entry.cacheRoot, { recursive: true })
;(options.share ?? shareTree)(distPath, stagePath)
// Before publishing, not after: an entry is visible the instant the rename lands, and under
// hardlink sharing this is the only thing standing between a stray write and every worktree.
;(options.protect ?? makeTreeReadOnly)(stagePath)
} catch {
rmSync(stagePath, { recursive: true, force: true })
return false
}
return swapInElectronDistEntry(entry, stagePath, {
canValidate,
version,
platformPath,
uuid,
rename: options.rename ?? renameSync
})
}
/**
* Replace the entry with a staged tree, re-checking first.
*
* Sharing the tree above takes seconds, and a sibling worktree can publish a perfectly good entry
* in that time. Re-validating here, immediately before the destructive rename, keeps us from
* discarding that entry -- and restoring the quarantine on a failed swap keeps a losing publisher
* from leaving the cache empty.
*/
function swapInElectronDistEntry(entry, stagePath, options) {
const { canValidate, version, platformPath, uuid, rename } = options
let quarantinePath = null
if (existsSync(entry.entryPath)) {
// Same rule as before staging: an entry we cannot judge, or one that is good, is never
// displaced. Both mean another worktree got there first, so keep theirs.
if (!canValidate || isUsable(entry, version, platformPath)) {
rmSync(stagePath, { recursive: true, force: true })
return false
}
quarantinePath = `${entry.entryPath}.unusable-${process.pid}-${uuid()}`
try {
renameSync(entry.entryPath, quarantinePath)
} catch {
rmSync(stagePath, { recursive: true, force: true })
return false // Another worktree is already replacing it.
}
}
try {
rename(stagePath, entry.entryPath)
} catch {
rmSync(stagePath, { recursive: true, force: true })
if (quarantinePath !== null) {
// Put it back rather than leave no entry at all; a bad entry still beats an empty cache,
// because the next publisher re-validates and replaces it.
try {
renameSync(quarantinePath, entry.entryPath)
return false
} catch {
rmSync(quarantinePath, { recursive: true, force: true })
}
}
return false
}
if (quarantinePath !== null) {
rmSync(quarantinePath, { recursive: true, force: true })
}
return true
}
function isUsable(entry, version, platformPath) {
return isUsableElectronDist(entry.entryPath, version, platformPath)
}
export function isUsableElectronDist(distPath, version, platformPath) {
try {
if (!lstatSync(distPath).isDirectory()) {
return false
}
const installedVersion = readFileSync(path.join(distPath, 'version'), 'utf8')
.trim()
.replace(/^v/, '')
return installedVersion === version && existsSync(path.join(distPath, platformPath))
} catch {
return false
}
}
@@ -0,0 +1,358 @@
import type { execFileSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
shareElectronDistFromCache,
hasAdoptedSharedElectronDist,
isUsableElectronDist,
publishSharedElectronDist,
recordAdoptedSharedElectronDist,
resolveSharedElectronDistEntry
} from './shared-electron-dist-cache.mjs'
import { makeTreeReadOnly } from './space-sharing-copy.mjs'
const VERSION = '43.4.1'
const PLATFORM_PATH = path.join('Electron.app', 'Contents', 'MacOS', 'Electron')
const identity = { version: VERSION, platformPath: PLATFORM_PATH }
const roots: string[] = []
afterEach(() => {
while (roots.length > 0) {
rmSync(roots.pop()!, { recursive: true, force: true })
}
})
function makeRoot(): string {
const root = mkdtempSync(path.join(tmpdir(), 'orca-shared-electron-'))
roots.push(root)
return root
}
function writeDist(distPath: string, version = VERSION): string {
mkdirSync(path.join(distPath, path.dirname(PLATFORM_PATH)), { recursive: true })
writeFileSync(path.join(distPath, 'version'), `v${version}\n`)
writeFileSync(path.join(distPath, PLATFORM_PATH), 'electron')
return distPath
}
function makeEntry(root: string, entryName = `${VERSION}-darwin-arm64`) {
const cacheRoot = path.join(root, 'cache')
return {
cacheRoot,
entryPath: path.join(cacheRoot, entryName),
markerPath: path.join(root, '.orca-shared-dist')
}
}
const baseOptions = {
repoRoot: '/repo',
electronPackageDir: '/repo/node_modules/electron',
version: VERSION,
targetPlatform: 'darwin',
targetArch: 'arm64',
hostPlatform: 'darwin' as const,
env: {} as NodeJS.ProcessEnv,
execFile: (() => '/repo/.git\n') as unknown as typeof execFileSync
}
describe('resolveSharedElectronDistEntry', () => {
it('keys the entry by version, platform, and arch under the git common dir', () => {
const entry = resolveSharedElectronDistEntry(baseOptions)
expect(entry?.cacheRoot).toBe(path.join('/repo/.git', 'orca-cache', 'electron'))
expect(entry?.entryPath).toBe(
path.join('/repo/.git', 'orca-cache', 'electron', '43.4.1-darwin-arm64')
)
expect(entry?.markerPath).toBe(path.join('/repo/node_modules/electron', '.orca-shared-dist'))
})
it('offers an entry on every platform a worktree is developed on', () => {
for (const hostPlatform of ['darwin', 'linux', 'win32']) {
expect(resolveSharedElectronDistEntry({ ...baseOptions, hostPlatform })).not.toBeNull()
}
})
it('declines on CI, where every job gets a fresh checkout', () => {
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: '1' } })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: 'true' } })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, env: { CI: 'false' } })).not.toBeNull()
})
it('declines outside a Git worktree so folder workspaces install normally', () => {
const execFile = (() => {
throw new Error('not a git repository')
}) as unknown as typeof execFileSync
expect(resolveSharedElectronDistEntry({ ...baseOptions, execFile })).toBeNull()
})
it('declines an identity that would not be a single safe path segment', () => {
expect(resolveSharedElectronDistEntry({ ...baseOptions, targetArch: '../escape' })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, targetPlatform: 'dar/win' })).toBeNull()
expect(resolveSharedElectronDistEntry({ ...baseOptions, version: '' })).toBeNull()
})
})
describe('isUsableElectronDist', () => {
it('accepts a complete dist and tolerates the leading v in the version file', () => {
const root = makeRoot()
expect(isUsableElectronDist(writeDist(path.join(root, 'dist')), VERSION, PLATFORM_PATH)).toBe(
true
)
})
it('rejects a version mismatch, a missing executable, and a missing directory', () => {
const root = makeRoot()
expect(
isUsableElectronDist(writeDist(path.join(root, 'a'), '40.0.0'), VERSION, PLATFORM_PATH)
).toBe(false)
const partial = path.join(root, 'b')
mkdirSync(partial, { recursive: true })
writeFileSync(path.join(partial, 'version'), `v${VERSION}`)
expect(isUsableElectronDist(partial, VERSION, PLATFORM_PATH)).toBe(false)
expect(isUsableElectronDist(path.join(root, 'missing'), VERSION, PLATFORM_PATH)).toBe(false)
})
it('rejects a symlink so a redirected entry is never treated as cache content', () => {
const root = makeRoot()
writeDist(path.join(root, 'real'))
symlinkSync(path.join(root, 'real'), path.join(root, 'link'), 'dir')
expect(isUsableElectronDist(path.join(root, 'link'), VERSION, PLATFORM_PATH)).toBe(false)
})
})
describe('publishSharedElectronDist', () => {
it('publishes through a staging directory and an atomic rename', () => {
const root = makeRoot()
const entry = makeEntry(root)
const dist = writeDist(path.join(root, 'dist'))
const share = vi.fn((source: string, destination: string) => {
expect(path.basename(destination)).toMatch(/^43\.4\.1-darwin-arm64\.staging-/)
writeDist(destination)
expect(source).toBe(dist)
})
expect(publishSharedElectronDist(dist, entry, { share, ...identity })).toBe(true)
expect(isUsableElectronDist(entry.entryPath, VERSION, PLATFORM_PATH)).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('publishes the entry read-only, before it is reachable under its final name', () => {
const root = makeRoot()
const entry = makeEntry(root)
const dist = writeDist(path.join(root, 'dist'))
const protectedPaths: string[] = []
const share = (source: string, destination: string) => {
writeDist(destination)
expect(source).toBe(dist)
}
const protect = (target: string) => {
// Why order matters: a reader can clone the entry the instant the rename lands.
expect(existsSync(entry.entryPath)).toBe(false)
protectedPaths.push(target)
makeTreeReadOnly(target)
}
expect(publishSharedElectronDist(dist, entry, { share, protect, ...identity })).toBe(true)
expect(protectedPaths).toHaveLength(1)
expect(statSync(path.join(entry.entryPath, 'version')).mode & 0o222).toBe(0)
// Entry directories stay removable, which is what the install transaction actually needs.
expect(() => rmSync(entry.entryPath, { recursive: true })).not.toThrow()
})
it('never overwrites an entry another worktree already published', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath)
writeFileSync(path.join(entry.entryPath, 'marker'), 'first-writer')
const share = vi.fn()
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(share).not.toHaveBeenCalled()
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
})
it('loses a publish race without clobbering the winner or leaking staging', () => {
const root = makeRoot()
const entry = makeEntry(root)
const share = (_source: string, destination: string) => {
writeDist(destination)
// The winner lands between our existence check and our rename.
writeDist(entry.entryPath)
writeFileSync(path.join(entry.entryPath, 'marker'), 'winner')
}
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('keeps a good entry a sibling published while this one was still sharing', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0') // Unusable, so this worktree intends to replace it.
const share = (_source: string, destination: string) => {
writeDist(destination)
// A sibling replaces the bad entry with a good one while this share is still running.
rmSync(entry.entryPath, { recursive: true, force: true })
writeDist(entry.entryPath)
writeFileSync(path.join(entry.entryPath, 'marker'), 'sibling')
}
expect(
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share, ...identity })
).toBe(false)
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('restores the quarantined entry rather than leaving the cache empty', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
writeFileSync(path.join(entry.entryPath, 'marker'), 'stale')
const share = (_source: string, destination: string) => writeDist(destination)
// The swap itself fails; a bad entry still beats no entry, since the next publisher replaces it.
const failingRename = () => {
throw new Error('rename failed')
}
expect(
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, {
share,
rename: failingRename,
...identity
})
).toBe(false)
expect(existsSync(path.join(entry.entryPath, 'marker'))).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('replaces an entry that fails validation instead of stranding every worktree', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
const share = (_source: string, destination: string) => writeDist(destination)
expect(
publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share, ...identity })
).toBe(true)
expect(isUsableElectronDist(entry.entryPath, VERSION, PLATFORM_PATH)).toBe(true)
expect(readdirSync(entry.cacheRoot)).toEqual([path.basename(entry.entryPath)])
})
it('never discards an entry it was given no identity to check', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
const share = vi.fn()
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(share).not.toHaveBeenCalled()
expect(existsSync(path.join(entry.entryPath, 'version'))).toBe(true)
})
it('leaves no entry and no staging tree when sharing fails', () => {
const root = makeRoot()
const entry = makeEntry(root)
const share = (_source: string, destination: string) => {
writeDist(destination)
throw new Error('no shareable storage')
}
expect(publishSharedElectronDist(writeDist(path.join(root, 'dist')), entry, { share })).toBe(
false
)
expect(existsSync(entry.entryPath)).toBe(false)
expect(readdirSync(entry.cacheRoot)).toEqual([])
})
})
describe('shareElectronDistFromCache', () => {
it('shares a validated entry with real filesystem semantics', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath)
const stagePath = path.join(root, 'stage')
expect(
shareElectronDistFromCache(entry, stagePath, {
version: VERSION,
platformPath: PLATFORM_PATH
})
).toBe(true)
expect(isUsableElectronDist(stagePath, VERSION, PLATFORM_PATH)).toBe(true)
})
it('refuses an entry that fails validation instead of installing it', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath, '40.0.0')
const stagePath = path.join(root, 'stage')
expect(
shareElectronDistFromCache(entry, stagePath, {
version: VERSION,
platformPath: PLATFORM_PATH
})
).toBe(false)
expect(existsSync(stagePath)).toBe(false)
})
it('reports failure rather than falling back to a full copy', () => {
const root = makeRoot()
const entry = makeEntry(root)
mkdirSync(entry.cacheRoot, { recursive: true })
writeDist(entry.entryPath)
// Injected rather than provoked: what counts as an unshareable destination differs per
// mechanism, and a byte-copy fallback here would defeat the point of the cache.
const stagePath = path.join(root, 'stage')
const share = () => {
throw new Error('no shareable storage')
}
expect(
shareElectronDistFromCache(entry, stagePath, {
version: VERSION,
platformPath: PLATFORM_PATH,
share
})
).toBe(false)
expect(existsSync(stagePath)).toBe(false)
})
})
describe('shared dist marker', () => {
it('reports adoption only for the entry the marker names', () => {
const root = makeRoot()
const entry = makeEntry(root)
expect(hasAdoptedSharedElectronDist(entry)).toBe(false)
recordAdoptedSharedElectronDist(entry, writeFileSync)
expect(hasAdoptedSharedElectronDist(entry)).toBe(true)
expect(
hasAdoptedSharedElectronDist({
...entry,
entryPath: path.join(entry.cacheRoot, '44.0.0-darwin-arm64')
})
).toBe(false)
})
it('swallows a marker write failure, which only costs one extra share', () => {
const entry = makeEntry(makeRoot())
expect(() =>
recordAdoptedSharedElectronDist(entry, () => {
throw new Error('read-only node_modules')
})
).not.toThrow()
})
})
@@ -0,0 +1,70 @@
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
/**
* Guard the `.gitattributes` pin that keeps `config/scripts` scripts on LF.
*
* `core.autocrlf=true` ships in the Git-for-Windows system config, so without a
* pin a Windows checkout gets CRLF. Vite's SSR transform locates the shebang
* with `/^#!.*\n/` — `\r` is a JS regex line terminator, so `.` never matches it
* and the pattern misses on CRLF. The hoisted import/export preamble then lands
* at offset 0, ahead of the shebang, which in turn defeats the `code[0] === '#'`
* guard that blanks it. A literal `#!` survives into the middle of the module and
* every suite importing the script dies at load with a SyntaxError.
*
* Scoped to `config/scripts` because that is where tests import scripts. Other
* shebanged `.mjs` in the tree are spawned, not imported, so they cannot hit this.
*/
const projectDir = resolve(import.meta.dirname, '../..')
const SCRIPT_DIRECTORY = 'config/scripts'
function git(args) {
return execFileSync('git', args, { cwd: projectDir, encoding: 'utf8' })
}
/** `git check-attr -z` emits NUL-separated path/attr/value triples. */
function eolAttributes(paths) {
const fields = git(['check-attr', '-z', 'eol', '--', ...paths]).split('\0')
const found = new Map()
for (let index = 0; index + 2 < fields.length; index += 3) {
found.set(fields[index], fields[index + 2])
}
return found
}
function shebangScripts() {
return git(['ls-files', '-z', '--', `${SCRIPT_DIRECTORY}/*.mjs`])
.split('\0')
.filter(Boolean)
.filter((path) => readFileSync(join(projectDir, path), 'utf8').startsWith('#!'))
}
describe('config/scripts line-ending pin', () => {
it('pins every shebanged script to LF', () => {
const scripts = shebangScripts()
expect(scripts.length).toBeGreaterThan(0)
const attributes = eolAttributes(scripts)
const unpinned = scripts.filter((path) => attributes.get(path) !== 'lf')
expect(
unpinned,
'A shebanged script left on the platform default gets CRLF on Windows, ' +
'which makes every suite importing it fail to load. Pin it in .gitattributes.'
).toEqual([])
})
// Why: without these the assertion above still passes against a pattern so broad
// it says nothing, or so narrow it only covers the files that exist today.
it.each([
['config/scripts/example.mjs', 'lf'],
['config/scripts/nested/deeper/example.mjs', 'lf'],
['config/scripts-extra/example.mjs', 'unspecified'],
['vendor/config/scripts/example.mjs', 'unspecified'],
['config/scripts/example.mjsx', 'unspecified']
])('resolves %s to eol=%s', (path, expected) => {
expect(eolAttributes([path]).get(path)).toBe(expected)
})
})
@@ -10,7 +10,28 @@ function stepNamed(job, name) {
}
describe('skill-sharing release workflow', () => {
it('blocks publication on native Windows, macOS, and the Linux floor', () => {
it('keeps artifact builds behind every blocking release gate', () => {
const preflight = workflow.jobs['release-preflight']
const build = workflow.jobs.build
const macBuild = workflow.jobs['build-mac']
expect(preflight.needs).toEqual([
'cut',
'terminal-rendering-golden',
'skill-sharing-release-gate',
'skill-sharing-linux-floor-release-gate'
])
expect(preflight.if).toContain('always()')
expect(preflight.if).toContain("needs.terminal-rendering-golden.result == 'success'")
expect(preflight.if).toContain("needs.skill-sharing-release-gate.result == 'success'")
expect(preflight.if).toContain(
"needs.skill-sharing-linux-floor-release-gate.result == 'success'"
)
expect(build.needs).toContain('release-preflight')
expect(macBuild.needs).toContain('release-preflight')
})
it('blocks on macOS and the Linux floor while keeping Windows diagnostic', () => {
const platform = workflow.jobs['skill-sharing-release-gate']
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
const publishNeeds = workflow.jobs['publish-release'].needs
@@ -19,6 +40,7 @@ describe('skill-sharing release workflow', () => {
{ os: 'macos-15', platform: 'mac' },
{ os: 'windows-2022', platform: 'windows' }
])
expect(platform['continue-on-error']).toBe("${{ matrix.platform == 'windows' }}")
expect(linux.container).toBe('ubuntu:20.04')
expect(publishNeeds).toContain('skill-sharing-release-gate')
expect(publishNeeds).toContain('skill-sharing-linux-floor-release-gate')
+175
View File
@@ -0,0 +1,175 @@
import { execFileSync } from 'node:child_process'
import {
chmodSync,
cpSync,
linkSync,
mkdirSync,
readdirSync,
readlinkSync,
rmSync,
statSync,
symlinkSync
} from 'node:fs'
import { join } from 'node:path'
// -c asks for clonefile(2). -P keeps Electron.framework's relative symlinks as symlinks; resolving
// them breaks Chromium's bundle lookup.
export const MACOS_CLONE_ARGS = Object.freeze(['-c', '-R', '-P'])
// -a implies -d (no symlink following) and preserves mode. --reflink=always fails loudly on a
// filesystem without reflinks rather than silently writing a second full copy.
export const LINUX_REFLINK_ARGS = Object.freeze(['--reflink=always', '-a'])
/**
* Copy a directory tree so the destination costs no new storage.
*
* Three mechanisms, strongest isolation first. Clone and reflink are copy-on-write, so the
* destination is genuinely private. Hardlinks are not: the two trees share inodes, and a write
* through either mutates both. That is only sound for a tree nothing writes to, which is why
* `makeTreeReadOnly` exists and why the caller must apply it.
*
* Throws when no mechanism is available, so a caller can fall back to installing normally rather
* than silently paying for a second full copy.
*/
export function shareTree(sourcePath, destinationPath, options = {}) {
const platform = options.platform ?? process.platform
const errors = []
for (const mechanism of getShareMechanisms(platform)) {
try {
;(options[mechanism] ?? shareMechanisms[mechanism])(sourcePath, destinationPath)
return mechanism
} catch (error) {
errors.push(error)
// A mechanism can fail part-way through a tree; the next one needs a clean destination.
rmSync(destinationPath, { recursive: true, force: true })
}
}
throw new AggregateError(errors, `Could not share storage for ${destinationPath}`)
}
function getShareMechanisms(platform) {
switch (platform) {
case 'darwin':
// APFS only. HFS+ has no clonefile, and hardlinking a 585-entry bundle buys little.
return ['clone']
case 'linux':
// reflink covers btrfs/XFS/bcachefs/ZFS; ext4 has none, which is most developers.
return ['reflink', 'hardlink']
case 'win32':
// Block cloning is ReFS-only, so NTFS gets hardlinks or nothing.
return ['hardlink']
default:
return []
}
}
const shareMechanisms = {
clone: (sourcePath, destinationPath) =>
execFileSync('/bin/cp', [...MACOS_CLONE_ARGS, sourcePath, destinationPath], {
stdio: 'ignore'
}),
reflink: (sourcePath, destinationPath) =>
execFileSync('cp', [...LINUX_REFLINK_ARGS, sourcePath, destinationPath], { stdio: 'ignore' }),
hardlink: hardlinkTree
}
export function hardlinkTree(sourcePath, destinationPath) {
mkdirSync(destinationPath, { recursive: true })
for (const entry of readdirSync(sourcePath, { withFileTypes: true })) {
const from = join(sourcePath, entry.name)
const to = join(destinationPath, entry.name)
if (entry.isDirectory()) {
hardlinkTree(from, to)
} else if (entry.isSymbolicLink()) {
symlinkSync(readlinkSync(from), to)
} else {
linkSync(from, to)
}
}
}
/**
* Drop write permission across a tree.
*
* This is what makes hardlink sharing safe: Electron's own install.js extracts over an existing
* dist with O_TRUNC, which through a hardlink would rewrite every sibling worktree and the cache at
* once. Read-only turns that into EPERM. Directories stay writable because unlink needs a writable
* parent, not a writable file, so the install transaction's renames still work.
*/
export function makeTreeReadOnly(targetPath, chmod = chmodSync) {
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
const entryPath = join(targetPath, entry.name)
if (entry.isDirectory()) {
makeTreeReadOnly(entryPath, chmod)
} else if (!entry.isSymbolicLink()) {
// Clear the write bits and nothing else. A flat 0o555 would strip setuid from
// chrome-sandbox, and under hardlink sharing it would strip it in every worktree at once.
const mode = statSync(entryPath, { throwIfNoEntry: false })?.mode
chmod(entryPath, mode === undefined ? 0o555 : mode & ~0o222)
}
}
chmod(targetPath, 0o755)
}
/**
* Restore owner write permission across a private copy.
*
* Counterpart to `makeTreeReadOnly`: clonefile, reflink and `cpSync` all carry the source's mode
* across, so a tree copied from the write-protected shared cache lands read-only and every patch
* the caller then makes -- `plutil -replace`, `codesign` -- fails with EACCES. Only the owner bit
* comes back; group and other stay as the source left them.
*/
export function makeTreeWritable(targetPath, chmod = chmodSync) {
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
const entryPath = join(targetPath, entry.name)
if (entry.isDirectory()) {
makeTreeWritable(entryPath, chmod)
} else if (!entry.isSymbolicLink()) {
const mode = statSync(entryPath, { throwIfNoEntry: false })?.mode
chmod(entryPath, mode === undefined ? 0o644 : mode | 0o200)
}
}
chmod(targetPath, 0o755)
}
/**
* Share storage when possible, otherwise copy the bytes.
*
* Never hardlinks: this is for trees the caller goes on to patch, where shared inodes would write
* through into the source. The copy is unprotected on the way out for the same reason -- a private
* tree the caller cannot write to is useless to it.
*/
export function copyPrivateTree(sourcePath, destinationPath, options = {}) {
const platform = options.platform ?? process.platform
const copy = options.copy ?? copyTreeVerbatim
const unprotect = options.unprotect ?? makeTreeWritable
const privateMechanisms = new Set(['clone', 'reflink'])
let result = { mechanism: null, copyError: null }
if (getShareMechanisms(platform).some((mechanism) => privateMechanisms.has(mechanism))) {
try {
result = {
mechanism: shareTree(sourcePath, destinationPath, {
...options,
hardlink: () => {
throw new Error('hardlinks would not be private')
}
}),
copyError: null
}
} catch (copyError) {
copy(sourcePath, destinationPath)
result = { mechanism: null, copyError }
}
} else {
copy(sourcePath, destinationPath)
}
unprotect(destinationPath)
return result
}
function copyTreeVerbatim(sourcePath, destinationPath) {
cpSync(sourcePath, destinationPath, {
recursive: true,
dereference: false,
verbatimSymlinks: true
})
}
+256
View File
@@ -0,0 +1,256 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
LINUX_REFLINK_ARGS,
MACOS_CLONE_ARGS,
copyPrivateTree,
hardlinkTree,
makeTreeReadOnly,
makeTreeWritable,
shareTree
} from './space-sharing-copy.mjs'
const roots: string[] = []
afterEach(() => {
while (roots.length > 0) {
rmSync(roots.pop()!, { recursive: true, force: true })
}
})
function makeTree(): { root: string; source: string } {
const root = mkdtempSync(path.join(tmpdir(), 'orca-share-'))
roots.push(root)
const source = path.join(root, 'source')
mkdirSync(path.join(source, 'nested'), { recursive: true })
writeFileSync(path.join(source, 'nested', 'file'), 'contents')
symlinkSync(path.join('nested', 'file'), path.join(source, 'relative-link'))
return { root, source }
}
describe('shareTree', () => {
// Mechanism selection is asserted with stubs, because the real mechanisms only exist on the host
// that owns them: /bin/cp -c is macOS-only and `cp --reflink` is GNU-only.
it('prefers the strongest isolation each platform offers', () => {
const stub = () =>
vi.fn((_source: string, target: string) => mkdirSync(target, { recursive: true }))
const stubs = { clone: stub(), reflink: stub(), hardlink: stub() }
const { root, source } = makeTree()
expect(shareTree(source, path.join(root, 'a'), { platform: 'darwin', ...stubs })).toBe('clone')
expect(shareTree(source, path.join(root, 'b'), { platform: 'linux', ...stubs })).toBe('reflink')
expect(shareTree(source, path.join(root, 'c'), { platform: 'win32', ...stubs })).toBe(
'hardlink'
)
})
it('keeps relative symlinks unresolved on whatever this host supports', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'shared')
expect(shareTree(source, destination)).toBeTruthy()
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
})
it('falls from reflink to hardlink on Linux, where ext4 has no reflinks', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'shared')
const reflink = vi.fn(() => {
throw new Error('failed to clone: Invalid cross-device link')
})
expect(shareTree(source, destination, { platform: 'linux', reflink })).toBe('hardlink')
expect(reflink).toHaveBeenCalledOnce()
expect(statSync(path.join(destination, 'nested', 'file')).ino).toBe(
statSync(path.join(source, 'nested', 'file')).ino
)
})
it('hardlinks on Windows, the only mechanism NTFS offers', () => {
const { root, source } = makeTree()
expect(shareTree(source, path.join(root, 'shared'), { platform: 'win32' })).toBe('hardlink')
})
it('clears a part-way tree before trying the next mechanism', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'shared')
const reflink = (_source: string, target: string) => {
mkdirSync(target, { recursive: true })
writeFileSync(path.join(target, 'half-written'), 'partial')
throw new Error('reflink failed midway')
}
expect(shareTree(source, destination, { platform: 'linux', reflink })).toBe('hardlink')
expect(existsSync(path.join(destination, 'half-written'))).toBe(false)
})
it('throws rather than silently paying for a second full copy', () => {
const { root, source } = makeTree()
expect(() => shareTree(source, path.join(root, 'shared'), { platform: 'freebsd' })).toThrow(
/Could not share storage/
)
})
it('fails loudly instead of degrading, on both copy-out mechanisms', () => {
expect(MACOS_CLONE_ARGS).toContain('-P')
expect(LINUX_REFLINK_ARGS).toContain('--reflink=always')
})
})
describe('hardlinkTree', () => {
it('shares inodes for files but recreates symlinks as their own entries', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'linked')
hardlinkTree(source, destination)
expect(statSync(path.join(destination, 'nested', 'file')).ino).toBe(
statSync(path.join(source, 'nested', 'file')).ino
)
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
})
it('propagates a write through the shared inode, which is why callers must protect it', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'linked')
hardlinkTree(source, destination)
writeFileSync(path.join(destination, 'nested', 'file'), 'mutated')
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('mutated')
})
})
describe('makeTreeReadOnly', () => {
it('drops write permission on files while leaving directories traversable and unlinkable', () => {
const { source } = makeTree()
makeTreeReadOnly(source)
expect(statSync(path.join(source, 'nested', 'file')).mode & 0o222).toBe(0)
// Asserted as behavior, not mode bits: Windows maps chmod onto the read-only attribute alone,
// so a directory there never reports 0o755. What has to hold everywhere is that the install
// transaction can still rename dist aside and remove it.
expect(() => rmSync(path.join(source, 'nested'), { recursive: true })).not.toThrow()
})
it('turns an extract-over-dist write into an error instead of silent shared corruption', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'linked')
hardlinkTree(source, destination)
makeTreeReadOnly(destination)
expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'mutated')).toThrow()
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents')
})
it.runIf(process.platform !== 'win32')('preserves setuid, which chrome-sandbox needs', () => {
const { source } = makeTree()
const sandbox = path.join(source, 'chrome-sandbox')
writeFileSync(sandbox, 'binary')
chmodSync(sandbox, 0o4755)
makeTreeReadOnly(source)
expect(statSync(sandbox).mode & 0o4000).toBe(0o4000)
expect(statSync(sandbox).mode & 0o222).toBe(0)
})
it.runIf(process.platform !== 'win32')(
'keeps the executable bit, which Electron needs to launch',
() => {
const { source } = makeTree()
const executable = path.join(source, 'electron')
writeFileSync(executable, 'binary', { mode: 0o755 })
makeTreeReadOnly(source)
// Verified on real ext4: 0o555. Windows has no execute bit -- the read-only attribute does
// not gate execution there, confirmed by running a read-only hardlinked .exe on NTFS.
expect(statSync(executable).mode & 0o111).toBe(0o111)
}
)
})
describe('makeTreeWritable', () => {
it.runIf(process.platform !== 'win32')('undoes makeTreeReadOnly for the owner', () => {
const { source } = makeTree()
makeTreeReadOnly(source)
makeTreeWritable(source)
const file = path.join(source, 'nested', 'file')
expect(statSync(file).mode & 0o200).toBe(0o200)
expect(() => writeFileSync(file, 'mutated')).not.toThrow()
})
it.runIf(process.platform !== 'win32')('adds no write permission beyond the owner', () => {
const { source } = makeTree()
const executable = path.join(source, 'electron')
writeFileSync(executable, 'binary')
chmodSync(executable, 0o555)
makeTreeWritable(source)
expect(statSync(executable).mode & 0o777).toBe(0o755)
})
})
describe('copyPrivateTree', () => {
it.runIf(process.platform !== 'win32')(
'hands back a tree the caller can patch, even from a write-protected source',
() => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
makeTreeReadOnly(source)
copyPrivateTree(source, destination)
// The regression this guards: the shared Electron dist is read-only, clonefile/reflink/cpSync
// all carry that across, and `pn dev` then died patching the copied bundle's Info.plist.
expect(() => writeFileSync(path.join(destination, 'nested', 'file'), 'patched')).not.toThrow()
expect(readFileSync(path.join(source, 'nested', 'file'), 'utf8')).toBe('contents')
}
)
it('never hardlinks, because the caller patches what it gets back', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
const hardlink = vi.fn()
const result = copyPrivateTree(source, destination, { platform: 'linux', hardlink })
expect(hardlink).not.toHaveBeenCalled()
expect(statSync(path.join(destination, 'nested', 'file')).ino).not.toBe(
statSync(path.join(source, 'nested', 'file')).ino
)
expect(result.mechanism === 'reflink' || result.mechanism === null).toBe(true)
})
it('copies bytes on a platform with no private mechanism at all', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
const hardlink = vi.fn()
expect(copyPrivateTree(source, destination, { platform: 'win32', hardlink })).toEqual({
mechanism: null,
copyError: null
})
expect(hardlink).not.toHaveBeenCalled()
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
expect(readlinkSync(path.join(destination, 'relative-link'))).toBe(path.join('nested', 'file'))
})
it('reports the private mechanism it used', () => {
const { root, source } = makeTree()
const clone = vi.fn((_source: string, target: string) => mkdirSync(target, { recursive: true }))
expect(
copyPrivateTree(source, path.join(root, 'private'), { platform: 'darwin', clone })
).toEqual({
mechanism: 'clone',
copyError: null
})
})
it('falls back to a byte copy when the private mechanism fails', () => {
const { root, source } = makeTree()
const destination = path.join(root, 'private')
const clone = () => {
throw new Error('clonefile unsupported')
}
const result = copyPrivateTree(source, destination, { platform: 'darwin', clone })
expect(result.mechanism).toBeNull()
expect(result.copyError).toBeInstanceOf(Error)
expect(readFileSync(path.join(destination, 'nested', 'file'), 'utf8')).toBe('contents')
})
})
@@ -0,0 +1,260 @@
const { closeSync, fstatSync, openSync, readSync } = require('node:fs')
const { basename } = require('node:path')
const EXPECTED_ARCHITECTURE_BY_FILENAME = new Map([
['orca-linux.AppImage', 'x64'],
['orca-linux-arm64.AppImage', 'arm64']
])
const APPIMAGE_MAGIC = Buffer.from([0x41, 0x49, 0x02])
const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime')
const TARGET_ARCHITECTURE_BY_ENUM = new Map([
[1, 'x64'],
[3, 'arm64']
])
const RUNTIME_ARCHITECTURE_BY_MACHINE = new Map([
[0x3e, 'x64'],
[0xb7, 'arm64']
])
const ELF_HEADER_BYTES = 64
const PROGRAM_HEADER_BYTES = 56
const DYNAMIC_ENTRY_BYTES = 16
const MAX_PROGRAM_HEADERS = 128
const MAX_LOAD_BYTES = 16 * 1024 * 1024
const MAX_DYNAMIC_BYTES = 1024 * 1024
function verifyStaticAppImagePackage(filePath, targetArch) {
const filename = basename(filePath)
const filenameArchitecture = EXPECTED_ARCHITECTURE_BY_FILENAME.get(filename)
if (!filenameArchitecture) {
invalid(
filename,
`unsupported artifact name; expected ${[...EXPECTED_ARCHITECTURE_BY_FILENAME.keys()].join(' or ')}`
)
}
const targetArchitecture = normalizeTargetArchitecture(targetArch, filename)
if (filenameArchitecture !== targetArchitecture) {
invalid(
filename,
`artifact filename targets ${filenameArchitecture}, but electron-builder target is ${targetArchitecture}`
)
}
const descriptor = openSync(filePath, 'r')
try {
const stats = fstatSync(descriptor, { bigint: true })
if (process.platform !== 'win32' && (stats.mode & 0o111n) === 0n) {
invalid(filename, 'artifact is not executable')
}
const fileSize = stats.size
const header = readRange(
descriptor,
0n,
BigInt(ELF_HEADER_BYTES),
fileSize,
filename,
'ELF header'
)
const { entry, machine } = verifyElfHeader(header, filename)
const runtimeArchitecture = RUNTIME_ARCHITECTURE_BY_MACHINE.get(machine)
if (runtimeArchitecture !== targetArchitecture) {
invalid(
filename,
`runtime architecture ${runtimeArchitecture ?? `machine 0x${machine.toString(16)}`} does not match electron-builder target ${targetArchitecture}`
)
}
const programHeaderOffset = header.readBigUInt64LE(32)
const programHeaderSize = header.readUInt16LE(54)
const programHeaderCount = header.readUInt16LE(56)
if (programHeaderSize !== PROGRAM_HEADER_BYTES) {
invalid(filename, `unexpected ELF program-header size ${programHeaderSize}`)
}
if (programHeaderCount === 0 || programHeaderCount > MAX_PROGRAM_HEADERS) {
invalid(filename, `invalid ELF program-header count ${programHeaderCount}`)
}
const tableSize = BigInt(programHeaderSize * programHeaderCount)
const table = readRange(
descriptor,
programHeaderOffset,
tableSize,
fileSize,
filename,
'ELF program-header table'
)
const segments = parseProgramHeaders(table, programHeaderSize)
verifySegments(descriptor, segments, fileSize, filename, entry)
} finally {
closeSync(descriptor)
}
}
function verifyElfHeader(header, filename) {
if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) {
invalid(filename, 'missing ELF magic')
}
if (header[4] !== 2 || header[5] !== 1 || header[6] !== 1) {
invalid(filename, 'runtime must be ELF64 little-endian version 1')
}
if (!header.subarray(8, 11).equals(APPIMAGE_MAGIC)) {
invalid(filename, 'missing type-2 AppImage marker')
}
if (header.readUInt16LE(16) !== 3) {
invalid(filename, 'runtime must be an ET_DYN static PIE')
}
const machine = header.readUInt16LE(18)
if (!RUNTIME_ARCHITECTURE_BY_MACHINE.has(machine)) {
invalid(filename, `unsupported ELF machine 0x${machine.toString(16)}`)
}
if (header.readUInt32LE(20) !== 1) {
invalid(filename, 'runtime has an unsupported ELF version')
}
if (header.readUInt16LE(52) !== ELF_HEADER_BYTES) {
invalid(filename, `unexpected ELF header size ${header.readUInt16LE(52)}`)
}
return { entry: header.readBigUInt64LE(24), machine }
}
function parseProgramHeaders(table, entrySize) {
const segments = []
for (let offset = 0; offset < table.length; offset += entrySize) {
segments.push({
type: table.readUInt32LE(offset),
flags: table.readUInt32LE(offset + 4),
offset: table.readBigUInt64LE(offset + 8),
virtualAddress: table.readBigUInt64LE(offset + 16),
fileSize: table.readBigUInt64LE(offset + 32),
memorySize: table.readBigUInt64LE(offset + 40)
})
}
return segments
}
function verifySegments(descriptor, segments, fileSize, filename, entry) {
if (segments.some((segment) => segment.type === 3)) {
invalid(filename, 'runtime contains PT_INTERP')
}
const loadSegments = segments.filter((segment) => segment.type === 1)
const totalLoadBytes = loadSegments.reduce((total, segment) => total + segment.fileSize, 0n)
if (loadSegments.length === 0 || totalLoadBytes > BigInt(MAX_LOAD_BYTES)) {
invalid(filename, `invalid or oversized PT_LOAD data (${totalLoadBytes} bytes)`)
}
if (
!loadSegments.some(
(segment) =>
segment.flags & 1 &&
entry >= segment.virtualAddress &&
entry - segment.virtualAddress < segment.memorySize
)
) {
invalid(filename, 'ELF entry point is outside an executable PT_LOAD segment')
}
let identifiesStaticRuntime = false
for (const segment of loadSegments) {
verifyFileBackedSegment(segment, fileSize, filename, 'PT_LOAD')
const data = readRange(
descriptor,
segment.offset,
segment.fileSize,
fileSize,
filename,
'PT_LOAD data'
)
identifiesStaticRuntime ||= data.includes(RUNTIME_SOURCE)
}
if (!identifiesStaticRuntime) {
invalid(filename, `runtime does not identify ${RUNTIME_SOURCE.toString()}`)
}
for (const segment of segments.filter((entry) => entry.type === 2)) {
verifyDynamicSegment(descriptor, segment, fileSize, filename)
}
}
function normalizeTargetArchitecture(targetArch, filename) {
const architecture =
typeof targetArch === 'number' ? TARGET_ARCHITECTURE_BY_ENUM.get(targetArch) : targetArch
if (architecture !== 'x64' && architecture !== 'arm64') {
invalid(filename, `unsupported electron-builder target architecture ${String(targetArch)}`)
}
return architecture
}
function verifyFileBackedSegment(segment, fileSize, filename, label) {
if (segment.memorySize < segment.fileSize) {
invalid(filename, `${label} memory size is smaller than its file size`)
}
verifyRange(segment.offset, segment.fileSize, fileSize, filename, label)
}
function verifyDynamicSegment(descriptor, segment, fileSize, filename) {
verifyFileBackedSegment(segment, fileSize, filename, 'PT_DYNAMIC')
if (
segment.fileSize === 0n ||
segment.fileSize > BigInt(MAX_DYNAMIC_BYTES) ||
segment.fileSize % BigInt(DYNAMIC_ENTRY_BYTES) !== 0n
) {
invalid(filename, `invalid PT_DYNAMIC size ${segment.fileSize}`)
}
const dynamic = readRange(
descriptor,
segment.offset,
segment.fileSize,
fileSize,
filename,
'PT_DYNAMIC data'
)
let terminated = false
for (let offset = 0; offset < dynamic.length; offset += DYNAMIC_ENTRY_BYTES) {
const tag = dynamic.readBigInt64LE(offset)
if (tag === 0n) {
terminated = true
break
}
if (tag === 1n) {
invalid(filename, 'runtime contains a DT_NEEDED dependency')
}
}
if (!terminated) {
invalid(filename, 'PT_DYNAMIC is missing DT_NULL')
}
}
function readRange(descriptor, offset, size, fileSize, filename, label) {
verifyRange(offset, size, fileSize, filename, label)
const buffer = Buffer.alloc(Number(size))
let bytesRead = 0
while (bytesRead < buffer.length) {
const count = readSync(
descriptor,
buffer,
bytesRead,
buffer.length - bytesRead,
Number(offset) + bytesRead
)
if (count === 0) {
throw new Error(`Unable to read complete ${label}`)
}
bytesRead += count
}
return buffer
}
function verifyRange(offset, size, fileSize, filename, label) {
const maxSafeOffset = BigInt(Number.MAX_SAFE_INTEGER)
if (
offset > fileSize ||
size > fileSize - offset ||
offset > maxSafeOffset ||
size > maxSafeOffset - offset
) {
invalid(filename, `${label} is outside the artifact`)
}
}
function invalid(filename, reason) {
throw new Error(`Invalid static AppImage ${filename}: ${reason}`)
}
module.exports = { verifyStaticAppImagePackage }
@@ -0,0 +1,225 @@
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const { verifyStaticAppImagePackage } = require('./static-appimage-package-contract.cjs')
const RUNTIME_SOURCE = Buffer.from('https://github.com/AppImage/type2-runtime')
const LOAD_HEADER = 64
const DYNAMIC_HEADER = 120
const DYNAMIC_OFFSET = 320
const FIXTURE_BYTES = 384
describe('static AppImage package contract', () => {
it.each([
['orca-linux.AppImage', 0x3e, 1],
['orca-linux-arm64.AppImage', 0xb7, 'arm64']
])('accepts a dependency-free type-2 %s runtime', async (filename, machine, targetArch) => {
await withFixture(filename, createRuntime({ machine }), (path) => {
expect(() => verifyStaticAppImagePackage(path, targetArch)).not.toThrow()
})
})
it.each([
['generic filename for an arm64 runtime and target', 'orca-linux.AppImage', 0xb7, 3],
['arm64 filename for an x64 runtime and target', 'orca-linux-arm64.AppImage', 0x3e, 1],
['generic x64 runtime for an arm64 target', 'orca-linux.AppImage', 0x3e, 3],
['generic arm64 runtime for an x64 target', 'orca-linux.AppImage', 0xb7, 1],
['arm64 artifact filename for an x64 target', 'orca-linux-arm64.AppImage', 0xb7, 1],
['x64 runtime under an arm64 artifact filename', 'orca-linux-arm64.AppImage', 0x3e, 3]
])('rejects %s', async (_label, filename, machine, targetArch) => {
await withFixture(filename, createRuntime({ machine }), (path) => {
expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/architecture|target/)
})
})
it.each([undefined, 0, 'ia32'])(
'rejects unsupported target architecture %s',
async (targetArch) => {
await withFixture('orca-linux.AppImage', createRuntime(), (path) => {
expect(() => verifyStaticAppImagePackage(path, targetArch)).toThrow(/target architecture/)
})
}
)
it('accepts PT_DYNAMIC relocation metadata without dependencies', async () => {
const runtime = createRuntime()
runtime.writeBigInt64LE(7n, DYNAMIC_OFFSET)
await withFixture('orca-linux.AppImage', runtime, (path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow()
})
})
it('does not scan the appended AppImage payload as outer ELF data', async () => {
const payload = Buffer.concat([RUNTIME_SOURCE, Buffer.alloc(16, 1)])
await withFixture('orca-linux.AppImage', Buffer.concat([createRuntime(), payload]), (path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).not.toThrow()
})
const unidentifiedRuntime = createRuntime()
unidentifiedRuntime.fill(0, 192, 192 + RUNTIME_SOURCE.length)
await withFixture(
'orca-linux.AppImage',
Buffer.concat([unidentifiedRuntime, payload]),
(path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/does not identify/)
}
)
})
it('rejects artifact names outside the release contract before reading them', () => {
expect(() => verifyStaticAppImagePackage('/missing/orca-preview.AppImage')).toThrow(
'unsupported artifact name'
)
})
it.skipIf(process.platform === 'win32')(
'rejects a readable but non-executable AppImage',
async () => {
await withFixture(
'orca-linux.AppImage',
createRuntime(),
(path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(/not executable/)
},
{ mode: 0o644 }
)
}
)
it.each([
[
'non-ELF64 runtimes',
(runtime) => {
runtime[4] = 1
},
/ELF64 little-endian/
],
[
'unsupported ELF versions',
(runtime) => runtime.writeUInt32LE(2, 20),
/unsupported ELF version/
],
[
'non-type-2 AppImages',
(runtime) => {
runtime[10] = 1
},
/type-2 AppImage marker/
],
['non-PIE runtimes', (runtime) => runtime.writeUInt16LE(2, 16), /ET_DYN static PIE/],
[
'unsupported architectures',
(runtime) => runtime.writeUInt16LE(3, 18),
/unsupported ELF machine/
],
['dynamic loaders', (runtime) => runtime.writeUInt32LE(3, DYNAMIC_HEADER), /PT_INTERP/],
[
'shared-library dependencies',
(runtime) => runtime.writeBigInt64LE(1n, DYNAMIC_OFFSET),
/DT_NEEDED/
],
[
'unidentified runtimes',
(runtime) => runtime.fill(0, 192, 192 + RUNTIME_SOURCE.length),
/does not identify/
],
[
'out-of-bounds load segments',
(runtime) => {
runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 32)
runtime.writeBigUInt64LE(1000n, LOAD_HEADER + 40)
},
/outside the artifact/
],
[
'oversized load claims',
(runtime) => {
runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 32)
runtime.writeBigUInt64LE(16n * 1024n * 1024n + 1n, LOAD_HEADER + 40)
},
/oversized PT_LOAD/
],
[
'non-executable entry segments',
(runtime) => runtime.writeUInt32LE(4, LOAD_HEADER + 4),
/executable PT_LOAD/
],
[
'entry points outside load segments',
(runtime) => runtime.writeBigUInt64LE(4096n, 24),
/entry point/
]
])('rejects %s', async (_label, mutate, expected) => {
const runtime = createRuntime()
mutate(runtime)
await withFixture('orca-linux.AppImage', runtime, (path) => {
expect(() => verifyStaticAppImagePackage(path, 1)).toThrow(expected)
})
})
})
function createRuntime({ machine = 0x3e } = {}) {
const runtime = Buffer.alloc(FIXTURE_BYTES)
Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(runtime)
Buffer.from([0x41, 0x49, 0x02]).copy(runtime, 8)
runtime.writeUInt16LE(3, 16)
runtime.writeUInt16LE(machine, 18)
runtime.writeUInt32LE(1, 20)
runtime.writeBigUInt64LE(0n, 24)
runtime.writeBigUInt64LE(64n, 32)
runtime.writeUInt16LE(64, 52)
runtime.writeUInt16LE(56, 54)
runtime.writeUInt16LE(2, 56)
writeProgramHeader(runtime, LOAD_HEADER, {
type: 1,
flags: 5,
offset: 0,
virtualAddress: 0,
size: FIXTURE_BYTES,
memorySize: FIXTURE_BYTES,
alignment: 4096
})
writeProgramHeader(runtime, DYNAMIC_HEADER, {
type: 2,
flags: 4,
offset: DYNAMIC_OFFSET,
virtualAddress: DYNAMIC_OFFSET,
size: 32,
memorySize: 32,
alignment: 8
})
RUNTIME_SOURCE.copy(runtime, 192)
return runtime
}
function writeProgramHeader(
runtime,
headerOffset,
{ type, flags, offset, virtualAddress, size, memorySize = size, alignment }
) {
runtime.writeUInt32LE(type, headerOffset)
runtime.writeUInt32LE(flags, headerOffset + 4)
runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 8)
runtime.writeBigUInt64LE(BigInt(virtualAddress), headerOffset + 16)
runtime.writeBigUInt64LE(BigInt(offset), headerOffset + 24)
runtime.writeBigUInt64LE(BigInt(size), headerOffset + 32)
runtime.writeBigUInt64LE(BigInt(memorySize), headerOffset + 40)
runtime.writeBigUInt64LE(BigInt(alignment), headerOffset + 48)
}
async function withFixture(filename, contents, check, { mode = 0o755 } = {}) {
const root = await mkdtemp(join(tmpdir(), 'orca-static-appimage-contract-'))
try {
const path = join(root, filename)
await writeFile(path, contents)
await chmod(path, mode)
await check(path)
} finally {
await rm(root, { recursive: true, force: true })
}
}
+9 -10
View File
@@ -5,15 +5,14 @@ import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'nod
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const OUT_COMMONJS_PACKAGE_JSON = `${JSON.stringify(
{
name: 'orca-compiled-output',
type: 'commonjs',
private: true
},
null,
2
)}\n`
// Electron packaging restamps the channel-specific version after compilation.
function buildOutPackageJson(version) {
return `${JSON.stringify(
{ name: 'orca-compiled-output', type: 'commonjs', private: true, version },
null,
2
)}\n`
}
/**
* Verifies the published CLI entrypoint and the module-type boundary for the
@@ -49,7 +48,7 @@ export function verifyPackageCliBin({
const outPackageJsonPath = path.join(projectDir, 'out', 'package.json')
if (fixPackageJson) {
mkdirSync(path.dirname(outPackageJsonPath), { recursive: true })
writeFileSync(outPackageJsonPath, OUT_COMMONJS_PACKAGE_JSON, 'utf8')
writeFileSync(outPackageJsonPath, buildOutPackageJson(packageJson.version), 'utf8')
}
let outPackageJson
try {
@@ -164,6 +164,78 @@ function findMissingProviderDeps(importedSymbols, neededLibraries) {
return missing
}
// ELF e_machine values for the Linux slices we package. Names match electron-builder's Arch enum.
const ELF_MACHINE_BY_ARCH = Object.freeze({ x64: 0x3e, arm64: 0xb7 })
const ARCH_BY_ELF_MACHINE = Object.freeze({ 0x3e: 'x64', 0xb7: 'arm64' })
/**
* ELF `e_machine`, or null when the file is not a readable little-endian ELF.
*
* Why this is checked at all: cross-building an arm64 package on an x64 host can silently pack an
* x86-64 `pty.node` into the arm64 slice — the rebuild logs a forced arm64 rebuild and still ships
* the host's binary. Every other gate here inspects symbol versions, which are perfectly valid on
* the wrong architecture, so nothing noticed. Observed on a Raspberry Pi 5: the app loaded, then
* failed with "Failed to load native module: pty.node".
*/
function readElfMachine(filePath) {
let fd
try {
fd = openSync(filePath, 'r')
const header = Buffer.alloc(20)
if (readSync(fd, header, 0, 20, 0) !== 20) {
return null
}
// EI_DATA (offset 5) must be ELFDATA2LSB for a little-endian e_machine read.
if (header[5] !== 1) {
return null
}
return header.readUInt16LE(18)
} catch {
return null
} finally {
if (fd !== undefined) {
closeSync(fd)
}
}
}
// Arch tokens that appear in vendored per-architecture package/directory names.
const ARCH_TOKEN_PATTERN = /(?:^|[^a-z0-9])(arm64|aarch64|x64|x86_64)(?:[^a-z0-9]|$)/i
const ARCH_BY_TOKEN = Object.freeze({ arm64: 'arm64', aarch64: 'arm64', x64: 'x64', x86_64: 'x64' })
/**
* The architecture a path advertises, or null when it advertises none.
*
* Why this matters: some dependencies ship every architecture and let their loader pick
* (`@parcel/watcher-linux-arm64-glibc/watcher.node` is arm64 on purpose inside an x64 build). Those
* must be judged against the arch their own path declares, not against the slice.
*/
function declaredArchFromPath(filePath) {
const match = ARCH_TOKEN_PATTERN.exec(filePath)
return match ? ARCH_BY_TOKEN[match[1].toLowerCase()] : null
}
function findArchViolation(filePath, targetArch) {
// A path that names an architecture is judged against that name, so a per-arch vendored package
// is fine while `bin/linux-arm64-.../node-pty.node` holding an x86-64 binary is still caught.
const declared = declaredArchFromPath(filePath)
const expectedArch = declared ?? targetArch
const expected = ELF_MACHINE_BY_ARCH[expectedArch]
if (expected === undefined) {
return null
}
const machine = readElfMachine(filePath)
if (machine === null || machine === expected) {
return null
}
return {
machine,
actual: ARCH_BY_ELF_MACHINE[machine] ?? `0x${machine.toString(16)}`,
expectedArch,
declared: declared !== null
}
}
function isElfFile(filePath) {
let fd
try {
@@ -312,6 +384,7 @@ function readImportedSymbols(filePath, objdumpPath) {
*/
function verifyLinuxGlibcFloor(rootDir, options = {}) {
const binaries = collectNativeBinaries(rootDir)
const targetArch = options.targetArch
if (binaries.length === 0) {
console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`)
return
@@ -327,6 +400,28 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) {
)
}
// Why before the glibc pass: a wrong-architecture binary's symbol versions are valid but
// meaningless, so reporting a floor violation for it would send the reader down the wrong path.
const archOffenders = binaries
.map((filePath) => ({ filePath, violation: findArchViolation(filePath, targetArch) }))
.filter(({ violation }) => violation !== null)
if (archOffenders.length > 0) {
const detail = archOffenders
.map(
({ filePath, violation }) =>
` ${relative(rootDir, filePath) || filePath} is ${violation.actual}, expected ` +
`${violation.expectedArch}${violation.declared ? ' (from its own path)' : ''}`
)
.join('\n')
throw new Error(
`[verify-linux-glibc-floor] ${archOffenders.length} bundled native binar` +
`${archOffenders.length === 1 ? 'y is' : 'ies are'} built for the wrong architecture ` +
`(target ${targetArch}), so the app will fail to load them at runtime:\n${detail}\n` +
'Cross-building a Linux slice can pack the host architecture despite a forced rebuild; ' +
'build this slice on a native runner.'
)
}
const offenders = []
for (const filePath of binaries) {
const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath)
@@ -375,6 +470,10 @@ function verifyLinuxGlibcFloor(rootDir, options = {}) {
module.exports = {
MIN_GLIBC,
ELF_MACHINE_BY_ARCH,
readElfMachine,
declaredArchFromPath,
findArchViolation,
VERSION_FLOORS,
FLOOR_LABEL,
RELOCATED_SYMBOL_PROVIDERS,
@@ -6,6 +6,10 @@ import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
readElfMachine,
declaredArchFromPath,
findArchViolation,
ELF_MACHINE_BY_ARCH,
parseGlibcVersion,
compareGlibcVersions,
parseVersionNeeds,
@@ -321,3 +325,86 @@ describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => {
}
})
})
/** Minimal little-endian 64-bit ELF header with the given e_machine. */
function elfHeader(machine) {
const header = Buffer.alloc(64)
header.write('\x7fELF', 0, 'latin1')
header[4] = 2 // ELFCLASS64
header[5] = 1 // ELFDATA2LSB
header[6] = 1 // EV_CURRENT
header.writeUInt16LE(3, 16) // ET_DYN
header.writeUInt16LE(machine, 18)
return header
}
describe('bundled native binary architecture', () => {
it('reads e_machine from a little-endian ELF', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const file = join(dir, 'pty.node')
await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.arm64))
expect(readElfMachine(file)).toBe(ELF_MACHINE_BY_ARCH.arm64)
await rm(dir, { recursive: true, force: true })
})
// The observed failure: cross-building arm64 on an x64 host packed an x86-64 pty.node, whose
// symbol versions are valid, so every other gate here passed it.
// Real CI hit: @parcel/watcher ships every architecture and its loader picks the match, so the
// arm64 copy is present in an x64 build on purpose.
it('accepts a per-arch vendored package that matches its own path', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const pkg = join(dir, '@parcel', 'watcher-linux-arm64-glibc')
await mkdir(pkg, { recursive: true })
const file = join(pkg, 'watcher.node')
await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.arm64))
expect(declaredArchFromPath(file)).toBe('arm64')
expect(findArchViolation(file, 'x64')).toBeNull()
await rm(dir, { recursive: true, force: true })
})
// But a path that names an arch must actually hold it — this is the Pi 5 failure.
it('flags a binary that contradicts the architecture its own path names', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const nested = join(dir, 'bin', 'linux-arm64-148')
await mkdir(nested, { recursive: true })
const file = join(nested, 'node-pty.node')
await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64))
expect(findArchViolation(file, 'arm64')).toMatchObject({ actual: 'x64', expectedArch: 'arm64' })
// Still caught even when the slice being built is x64.
expect(findArchViolation(file, 'x64')).toMatchObject({ actual: 'x64', expectedArch: 'arm64' })
await rm(dir, { recursive: true, force: true })
})
it('flags an x86-64 binary in an arm64 slice', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const file = join(dir, 'pty.node')
await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64))
expect(findArchViolation(file, 'arm64')).toMatchObject({ actual: 'x64' })
await rm(dir, { recursive: true, force: true })
})
it('accepts a matching architecture', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const file = join(dir, 'pty.node')
await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64))
expect(findArchViolation(file, 'x64')).toBeNull()
await rm(dir, { recursive: true, force: true })
})
it('stays silent when no target architecture is supplied', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const file = join(dir, 'pty.node')
await writeFile(file, elfHeader(ELF_MACHINE_BY_ARCH.x64))
expect(findArchViolation(file, undefined)).toBeNull()
await rm(dir, { recursive: true, force: true })
})
it('ignores a file that is not a readable little-endian ELF', async () => {
const dir = await mkdtemp(join(tmpdir(), 'orca-elf-arch-'))
const file = join(dir, 'not-elf.node')
await writeFile(file, Buffer.from('not an elf at all'))
expect(readElfMachine(file)).toBeNull()
expect(findArchViolation(file, 'arm64')).toBeNull()
await rm(dir, { recursive: true, force: true })
})
})
@@ -0,0 +1,172 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join, relative } from 'node:path'
import { readCallOptionKeys } from './call-site-option-keys'
/**
* Locate every `new WebSocketServer(...)` in the tree and say, for each, whether
* it pins a bind address.
*
* `ws` accepts `{ port }` alone and silently binds the wildcard address, so a
* server the caller then dials on 127.0.0.1 sits at a port a foreign loopback
* listener can also hold -- and the more specific listener wins the connection,
* answering in that server's place.
*
* Anything unreadable is reported as `opaque` rather than skipped. A matcher
* that silently exempts the shapes it fails to parse is worse than no matcher,
* because it reads as coverage.
*/
export type BindSite = { path: string; line: number }
export type OpaqueSite = BindSite & { reason: string }
export type WebSocketServerBindScan = {
filesScanned: number
/** Every construction recognized, however it was then classified. */
constructions: number
/** Binds a port with no `host`: reachable at an address the dialer never named. */
wildcardBound: BindSite[]
/** Shape that could not be read; never treated as safe. */
opaque: OpaqueSite[]
/** Binds a port and pins `host`. */
loopbackBound: BindSite[]
/** No `port`: attaches to a server that owns the bind itself. */
attached: BindSite[]
}
const IGNORED_DIRECTORIES = new Set([
'node_modules',
'dist',
'out',
'build',
'.git',
'__fixtures__',
'coverage',
// Full snapshots of older releases; their bind sites are not this tree's to fix.
'.cross-version-checkouts'
])
const SCANNED_EXTENSIONS = /\.(?:ts|tsx|mts|cts)$/
const SCANNED_ROOTS = ['src', 'mobile', 'config', 'tests']
const WS_IMPORT_HINT = /from\s*['"]ws['"]/
function collectSourceFiles(root: string, found: string[] = []): string[] {
let entries: ReturnType<typeof readdirSync<{ withFileTypes: true }>>
try {
entries = readdirSync(root, { withFileTypes: true })
} catch {
return found
}
for (const entry of entries) {
if (IGNORED_DIRECTORIES.has(entry.name)) {
continue
}
const full = join(root, entry.name)
if (entry.isDirectory()) {
collectSourceFiles(full, found)
} else if (SCANNED_EXTENSIONS.test(entry.name)) {
found.push(full)
}
}
return found
}
/** Local names bound to ws's server class, following `as` aliases and namespace imports. */
function webSocketServerNames(text: string): { direct: Set<string>; namespaces: Set<string> } {
const direct = new Set<string>()
const namespaces = new Set<string>()
// One statement at a time: a pattern reaching for `from 'ws'` would swallow
// every import above it and lose the specifier names in the blob.
for (const match of text.matchAll(/\bimport\b([\s\S]*?)\bfrom\s*(['"])([^'"]+)\2/g)) {
if (match[3] !== 'ws') {
continue
}
const clause = match[1]
if (/^\s*type\b/.test(clause)) {
continue
}
const namespace = clause.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/)
if (namespace) {
namespaces.add(namespace[1])
}
const named = clause.match(/\{([\s\S]*)\}/)
if (!named) {
continue
}
for (const specifier of named[1].split(',')) {
const trimmed = specifier.trim()
if (!trimmed || /^type\s/.test(trimmed)) {
continue
}
const parts = trimmed.split(/\s+as\s+/)
// `Server` is ws's own alias for WebSocketServer.
if (parts[0].trim() === 'WebSocketServer' || parts[0].trim() === 'Server') {
direct.add((parts[1] ?? parts[0]).trim())
}
}
}
return { direct, namespaces }
}
function classify(
scan: WebSocketServerBindScan,
site: BindSite,
text: string,
paren: number
): void {
const options = readCallOptionKeys(text, paren)
if (!options.readable) {
scan.opaque.push({ ...site, reason: options.reason })
return
}
if (!options.keys.includes('port')) {
scan.attached.push(site)
return
}
if (!options.keys.includes('host')) {
scan.wildcardBound.push(site)
return
}
scan.loopbackBound.push(site)
}
export function scanWebSocketServerBinds(repoRoot: string): WebSocketServerBindScan {
const files = SCANNED_ROOTS.flatMap((directory) => collectSourceFiles(join(repoRoot, directory)))
const scan: WebSocketServerBindScan = {
filesScanned: files.length,
constructions: 0,
wildcardBound: [],
opaque: [],
loopbackBound: [],
attached: []
}
for (const file of files) {
const text = readFileSync(file, 'utf8')
// Filter on the import, not on the class name: `Server as Wss` never spells
// WebSocketServer, and keying on that name silently skipped the whole alias.
if (!WS_IMPORT_HINT.test(text)) {
continue
}
const { direct, namespaces } = webSocketServerNames(text)
if (!direct.size && !namespaces.size) {
continue
}
const path = relative(repoRoot, file).split('\\').join('/')
const patterns = [
...[...direct].map((name) => new RegExp(`\\bnew\\s+${name}\\s*\\(`, 'g')),
...[...namespaces].map(
(name) => new RegExp(`\\bnew\\s+${name}\\.(?:WebSocketServer|Server)\\s*\\(`, 'g')
)
]
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
scan.constructions++
const line = text.slice(0, match.index).split('\n').length
classify(scan, { path, line }, text, match.index + match[0].length - 1)
}
}
}
return scan
}
export function formatSites(sites: readonly BindSite[]): string[] {
return sites.map((site) => `${site.path}:${site.line}`)
}
@@ -0,0 +1,106 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { formatSites, scanWebSocketServerBinds } from './websocket-server-bind-scan'
/**
* Hold the bind address at the tree level rather than per call site.
*
* Every one of the ~30 `.listen(0, ...)` calls in this repo already passes
* '127.0.0.1'; 7 of 7 `new WebSocketServer({ port })` calls did not. Authors know
* the convention -- `ws` just never asks, because `{ port }` alone binds the
* wildcard without a word. That silence is what this test replaces.
*
* The allowlist only shrinks. A new wildcard bind fails here even where it looks
* harmless today, because harmless-looking is exactly what the seven were.
*/
/** The ratchet, held as data so it reads as the list it is. */
const WILDCARD_BIND_ALLOWLIST: readonly string[] = readFileSync(
join(__dirname, '__fixtures__', 'websocket-server-wildcard-bind-allowlist.txt'),
'utf8'
)
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
/**
* The true count of constructions that bind a port without pinning a host.
*
* May only ever be DECREASED, and only by pinning a host. Raising it is never
* the fix.
*/
const WILDCARD_BIND_PIN = 1
/**
* A floor under the constructions the scanner still recognizes.
*
* This is the guard against the scanner going blind: an import pattern it stops
* following reports zero offenders and reads exactly like a clean tree. During
* development a single wrong regex dropped this from 24 to 3.
*/
const RECOGNIZED_CONSTRUCTION_FLOOR = 20
describe('WebSocketServer loopback bind boundary', () => {
const repoRoot = resolve(__dirname, '..', '..')
const scan = scanWebSocketServerBinds(repoRoot)
const offenders = scan.wildcardBound.map((site) => site.path)
it('scans a plausible number of files', () => {
// A broken root or extension list would make the guard silently vacuous.
expect(scan.filesScanned).toBeGreaterThan(5_000)
})
it('still recognizes the known construction sites', () => {
expect(
scan.constructions,
`Only ${scan.constructions} WebSocketServer constructions were recognized; the floor is ` +
`${RECOGNIZED_CONSTRUCTION_FLOOR}. The scanner has probably stopped following an import ` +
'shape rather than the tree having lost that many servers.'
).toBeGreaterThanOrEqual(RECOGNIZED_CONSTRUCTION_FLOOR)
})
it('can read the options of every construction it found', () => {
// An unreadable shape is never assumed safe: it could be hiding a host, or
// hiding the absence of one. Rewrite it as a plain object literal.
expect(
scan.opaque.map((site) => `${site.path}:${site.line} -- ${site.reason}`),
'WebSocketServer options that this guard cannot read.'
).toEqual([])
})
it('has no wildcard-bound server outside the allowlist', () => {
const unlisted = scan.wildcardBound.filter(
(site) => !WILDCARD_BIND_ALLOWLIST.includes(site.path)
)
expect(
formatSites(unlisted),
"New WebSocketServer that binds a port without a host. Pass host: '127.0.0.1' so a foreign " +
'loopback listener cannot claim the port and answer in its place.'
).toEqual([])
})
it('has no stale allowlist entry', () => {
// Why this direction matters too: an entry left behind after the file was
// fixed hides the next regression in that same path.
const stale = WILDCARD_BIND_ALLOWLIST.filter((path) => !offenders.includes(path))
expect(stale, 'Allowlist entry no longer binds the wildcard — delete the line.').toEqual([])
})
it('holds the wildcard-bind count at the pin', () => {
// Bounding by the allowlist's own length would prove nothing: the two move
// together, so appending a line to silence a failure would keep the bound
// satisfied. The pin is a literal so that widening takes a second edit.
expect(
scan.wildcardBound.length,
`${scan.wildcardBound.length} constructions bind the wildcard; the pin is ` +
`${WILDCARD_BIND_PIN}. Never raise the pin -- pass host: '127.0.0.1' instead.`
).toBeLessThanOrEqual(WILDCARD_BIND_PIN)
// A pin left above reality is how a ratchet rots: it re-opens room for the
// next wildcard bind to land for free.
expect(
scan.wildcardBound.length,
`Only ${scan.wildcardBound.length} constructions bind the wildcard. Lower ` +
`WILDCARD_BIND_PIN to ${scan.wildcardBound.length} to keep the ground you just took.`
).toBeGreaterThanOrEqual(WILDCARD_BIND_PIN)
})
})
@@ -0,0 +1,673 @@
import { readFileSync, statSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { scanSourceTree, stripComments } from '../../src/shared/source-scan/source-tree-scan'
import { classifyPrJobs } from './pr-code-change-scope.mjs'
/**
* Every Windows-gated test file must be registered in BOTH Windows-lane lists.
*
* PR CI has exactly one job on a Windows runner -- asserted below on any
* `runs-on` spelling that could land there, because that premise is what makes
* this guard meaningful -- and it runs a curated explicit file list. Everything else runs on `ubuntu-latest`, where a Windows-gated
* suite self-skips and reports success. So a new Windows-gated file that nobody
* registers executes on no machine and passes green, silently. A recent
* security effort added six such files; five ran nowhere, including one whose
* whole point was asserting a native addon's bytes no longer contain a flagged
* primitive. Registering the instances did not hold -- a sixth arrived from
* unrelated work while the first five were being fixed -- so the class needs a
* guard.
*
* Both lists matter and being in one is not enough: `WINDOWS_PACKAGE_TESTS` in
* pr-code-change-scope.mjs decides whether the `package_windows` job RUNS at
* all for a diff, and the workflow step's vitest argv decides whether the FILE
* runs once the job started.
*
* WHAT THIS DETECTS -- a file is Windows-gated when its name is `*.win32.test.*`
* / `*.win32.spec.*`, or when it contains ANY suite-level gate, nested ones
* included, spelled:
* - `describe.runIf(<true only on win32>)`, `describe.skipIf(<false only on win32>)`
* - `const d = <true only on win32> ? describe : describe.skip`, and the
* `? describe.skip : describe` inversion
* where the condition is `process.platform === 'win32'` / `!== 'win32'`, a
* compound `<win32 check> && <anything>`, or a `const`/`let` in the same file
* assigned from either -- so `const RUN_REAL = platform === 'win32' && env…`
* used as `describe.runIf(RUN_REAL)` is detected, whatever the flag is named
* and whichever polarity it was written in. Quote style, spacing and the
* `describe`/`suite` spelling are tolerated. Nested gates count because the
* Windows lane runs whole files: a win32-only block buried three levels down
* still runs on no machine unless the file is registered.
*
* WHAT THIS CANNOT DETECT -- known blind spots, each deliberate:
* - `it`/`test`-level gates. A single win32-only case inside a cross-platform
* suite still leaves the file running its other cases on ubuntu, and
* pulling all such files -- about thirty, though the figure moves with
* which gate spellings you count, so do not lean on it -- into the serial
* Windows job is not the trade CI wants. This is the largest limit, and it
* is a policy choice, not an oversight: a suite-level gate means a whole
* block exists only for Windows, which is the shape worth a lane entry.
* - a gate whose condition crosses a module boundary or a function call --
* an imported flag, an imported `describeOnWindows`, `isWindows()`.
* `legacy-wsl-runtime-auth-drain-apply-script.test.ts` imports its
* `isWindows`; it happens to be a POSIX-only gate, so nothing is missed
* today, but a win32-only one written that way would be.
* - `runIf(<win32> || <x>)` and `skipIf(<not win32> && <x>)` are rejected on
* purpose: both can run off Windows, so neither is a win32-only gate. That
* holds whether the condition is written at the gate or routed through a
* named flag -- the two spellings used to disagree.
* - whether a registered suite EXECUTES. Registration is what is asserted. A
* suite gated on win32 plus an env var stays skipped on the CI runner even
* when registered -- see MANUAL_OPT_IN -- and a path registered but gated
* for another platform is not caught either.
* - whether the `package_windows` job is triggered for a given diff, or
* whether the registered test asserts anything worth running.
*
* Growth of the two grandfathered lists is capped by literals, but only review
* stops someone raising a cap. The caps make that an explicit, visible edit.
*/
const projectDir = resolve(import.meta.dirname, '../..')
const WINDOWS_LANE_JOB = 'package_windows'
const WINDOWS_LANE_STEP = 'Test Windows-specific boundaries'
const WINDOWS_LANE_RUNNER = 'windows-2022'
/**
* Windows-gated files that predate this guard and are registered in neither
* list. Shrink-only: registering one means deleting its line here. Never add.
*/
const UNREGISTERED_ON_MAIN = [
// Suite gated with `describe.skipIf(platform !== 'win32')`; the cross-platform
// half of the file still runs on ubuntu, the Windows half runs nowhere.
'src/main/antigravity/windows-hook-payload-delivery.test.ts',
// `.win32.test.ts` by name yet in neither list -- the plainest instance of the class.
'src/main/daemon/node-pty-windows-input-error.win32.test.ts',
// Same shape as the antigravity file: a win32-only sibling suite that never runs.
'src/main/grok/windows-grok-hook-script.test.ts',
// Whole file is `describe.runIf(platform === 'win32')`; runs on no machine.
'src/main/ipc/preflight-windows-path-refresh.repro.test.ts',
// Nested `describe.skipIf(!isWindows)` real-shell block; never exercised in CI.
'src/main/ipc/pty-encoding.test.ts',
// `describeWindows` ternary over the whole file; runs on no machine.
'src/main/providers/windows-shell-preflight-runtime.windows.test.ts',
// Whole file is `describe.runIf(platform === 'win32')`; runs on no machine.
'src/main/startup/windows-shell-path-restoration.windows.test.ts',
// Whole file is `describe.skipIf(platform !== 'win32')`; runs on no machine.
'src/shared/setup-agent-sequencing.windows.test.ts'
]
/**
* Windows-gated suites that ALSO require an opt-in env var, so registering them
* would not make them execute -- they are run by hand against a real distro or
* a real filesystem. Excluded deliberately and visibly rather than by accident
* of a regex; each entry is asserted below to be genuinely env-gated, so this
* list cannot become a place to park a file someone did not want to register.
*/
const MANUAL_OPT_IN = [
// `runIf(platform === 'win32' && Boolean(distro))`, distro from ORCA_TEST_WSL_DISTRO.
'src/main/git/runner-wsl-linked-gitdir-windows.test.ts',
// `runRealWsl = … && ORCA_REAL_WSL_BANNER_TEST === '1'`; needs a real distro.
'src/main/local-worktree-filesystem-wsl-banner.wsl.test.ts',
// `RUN_REAL_WINDOWS = platform === 'win32' && ORCA_REAL_WINDOWS_SKILL_TEST === '1'`.
'src/main/skills/skill-windows-rename-contention.integration.test.ts',
// Same flag; installs into a real Windows workspace.
'src/main/skills/skill-windows-workspace.integration.test.ts',
// `RUN_REAL_WSL = … && ORCA_REAL_WSL_SKILL_TEST === '1'`; real distro filesystem.
'src/main/skills/skill-wsl-delete.integration.test.ts',
// Same flag; real WSL install transactions.
'src/main/skills/skill-wsl-install-transaction.integration.test.ts',
// Same flag; real WSL POSIX semantics.
'src/main/skills/skill-wsl-posix-semantics.integration.test.ts',
// `runRealWsl = … && ORCA_REAL_WSL_DELETE_TEST === '1'`; real distro traversal race.
'src/main/wsl-approved-root-race.wsl.test.ts',
// Same flag; real UNC delete against a distro.
'src/main/wsl-unc-delete.wsl.test.ts',
// `enabled = platform === 'win32' && ORCA_REAL_WSL_RUNNER_TEST === '1'`; mutates a real distro's ~/.profile.
'src/main/wsl/wsl-runner.wsl.test.ts'
]
/** Caps so growing either list is two deliberate edits, not one. */
const UNREGISTERED_MAX = 8
const MANUAL_OPT_IN_MAX = 10
/**
* Floor for the Windows-gated population, so a broken walk or a regex that
* stops matching cannot make the guard pass by finding nothing. Only ever
* lowered, and only when a gated file is genuinely deleted.
*/
const GATED_FILE_FLOOR = 23
const TEST_FILE_PATTERN = /\.(?:test|spec)\.(?:ts|tsx|mjs|cjs|js)$/
/**
* Mobile has its own vitest run and never touches the desktop Windows job:
* `classifyPrJobs` reports `package_windows: false` for every `mobile/` path,
* so a gated file there could not satisfy this guard even in principle.
*/
const UNREACHABLE_BY_THE_WINDOWS_LANE = 'mobile/'
/**
* This file quotes every gate spelling as a fixture, so it matches its own
* matcher. It is not gated -- it must run on ubuntu, since a guard about
* Windows CI that only ran on Windows would be self-defeating. Exempt by exact
* path, never by directory, so a real gated file in config/scripts is caught.
*/
const SCANNER_SELF_PATH = 'config/scripts/win32-test-lane-registration.test.mjs'
export function isScannerSelfPath(path) {
return path === SCANNER_SELF_PATH
}
const WIN32_TRUE_EXPRESSION = String.raw`process\.platform\s*===\s*['"]win32['"]`
const WIN32_FALSE_EXPRESSION = String.raw`process\.platform\s*!==\s*['"]win32['"]`
const SUITE = String.raw`(?:describe|suite)`
/**
* Named flags resolved from their assignment in the same file, so polarity is
* read rather than guessed from the name.
*
* Why the trailing lookahead: `const d = platform === 'win32' ? describe : …`
* is a suite alias, not a boolean, and must not be collected as one.
*
* Why the two patterns differ on `&&`: a second conjunct NARROWS a
* truthy-on-Windows flag, which stays Windows-only, but WIDENS a
* falsy-on-Windows one -- `p = platform !== 'win32' && x` used as `skipIf(p)`
* runs on Windows AND on POSIX whenever `x` is false, so it is not a
* Windows-only gate. One lookahead shared across both polarities had that
* backwards, and routing the condition through a named flag flipped the answer
* the literal form got right. `||` is excluded from both.
*/
const FLAG_TRUE_ASSIGNMENT = new RegExp(
String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${WIN32_TRUE_EXPRESSION}(?=\s*(?:&&|;|\r?\n|$))`,
'g'
)
const FLAG_FALSE_ASSIGNMENT = new RegExp(
String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${WIN32_FALSE_EXPRESSION}(?=\s*(?:;|\r?\n|$))`,
'g'
)
function escapeForAlternation(name) {
return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** Never-matching branch, so an empty flag set cannot widen a pattern. */
const MATCHES_NOTHING = String.raw`(?!)`
function alternation(names) {
return names.length === 0 ? MATCHES_NOTHING : names.map(escapeForAlternation).join('|')
}
function buildGates(source) {
const trueOnWindows = [...source.matchAll(FLAG_TRUE_ASSIGNMENT)].map(([, name]) => name)
const falseOnWindows = [...source.matchAll(FLAG_FALSE_ASSIGNMENT)].map(([, name]) => name)
const isTrue = `(?:${WIN32_TRUE_EXPRESSION}|\\b(?:${alternation(trueOnWindows)})\\b)`
const isFalse = `(?:${WIN32_FALSE_EXPRESSION}|!\\s*(?:${alternation(trueOnWindows)})\\b|\\b(?:${alternation(falseOnWindows)})\\b)`
return [
// `\)` or `&&` after the condition: a bare gate, or a compound one whose
// remaining conjuncts only narrow it further. Anchoring on `\)` alone was
// this guard's own bug -- `runIf(win32 && hasAddon)` went undetected.
new RegExp(String.raw`\b${SUITE}\s*\.\s*runIf\s*\(\s*${isTrue}\s*(?:\)|&&)`),
new RegExp(String.raw`\b${SUITE}\s*\.\s*skipIf\s*\(\s*${isFalse}\s*(?:\)|\|\|)`),
// `(?!\s*\.\s*skip)`: `platform === 'win32' ? describe.skip : describe` is
// the POSIX-only gate, the exact opposite of the class, and seven files
// use it.
new RegExp(String.raw`=\s*${isTrue}\s*\?\s*${SUITE}\s*(?!\s*\.\s*skip)`),
new RegExp(String.raw`=\s*${isFalse}\s*\?\s*${SUITE}\s*\.\s*skip`)
]
}
/** Exported shape of the rule, so the fixtures below exercise the real matcher. */
export function isWindows32GatedTestFile(path, source) {
if (/\.win32\.(?:test|spec)\./.test(path)) {
return true
}
// Prose about a gate is not a gate; the shared stripper tracks quote state so
// a slash-star inside a string cannot blank live code.
const code = stripComments(source)
return buildGates(code).some((gate) => gate.test(code))
}
/**
* True when the env read REACHES the gate: the win32 check is compound, and one
* of its other conjuncts either reads `process.env` itself or names a const
* that does.
*
* "Mentions an env var anywhere in the file" is not enough and was the earlier
* bug here. `runIf(platform === 'win32' && hasAddon)` in a file that happens to
* read `process.env.RUNNER_TEMP` for a temp dir is a test CI COULD run -- the
* native-addon-bytes shape, exactly what this effort exists to keep in CI --
* and it would have parked in MANUAL_OPT_IN unnoticed. Only the cap number
* stood in the way, and a number is not an argument.
*
* One hop is enough for every real case: `distro = process.env.ORCA_TEST_WSL_DISTRO`
* then `runIf(platform === 'win32' && Boolean(distro))`. Deeper chains fail
* closed -- the file reads as registrable, which is the safe direction.
*/
const WIN32_CONJUNCT = new RegExp(String.raw`${WIN32_TRUE_EXPRESSION}\s*&&([^\n]*)`, 'g')
const ENV_READ = /process\.env\.[A-Za-z0-9_]+/
const IDENTIFIER = /[A-Za-z_$][\w$]*/g
function isAssignedFromEnv(name, code) {
return new RegExp(
String.raw`(?:const|let|var)\s+${escapeForAlternation(name)}\s*=[^\n]*process\.env\.`
).test(code)
}
export function requiresEnvOptIn(source) {
const code = stripComments(source)
return [...code.matchAll(WIN32_CONJUNCT)].some(([, conjunct]) => {
if (ENV_READ.test(conjunct)) {
return true
}
return [...conjunct.matchAll(IDENTIFIER)].some(([name]) => isAssignedFromEnv(name, code))
})
}
/**
* Any `runs-on` that could put a job on Windows.
*
* Not an equality test against `windows-2022`: `windows-latest` resolves to the
* same image today, a label array or `{ group, labels }` object is valid YAML
* here, and a `${{ matrix.os }}` expression cannot be resolved from the file at
* all. An unresolvable expression counts as "could be Windows" so it fails
* closed -- someone has to look rather than have a second lane appear silently.
*/
export function couldRunOnWindows(runsOn) {
const labels =
typeof runsOn === 'string'
? [runsOn]
: Array.isArray(runsOn)
? runsOn
: [...(runsOn?.labels ?? []), runsOn?.group ?? ''].flat()
return labels.some((label) => /windows/i.test(String(label)) || String(label).includes('${{'))
}
/** The vitest argv of the one Windows job's one curated-file step. */
function readWindowsWorkflow() {
const workflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const jobs = Object.entries(workflow.jobs ?? {})
const windowsJobs = jobs.filter(([, job]) => couldRunOnWindows(job?.['runs-on']))
const steps = workflow.jobs?.[WINDOWS_LANE_JOB]?.steps ?? []
const step = steps.find((candidate) => candidate?.name === WINDOWS_LANE_STEP)
if (!step) {
throw new Error(
`No "${WINDOWS_LANE_STEP}" step in the ${WINDOWS_LANE_JOB} job of .github/workflows/pr.yml. ` +
'If it was renamed, update WINDOWS_LANE_STEP here -- do not delete this guard.'
)
}
const run = String(step.run ?? '')
if (!run.includes('vitest run')) {
throw new Error(
`The "${WINDOWS_LANE_STEP}" step no longer invokes vitest; this guard is stale.`
)
}
return {
windowsJobNames: windowsJobs.map(([name]) => name),
laneFiles: run.split(/\s+/).filter((token) => TEST_FILE_PATTERN.test(token))
}
}
const { windowsJobNames, laneFiles } = readWindowsWorkflow()
const scannedTestFiles = scanSourceTree(projectDir, {
includeTests: true,
extensions: TEST_FILE_PATTERN
}).filter(({ relativePath }) => !relativePath.startsWith(UNREACHABLE_BY_THE_WINDOWS_LANE))
const gatedFiles = scannedTestFiles
.filter(({ relativePath }) => !isScannerSelfPath(relativePath))
.filter(({ relativePath, source }) => isWindows32GatedTestFile(relativePath, source))
.map(({ relativePath }) => relativePath)
/**
* Why the classifier and not the literal list: `WINDOWS_PACKAGE_TESTS` is not
* exported, and the classifier is what CI actually consults. It inherits
* `classifyPrJobs`'s force-all, so a path under GLOBAL_FORCE_PREFIXES would
* read as registered without being listed -- no test file is one today.
*/
function isInClassifier(path) {
return classifyPrJobs([path])[WINDOWS_LANE_JOB] === true
}
function registrationFailure(path) {
const missing = []
if (!laneFiles.includes(path)) {
missing.push(
`add "${path}" to the "${WINDOWS_LANE_STEP}" vitest argv in .github/workflows/pr.yml ` +
`(job ${WINDOWS_LANE_JOB})`
)
}
if (!isInClassifier(path)) {
missing.push(
`add '${path}' to WINDOWS_PACKAGE_TESTS in config/scripts/pr-code-change-scope.mjs`
)
}
return missing.length === 0 ? null : `${path}: ${missing.join('; and ')}`
}
function sourceOf(path) {
return readFileSync(join(projectDir, path), 'utf8')
}
describe('Windows-gated test files are registered in the Windows CI lane', () => {
it('scans a plausible number of test files', () => {
// A broken root or extension filter would make every assertion below vacuous.
expect(scannedTestFiles.length).toBeGreaterThan(5000)
})
it('has exactly one windows-2022 job to register into', () => {
// The whole premise: one Windows lane, one curated list. A second lane would
// mean a file could be registered in the wrong one and still run nowhere.
expect(
windowsJobNames,
`Expected only ${WINDOWS_LANE_JOB} to run on ${WINDOWS_LANE_RUNNER}.`
).toEqual([WINDOWS_LANE_JOB])
})
it('parses a plausible Windows lane invocation', () => {
expect(laneFiles.length).toBeGreaterThan(15)
const missingFromDisk = laneFiles.filter((path) => {
try {
return !statSync(join(projectDir, path)).isFile()
} catch {
return true
}
})
expect(
missingFromDisk,
'The Windows lane invokes vitest on paths that do not exist -- vitest will run nothing for them.'
).toEqual([])
})
it('rediscovers Windows-gated files that are already registered', () => {
// Both discovery paths, proven against real files rather than fixtures: one
// found by filename plus ternary alias, one found only by its gate
// expression because its name says nothing about Windows gating.
expect(gatedFiles).toContain('src/shared/child-process/windows-command-line.win32.test.ts')
expect(gatedFiles).toContain('src/main/agent-hooks/windows-hook-payload-delivery.test.ts')
// And a compound gate, the case this guard was blind to at first.
expect(gatedFiles).toContain('src/main/git/runner-wsl-linked-gitdir-windows.test.ts')
})
it('exempts itself, and nothing else, from the scan', () => {
expect(scannedTestFiles.map(({ relativePath }) => relativePath)).toContain(SCANNER_SELF_PATH)
// The exemption is load-bearing only while the fixtures below still match.
expect(isWindows32GatedTestFile(SCANNER_SELF_PATH, sourceOf(SCANNER_SELF_PATH))).toBe(true)
expect(gatedFiles).not.toContain(SCANNER_SELF_PATH)
// The other half of the claim: no sibling rides the exemption.
expect(isScannerSelfPath('config/scripts/pr-code-change-scope.test.mjs')).toBe(false)
})
it('holds the Windows-gated population at or above the floor', () => {
// Bounding by the grandfathered lists' lengths would be trivially true --
// they move together. The floor is a literal for that reason.
expect(
gatedFiles.length,
`Found ${gatedFiles.length} Windows-gated test files; the floor is ${GATED_FILE_FLOOR}. ` +
'A drop means the scan stopped matching, not that the files went away. Lower the floor ' +
'only for a genuine deletion.'
).toBeGreaterThanOrEqual(GATED_FILE_FLOOR)
})
it('confirms the classifier distinguishes registered from unregistered paths', () => {
// Without this, a classifier that answered true for everything would make
// the registration assertion below pass for free.
expect(isInClassifier('src/main/windows/windows-pty-job.win32.test.ts')).toBe(true)
expect(isInClassifier('src/main/windows/not-a-real-file.win32.test.ts')).toBe(false)
})
it('has every Windows-gated test file in both registration lists', () => {
const grandfathered = new Set([...UNREGISTERED_ON_MAIN, ...MANUAL_OPT_IN])
const failures = gatedFiles
.filter((path) => !grandfathered.has(path))
.map(registrationFailure)
.filter((failure) => failure !== null)
expect(
failures,
'A Windows-gated test file is missing from a Windows CI registration list. It self-skips on ' +
'ubuntu and reports success, so it runs on no machine. Both lists are required: ' +
'WINDOWS_PACKAGE_TESTS decides whether the package_windows job runs for a diff, the ' +
'workflow argv decides whether the file runs once it started. Fix each line below.'
).toEqual([])
})
it('has no stale entry in either grandfathered list', () => {
const stale = [...UNREGISTERED_ON_MAIN, ...MANUAL_OPT_IN].filter(
(path) => !gatedFiles.includes(path) || registrationFailure(path) === null
)
expect(
stale,
'These files are no longer unregistered Windows-gated debt -- they were registered, ' +
'renamed, un-gated, or deleted. Delete each line from UNREGISTERED_ON_MAIN or ' +
'MANUAL_OPT_IN; the lists only ever shrink.'
).toEqual([])
})
it('caps growth of both grandfathered lists', () => {
expect(
UNREGISTERED_ON_MAIN.length,
'Never raise UNREGISTERED_MAX. Register the file instead.'
).toBeLessThanOrEqual(UNREGISTERED_MAX)
expect(
MANUAL_OPT_IN.length,
'Never raise MANUAL_OPT_IN_MAX to avoid registering a file that CI could actually run.'
).toBeLessThanOrEqual(MANUAL_OPT_IN_MAX)
})
it('keeps MANUAL_OPT_IN to suites CI genuinely cannot run', () => {
// Otherwise this list is just a quieter way to skip registration.
const notActuallyOptIn = MANUAL_OPT_IN.filter((path) => !requiresEnvOptIn(sourceOf(path)))
expect(
notActuallyOptIn,
'A MANUAL_OPT_IN entry has no env-var opt-in, so registering it WOULD make it run. ' +
'Register it in both lists and delete the line.'
).toEqual([])
})
it('keeps every UNREGISTERED_ON_MAIN file ineligible for MANUAL_OPT_IN', () => {
// The two lists must not be interchangeable: debt that CI could run must
// not be re-labelled as manual to make the debt cap look better.
const movable = UNREGISTERED_ON_MAIN.filter((path) => requiresEnvOptIn(sourceOf(path)))
expect(
movable,
'This file is registrable; it cannot be reclassified as MANUAL_OPT_IN.'
).toEqual([])
})
})
describe('manual opt-in classification', () => {
it('requires the env read to reach the gate', () => {
// The parking attack: a compound gate CI could satisfy, in a file that
// happens to read an unrelated env var. This is the native-addon-bytes
// shape, and it must read as registrable.
expect(
requiresEnvOptIn(
"const tmp = process.env.RUNNER_TEMP\ndescribe.runIf(process.platform === 'win32' && hasAddon)('x', () => {})"
)
).toBe(false)
// One hop through a const: the real shape of the ten listed suites.
expect(
requiresEnvOptIn(
"const distro = process.env.ORCA_TEST_WSL_DISTRO\ndescribe.runIf(process.platform === 'win32' && Boolean(distro))('x', () => {})"
)
).toBe(true)
// Read inline in the conjunct: the other real shape.
expect(
requiresEnvOptIn(
"const RUN = process.platform === 'win32' && process.env.ORCA_REAL_X === '1'"
)
).toBe(true)
})
it('requires the gate to be compound at all', () => {
// A bare `runIf(win32)` file -- which CI can run -- must never park as
// manual, however much `process.env` the file reads elsewhere.
expect(
requiresEnvOptIn(
"const t = process.env.CI\ndescribe.runIf(process.platform === 'win32')('x', () => {})"
)
).toBe(false)
// The case that makes the `&&` in WIN32_CONJUNCT load-bearing rather than
// decorative: an env read on the SAME line as a bare gate. Drop the `&&`
// and this reads as manual, which is the parking hole reopened.
expect(
requiresEnvOptIn(
"describe.runIf(process.platform === 'win32')(`x ${process.env.ORCA_TAG}`, () => {})"
)
).toBe(false)
})
})
describe('Windows runner detection', () => {
it('reads every runs-on spelling that could land on Windows', () => {
expect(couldRunOnWindows('windows-2022')).toBe(true)
// The spelling that would have slipped past an equality test.
expect(couldRunOnWindows('windows-latest')).toBe(true)
expect(couldRunOnWindows(['self-hosted', 'Windows', 'X64'])).toBe(true)
expect(couldRunOnWindows({ group: 'windows-runners', labels: ['x64'] })).toBe(true)
// Unresolvable from the file, so it fails closed rather than reading as safe.
expect(couldRunOnWindows('${{ matrix.os }}')).toBe(true)
expect(couldRunOnWindows('ubuntu-latest')).toBe(false)
expect(couldRunOnWindows(['self-hosted', 'linux'])).toBe(false)
expect(couldRunOnWindows(undefined)).toBe(false)
})
})
describe('Windows-gate detection', () => {
// Each positive is paired with the near-miss it must reject. The pairs are
// written from the shapes that exist in the repo, not from the regexes above.
const cases = [
[
'describe.runIf equality',
"describe.runIf(process.platform === 'win32')('x', () => {})",
"describe.runIf(process.platform !== 'win32')('x', () => {})"
],
[
'describe.skipIf inequality',
"describe.skipIf(process.platform !== 'win32')('x', () => {})",
"describe.skipIf(process.platform === 'win32')('x', () => {})"
],
[
'ternary describe alias',
"const d = process.platform === 'win32' ? describe : describe.skip",
"const d = process.platform === 'win32' ? describe.skip : describe"
],
[
'inverted ternary describe alias',
"const d = process.platform !== 'win32' ? describe.skip : describe",
"const d = process.platform !== 'win32' ? describe : describe.skip"
],
[
'local isWindows flag',
"const isWindows = process.platform === 'win32'\ndescribe.skipIf(!isWindows)('x', () => {})",
"const isWindows = process.platform === 'win32'\ndescribe.skipIf(isWindows)('x', () => {})"
],
[
'local isWindows flag, runIf',
"const isWindows = process.platform === 'win32'\ndescribe.runIf(isWindows)('x', () => {})",
"const isWindows = process.platform === 'win32'\ndescribe.runIf(!isWindows)('x', () => {})"
],
[
// The blocking miss: a second conjunct made the gate invisible.
'compound gate with a second conjunct',
"describe.runIf(process.platform === 'win32' && Boolean(distro))('x', () => {})",
"describe.runIf(process.platform === 'win32' || Boolean(distro))('x', () => {})"
],
[
'compound gate behind a named flag assigned on the next line',
"const RUN_REAL =\n process.platform === 'win32' && process.env.X === '1'\ndescribe.runIf(RUN_REAL)('x', () => {})",
"const RUN_REAL =\n process.platform !== 'win32' && process.env.X === '1'\ndescribe.runIf(RUN_REAL)('x', () => {})"
],
[
'named flag driving a ternary suite alias',
"const enabled = process.platform === 'win32' && process.env.X === '1'\nconst d = enabled ? describe : describe.skip",
"const enabled = process.platform === 'win32' && process.env.X === '1'\nconst d = enabled ? describe.skip : describe"
],
[
'compound skipIf widened with ||',
"describe.skipIf(process.platform !== 'win32' || !hasAddon)('x', () => {})",
"describe.skipIf(process.platform !== 'win32' && !hasAddon)('x', () => {})"
],
[
'double-quoted and loosely spaced',
'describe . runIf ( process.platform === "win32" )("x", () => {})',
'describe . runIf ( process.platform === "darwin" )("x", () => {})'
]
]
for (const [label, gated, nearMiss] of cases) {
it(`detects ${label} and rejects its near miss`, () => {
expect(isWindows32GatedTestFile('src/x/sample.test.ts', gated)).toBe(true)
expect(isWindows32GatedTestFile('src/x/sample.test.ts', nearMiss)).toBe(false)
})
}
it('detects the .win32 filename with no gate expression at all', () => {
expect(isWindows32GatedTestFile('src/x/sample.win32.test.ts', 'describe("x", () => {})')).toBe(
true
)
// Near miss: `.win32.ts` is production source, not a test the lane can run.
expect(isWindows32GatedTestFile('src/x/sample.win32.ts', 'export const x = 1')).toBe(false)
})
it('does not read a flag whose name merely starts the same', () => {
// Without word boundaries `isWindows` would swallow `isWindowsHost`.
expect(
isWindows32GatedTestFile(
'src/x/sample.test.ts',
"const isWindows = process.platform === 'win32'\ndescribe.runIf(isWindowsHost)('x', () => {})"
)
).toBe(false)
})
it('rejects the documented blind spots rather than half-detecting them', () => {
// it-level gate inside a cross-platform suite: out of scope by design.
expect(
isWindows32GatedTestFile(
'src/x/sample.test.ts',
"describe('x', () => { it.skipIf(process.platform !== 'win32')('y', () => {}) })"
)
).toBe(false)
// A platform branch inside a test body is not a gate.
expect(
isWindows32GatedTestFile(
'src/x/sample.test.ts',
"it('x', () => { if (process.platform === 'win32') { return } })"
)
).toBe(false)
// An imported flag: the assignment is not in this file, so polarity is unknowable.
expect(
isWindows32GatedTestFile(
'src/x/sample.test.ts',
"import { isWindows } from './f'\ndescribe.runIf(isWindows)('x', () => {})"
)
).toBe(false)
})
it('does not treat a widening conjunct behind a named flag as Windows-only', () => {
// `!== 'win32' && x` skips only when BOTH hold, so the suite runs on
// Windows and on POSIX when `x` is false. The literal form is rejected by
// the `||` pair above; this is the same condition routed through a flag,
// which is where the shared lookahead used to flip the answer.
expect(
isWindows32GatedTestFile(
'src/x/sample.test.ts',
"const p = process.platform !== 'win32' && Boolean(x)\ndescribe.skipIf(p)('x', () => {})"
)
).toBe(false)
// The narrowing direction still counts: `=== 'win32' && x` is Windows-only.
expect(
isWindows32GatedTestFile(
'src/x/sample.test.ts',
"const p = process.platform === 'win32' && Boolean(x)\ndescribe.runIf(p)('x', () => {})"
)
).toBe(true)
})
it('ignores a gate that only appears in prose', () => {
expect(
isWindows32GatedTestFile(
'src/x/sample.test.ts',
"// describe.runIf(process.platform === 'win32')\ndescribe('x', () => {})"
)
).toBe(false)
})
})
@@ -0,0 +1,129 @@
import { readdirSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
/**
* Guard the one idiom that keeps re-killing Windows tooling.
*
* Node >= 20 refuses to spawn a Windows batch shim without `shell: true` (the
* CVE-2024-27980 mitigation), so `spawnSync('pnpm.cmd', …)` throws EINVAL
* before the command runs at all. On Windows that reads as a broken toolchain
* rather than a failing check, so the failure gets shrugged off — which is
* exactly how `check:code-quality:changed` ran dead for months.
*
* `src/` has its own chokepoint (runProcess) and its own ratchet. These trees
* are plain `.mjs` run by bare `node`, outside that module boundary, so they
* need this narrower one: a batch-shim command literal may not appear in a new
* script. The list only shrinks. Resolve the real executable instead —
* `oxlint-cli-invocation.mjs` and `windows-process-tree-gyp-rebuild.mjs` show
* the shape.
*
* Deliberately a text match on any `.cmd`/`.bat` literal, not on a list of
* runner names: these trees already spawn vitest, playwright, electron-builder
* and tsc, and the next offender is as likely to be one of those as it is to be
* pnpm. A literal is all a copy-paste carries.
*
* Two shapes this does not catch, both accepted. A shim assembled in a template
* literal, and a drive-lettered path — 'C:\tools\pnpm.cmd' — since a colon is
* not in the class. Real code builds those with path.join, whose 'pnpm.cmd'
* argument is caught. Also note codeText only drops lines that BEGIN with a
* comment marker, so a trailing `// 'pnpm.cmd'` false-positives; that fails
* closed. All of which is the ceiling of a text ratchet, and the reason `src/`
* gets a real chokepoint instead.
*/
const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/i
const SCANNED_ROOTS = ['config/scripts', 'tests/tools']
/** Scripts that still name a batch shim, held as data so it reads as the list it is. */
const WINDOWS_SHIM_SPAWN_ALLOWLIST = [
// Owns the pnpm invocation decision for every other script.
'config/scripts/pnpm-cli-invocation.mjs',
'config/scripts/pnpm-cli-invocation.test.mjs',
// Write or assert on shim files rather than spawning one.
'config/scripts/dev-cli-terminal-wrapper.mjs',
'config/scripts/dev-cli-terminal-wrapper.test.mjs',
'config/scripts/electron-builder-config.test.mjs',
'config/scripts/ensure-native-runtime.test.mjs',
'config/scripts/live-remote-freeze-rpc.mjs',
'config/scripts/remote-agent-session-authority-repro.mjs',
// Platform-local build paths; the win32 branch is dead code on both.
'config/scripts/build-mac-local.mjs',
'config/scripts/build-linux-local.mjs',
'config/scripts/build-linux-local.test.mjs',
// Benchmarks, repros and e2e drivers — developer-invoked or Linux-only in CI.
'config/scripts/build-orcad-prebuilds.mjs',
'config/scripts/run-ai-vault-typing-bench.mjs',
'config/scripts/run-ephemeral-vm-runtime-store-rollback-repro.mjs',
'config/scripts/run-local-ssh-browser-routing-e2e.mjs',
'config/scripts/run-multi-client-navigation-e2e.mjs',
'config/scripts/run-multi-workspace-typing-bench.mjs',
'config/scripts/run-nested-runtime-ssh-e2e.mjs',
'config/scripts/run-ssh-client-hosted-browser-drop-reconnect-e2e.mjs',
'config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs',
'config/scripts/run-ssh-docker-e2e.mjs',
'config/scripts/run-ssh-docker-perf-e2e.mjs',
'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs',
'config/scripts/run-ssh-docker-watcher-isolation-e2e.mjs',
'config/scripts/run-ssh-staged-upload-reliability.mjs',
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
'config/scripts/run-terminal-scale-perf-e2e.mjs',
// Routes its shim through an explicit `cmd.exe /d /s /c`, which is the correct form.
'config/scripts/verify-skill-update-roundtrip.mjs',
'tests/tools/benchmarks/startup-time-bench.mjs',
'tests/tools/benchmarks/worktree-deletion-dev-bench.mjs',
'tests/tools/repro-terminal-send-submit.mjs'
]
/** Drop comment-only lines so prose about the old idiom is not an offender. */
function codeText(contents) {
return contents
.split('\n')
.filter((line) => !/^\s*(?:\/\/|\/\*|\*)/.test(line))
.join('\n')
}
// Why recursive: a future config/scripts/<subdir>/ would otherwise escape silently.
function collectScripts(directory, repoRoot, found = []) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const full = path.join(directory, entry.name)
if (entry.isDirectory()) {
if (entry.name !== 'node_modules') {
collectScripts(full, repoRoot, found)
}
continue
}
if (/\.[cm]?js$/.test(entry.name)) {
found.push(path.relative(repoRoot, full).split(path.sep).join('/'))
}
}
return found
}
describe('windows batch shim spawn boundary', () => {
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const scripts = SCANNED_ROOTS.flatMap((root) =>
collectScripts(path.join(repoRoot, root), repoRoot)
)
const offenders = scripts.filter((relativePath) =>
WINDOWS_SHIM_LITERAL.test(codeText(readFileSync(path.join(repoRoot, relativePath), 'utf8')))
)
it('scans a plausible number of scripts', () => {
// A broken root or extension filter would make the guard silently vacuous.
expect(scripts.length).toBeGreaterThan(100)
})
it('has no unlisted script naming a Windows batch shim', () => {
const unlisted = offenders.filter((name) => !WINDOWS_SHIM_SPAWN_ALLOWLIST.includes(name))
expect(
unlisted,
'Node cannot spawn a Windows batch shim without a shell. Resolve the real executable — see oxlint-cli-invocation.mjs.'
).toEqual([])
})
it('has no stale allowlist entry', () => {
const stale = WINDOWS_SHIM_SPAWN_ALLOWLIST.filter((name) => !offenders.includes(name))
expect(stale, 'Script no longer names a batch shim — delete the line.').toEqual([])
})
})
@@ -38,7 +38,7 @@ describe('Windows signing workflow contract', () => {
const installStep = steps[installStepIndexes[0]]
expect(installStep.if).toBe("matrix.platform == 'win'")
expect(installStep.if).toBe("matrix.platform == 'win' && github.run_attempt == 1")
expect(installStep.uses).toBe('./.github/actions/install-signpath-module')
expect(installStep.run).toBeUndefined()
@@ -139,6 +139,34 @@ describe('Windows signing workflow contract', () => {
expect(installRun).toContain('throw "SHA-256 mismatch for $source')
})
it('never recreates Windows signing requests on a workflow rerun', () => {
const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml')
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const skipStep = steps.find((step) => step.name === 'Skip Windows artifact rebuild on rerun')
expect(skipStep?.if).toBe("matrix.platform == 'win' && github.run_attempt != 1")
expect(skipStep?.run).toContain('Existing signed release assets must be reused')
const signingStepNames = [
'Build Windows release artifacts',
'Stage unsigned inner PE files for signing',
'Upload unsigned inner binaries for SignPath',
'Submit inner binaries signing request',
'Download signed inner binaries from SignPath',
'Upload unsigned Windows installer for SignPath',
'Submit Windows installer signing request',
'Download signed Windows installer from SignPath',
'Stage signed Windows release assets',
'Publish signed Windows release artifacts'
]
for (const stepName of signingStepNames) {
const step = steps[stepNames.indexOf(stepName)]
expect(step?.if, stepName).toContain('github.run_attempt == 1')
}
})
it('shares one SignPath module install path between release and rehearsal', () => {
const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml')
const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name)
+176
View File
@@ -0,0 +1,176 @@
# Files currently allowed to carry a `@ts-nocheck` header.
# This is a RATCHET: the list may only SHRINK. These exist only because the split
# runtime mixin chain cannot express forward references yet — do NOT add entries to
# get CI green; fix the types instead.
# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)
src/main/runtime/orca-runtime-activate-managed-worktree.ts
src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts
src/main/runtime/orca-runtime-agent-teams-launch-plan.ts
src/main/runtime/orca-runtime-apply-layout.ts
src/main/runtime/orca-runtime-apply-mobile-display-mode.ts
src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts
src/main/runtime/orca-runtime-apply-tracked-pty-title.ts
src/main/runtime/orca-runtime-attach-remote-terminal-source-range-consumer.ts
src/main/runtime/orca-runtime-attach-window.ts
src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts
src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
src/main/runtime/orca-runtime-build-pty-terminal-summary.ts
src/main/runtime/orca-runtime-capture-provider-terminal-buffer.ts
src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts
src/main/runtime/orca-runtime-close-mobile-session-tab.ts
src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts
src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts
src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts
src/main/runtime/orca-runtime-core.ts
src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts
src/main/runtime/orca-runtime-create-agent-session.ts
src/main/runtime/orca-runtime-create-managed-remote-worktree.ts
src/main/runtime/orca-runtime-create-managed-worktree.ts
src/main/runtime/orca-runtime-create-mobile-session-terminal.ts
src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts
src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts
src/main/runtime/orca-runtime-create-terminal-desktop.ts
src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.ts
src/main/runtime/orca-runtime-create-terminal.ts
src/main/runtime/orca-runtime-deliver-pending-messages.ts
src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts
src/main/runtime/orca-runtime-fence-automation-owner.ts
src/main/runtime/orca-runtime-file-commands.ts
src/main/runtime/orca-runtime-fit-override-listeners.ts
src/main/runtime/orca-runtime-focus-terminal.ts
src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts
src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts
src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts
src/main/runtime/orca-runtime-get-runtime-id.ts
src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts
src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts
src/main/runtime/orca-runtime-get-worktree-ps.ts
src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts
src/main/runtime/orca-runtime-handle-mobile-subscribe-internal.ts
src/main/runtime/orca-runtime-handle-mobile-subscribe.ts
src/main/runtime/orca-runtime-handle-mobile-unsubscribe.ts
src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts
src/main/runtime/orca-runtime-has-live-or-persisted-serve-or-ssh-owned-pty-binding.ts
src/main/runtime/orca-runtime-has-recent-terminal-output-path.ts
src/main/runtime/orca-runtime-has-terminals-for-worktree.ts
src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts
src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts
src/main/runtime/orca-runtime-linear-commands.ts
src/main/runtime/orca-runtime-list-known-resolved-worktrees-for-explicit-target.ts
src/main/runtime/orca-runtime-list-managed-worktrees.ts
src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts
src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts
src/main/runtime/orca-runtime-merge-preserved-headless-mobile-session-tabs.ts
src/main/runtime/orca-runtime-mobile-took-floor.ts
src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts
src/main/runtime/orca-runtime-notify-ssh-state-changed.ts
src/main/runtime/orca-runtime-on-client-disconnected.ts
src/main/runtime/orca-runtime-on-pty-data.ts
src/main/runtime/orca-runtime-on-pty-exit.ts
src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts
src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts
src/main/runtime/orca-runtime-persist-headless-terminal-title.ts
src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts
src/main/runtime/orca-runtime-pick-most-recent-actor.ts
src/main/runtime/orca-runtime-postlude.ts
src/main/runtime/orca-runtime-prepare-pty-execution-context.ts
src/main/runtime/orca-runtime-preserved-branch-cleanup.ts
src/main/runtime/orca-runtime-prove-recovered-structured-tui-pty-process.ts
src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts
src/main/runtime/orca-runtime-pty-foreground-process-reads.ts
src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts
src/main/runtime/orca-runtime-reclaim-terminal-for-desktop.ts
src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts
src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts
src/main/runtime/orca-runtime-record-pty-worktree.ts
src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts
src/main/runtime/orca-runtime-refresh-pty-worktree-records-from-controller.ts
src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts
src/main/runtime/orca-runtime-refresh-repo-worktree-scan.ts
src/main/runtime/orca-runtime-refuse-unattributed-mobile-session-tab-close.ts
src/main/runtime/orca-runtime-register-pty.ts
src/main/runtime/orca-runtime-remove-managed-worktree.ts
src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts
src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts
src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts
src/main/runtime/orca-runtime-resolve-exit-waiters.ts
src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts
src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts
src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts
src/main/runtime/orca-runtime-resolve-terminal-pane.ts
src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts
src/main/runtime/orca-runtime-resolve-waiter.ts
src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts
src/main/runtime/orca-runtime-resolve-worktree-selector.ts
src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts
src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts
src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts
src/main/runtime/orca-runtime-runtime-id.ts
src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts
src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts
src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts
src/main/runtime/orca-runtime-serialize-headless-terminal-buffer.ts
src/main/runtime/orca-runtime-serialize-main-terminal-buffer.ts
src/main/runtime/orca-runtime-serialize-terminal-buffer-from-available-state.ts
src/main/runtime/orca-runtime-sleep-resolved-worktree-terminals.ts
src/main/runtime/orca-runtime-split-pty-backed-terminal.ts
src/main/runtime/orca-runtime-split-terminal.ts
src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts
src/main/runtime/orca-runtime-state-fields.ts
src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts
src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts
src/main/runtime/orca-runtime-stop-requested-pty-ids.ts
src/main/runtime/orca-runtime-stop-structured-session-process.ts
src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts
src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts
src/main/runtime/orca-runtime-structured-agent-session-launch-tui.ts
src/main/runtime/orca-runtime-structured-agent-session-recover-tui-owner.ts
src/main/runtime/orca-runtime-structured-agent-session-reprove-tui-owner.ts
src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts
src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts
src/main/runtime/orca-runtime-sync-window-graph.ts
src/main/runtime/orca-runtime-terminal-create-deduplication.ts
src/main/runtime/orca-runtime-terminal-drivers.ts
src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts
src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts
src/main/runtime/orca-runtime-verify-orchestration-compatibility-caller.ts
src/main/runtime/orca-runtime-visible-snapshot-preview.ts
src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts
src/main/runtime/orca-runtime-wait-for-mobile-terminal-surface.ts
src/main/runtime/orca-runtime-wait-for-session-tabs-inventory-publication.ts
src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts
src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts
src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts
src/main/runtime/runtime-browser-commands-browser-clear.ts
src/main/runtime/runtime-browser-commands-browser-click.ts
src/main/runtime/runtime-browser-commands-browser-command-target-params.ts
src/main/runtime/runtime-browser-commands-browser-network-log.ts
src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts
src/main/runtime/runtime-browser-commands-browser-screencast.ts
src/main/runtime/runtime-browser-commands-browser-set-headers.ts
src/main/runtime/runtime-browser-commands-browser-tab-close.ts
src/main/runtime/runtime-browser-commands-browser-tab-create.ts
src/main/runtime/runtime-browser-commands-browser-tab-list.ts
src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts
src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts
src/main/runtime/runtime-browser-commands-state.ts
src/main/runtime/runtime-file-command-host.ts
src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts
src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts
src/main/runtime/runtime-file-commands-constructor.ts
src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts
src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts
src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts
src/main/runtime/runtime-file-commands-read-mobile-file.ts
src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts
src/main/runtime/runtime-file-commands-resolve-terminal-path.ts
src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts
src/main/runtime/runtime-file-commands-search-local-runtime-files.ts
src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts
src/main/runtime/runtime-file-commands-search-runtime-files.ts
src/main/runtime/runtime-file-commands-ssh-file-watcher-rearm.ts
src/main/runtime/runtime-file-commands-terminal-artifact-access.ts
src/main/runtime/runtime-file-commands-terminal-file-paths.ts
src/main/runtime/runtime-file-commands-write-file-explorer-file.ts
src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts
src/main/runtime/runtime-file-watcher-leases.ts
+3
View File
@@ -117,6 +117,7 @@
"../src/main/hermes/hermes-home-filesystem.ts",
"../src/main/hermes/hermes-managed-plugin-source.ts",
"../src/main/hermes/hook-service.ts",
"../src/main/in-flight-run-dedupe.ts",
"../src/main/kimi/hook-service.ts",
"../src/main/kimi/kimi-hook-config-toml.ts",
"../src/main/openclaude/hook-service.ts",
@@ -126,6 +127,8 @@
// Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this
// project's serve spec; the module has no imports, so listing it pulls in nothing else.
"../src/main/startup/serve-mode-argv.ts",
// The parity test keeps this import-free list aligned with COMMAND_SPECS.
"../src/main/startup/cli-command-names.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/sqlite/sync-database.ts",
"../src/main/win32-utils.ts"
+14
View File
@@ -6,18 +6,32 @@
"../src/renderer/src/**/*.tsx",
"../src/preload/api-types.ts",
"../src/preload/api/**/*",
"../src/preload/browser-client-page-renderer-requests.ts",
"../src/preload/browser-find-subscriptions.ts",
"../src/preload/close-active-tab-payload-admission.ts",
"../src/preload/e2e-config.ts",
"../src/preload/gitlab.ts",
"../src/preload/preload-runtime-support.ts",
"../src/preload/renderer-heap-statistics-reader.ts",
"../src/preload/renderer-process-memory-reader.ts",
"../src/preload/renderer-restart-wiring.ts",
"../src/preload/runtime-environment-subscriptions.ts",
"../src/preload/usage-provider-api.ts",
"../src/shared/**/*",
"../src/main/gitlab/mappers.ts",
"../src/main/ipc/worktree-branch-name.ts",
"../src/main/ipc/worktree-logic.ts",
"../src/main/ipc/worktree-display-name.ts",
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
"../src/main/ipc/worktree-metadata-merge.ts",
"../src/main/ipc/worktree-path-comparison.ts",
"../src/main/wsl-availability.ts",
"../src/main/wsl-directory-probe-command.ts",
"../src/main/wsl-distro-list-output.ts",
"../src/main/wsl-distro-retry.ts",
"../src/main/wsl-running-distro-cache.ts",
"../src/main/wsl.ts",
"../src/main/wsl-interop-spawn-directory.ts",
"../src/main/persistence/applying-settings/ui-state-read.ts",
"../src/main/persistence/applying-settings/ui-state-update.ts",
"../src/main/persistence/applying-settings/ui-selection-normalization.ts",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 34m">
<title>downloads: 34m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 36m">
<title>downloads: 36m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">34m</text>
<text x="90" y="14">34m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">36m</text>
<text x="90" y="14">36m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

After

Width:  |  Height:  |  Size: 394 KiB

+2 -2
View File
@@ -243,9 +243,9 @@ Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votr
- **Discord :** Rejoignez la communauté sur **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X :** Suivez **[@orca_build](https://x.com/orca_build)** pour les news et annonces.
- **WeChat :** Scannez pour rejoindre le groupe WeChat 7 de la communauté Orca.
- **WeChat :** Scannez pour rejoindre le groupe WeChat 8 de la communauté Orca.
<img src="../assets/wechat-qr-group7.jpg" alt="QR code WeChat groupe 7 de la communauté Orca" width="160" />
<img src="../assets/wechat-qr-group8.jpg" alt="QR code WeChat groupe 8 de la communauté Orca" width="160" />
- **Feedback &amp; idées :** On ship vite. Il manque quelque chose ? [Demandez une feature](https://github.com/stablyai/orca/issues).
- **Confidentialité :** Voir la [doc confidentialité &amp; télémétrie](https://www.onorca.dev/docs/telemetry) pour ce qu'Orca collecte en anonyme et comment désactiver la télémétrie.
+2 -2
View File
@@ -238,9 +238,9 @@ yay -S stably-orca-bin
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 7에 참여하세요.
- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 8에 참여하세요.
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 커뮤니티 WeChat 그룹 7 QR 코드" width="160" />
<img src="../assets/wechat-qr-group8.jpg" alt="Orca 커뮤니티 WeChat 그룹 8 QR 코드" width="160" />
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
+1 -2
View File
@@ -235,9 +235,8 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **微信:** 扫码加入 Orca 社区微信第 7 群。如果第 7 群已满,请使用第 8 群。
- **微信:** 扫码加入 Orca 社区微信第 8 群。
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 社区微信第 7 群二维码" width="160" />&nbsp;&nbsp;
<img src="../assets/wechat-qr-group8.jpg" alt="Orca 社区微信第 8 群二维码" width="160" />
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
+11
View File
@@ -42,6 +42,17 @@ authority.
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
### Placeholders That Fail Open
`GitCapabilityCache` records commands Git _rejects_. A `git log --format`
placeholder Git does not know is not rejected: Git echoes it verbatim and exits
zero, so there is no error to remember and no probe to cache. Ask for both forms
in one record and pick at parse time.
| Placeholder | Preferred behavior | Compatibility behavior |
| --------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `%(decorate:…)` | Git 2.43 separates commit decorations with `\x1f`, so ref names containing commas survive | The same record also carries `%D` (Git 2.10); an unexpanded `%(decorate` placeholder selects it, at the cost of comma-splitting |
## Why Not `simple-git`
`simple-git` is a process wrapper around the installed Git binary. Its custom

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