Merge remote-tracking branch 'origin/main' into nwparker/sta-4319-ssh-host-key-verification

This commit is contained in:
Neil
2026-08-16 15:39:23 -07:00
704 changed files with 71533 additions and 15246 deletions
+8 -1
View File
@@ -17,6 +17,7 @@
Fixes #
## Visual Proof
<!-- REQUIRED for UI / behavior changes. Please attach a BEFORE and AFTER that can easily tabbed/switched. Use videos for when appropriate over screenshots -->
<!-- If there is truly no visual or interaction change, write exactly: `N/A` and briefly say why. -->
<!-- For attachments NEVER add directly to the PR files (do not commit to files), use `gh image` extension or drag + drop (works for any attachment) -->
@@ -28,13 +29,19 @@ Fixes #
- [ ] I manually tested these changes locally
- [ ] Automated tests added/updated, or explained why not below
## AI Disclosure
<!-- DO NOT FILL IN IF YOU ARE STABLYAI TEAM MEMBER (INTERNAL CONTRIBUTOR), IGNORE SECTION: -->
<!-- Which AI model if anyone was used, please state the details -->
## Review
## Agent skill upstream boundary
- [ ] Not applicable, or this change follows `docs/reference/agent-skill-sharing-upstream-boundary.md` and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.
## Notes
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
## Checklist
+11 -3
View File
@@ -33,15 +33,23 @@ export function sumChangedFiles(files) {
return totals
}
function signed(count) {
const COLOR_ADDED = '#1a7f37'
const COLOR_DELETED = '#cf222e'
function diffCell(count) {
if (count === 0) {
return '0'
}
return count > 0 ? `+${count}` : `−${Math.abs(count)}`
if (count > 0) {
return `$\\color{${COLOR_ADDED}}{\\Huge{\\mathbf{+}}}$\u200b${count}`
}
return `$\\color{${COLOR_DELETED}}{\\Huge{\\mathbf{−}}}$\u200b${Math.abs(count)}`
}
function locTableRow(label, bucket) {
return `| ${label} | ${bucket.files ?? 0} | ${signed(bucket.added)} | ${signed(-(bucket.deleted ?? 0))} | ${signed((bucket.added ?? 0) - (bucket.deleted ?? 0))} |`
const added = bucket.added ?? 0
const deleted = bucket.deleted ?? 0
return `| ${label} | ${bucket.files ?? 0} | ${diffCell(added)} | ${diffCell(-deleted)} | ${diffCell(added - deleted)} |`
}
export function formatLocTable({ test, nonTest }) {
+41 -5
View File
@@ -5,13 +5,15 @@ name: Daily macOS Dev Build
# hourly.
#
# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules).
# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute
# 18:15 UTC is late morning Pacific year-round (10:15am PST / 11:15am PDT). Minute
# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour.
#
# Deliberately narrow scope:
# - macOS only. Other platforms keep using RC/stable.
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
# and release-cut remain the gates that matter.
# - No tests or lint on the build job. After a live publish we fire-and-forget
# the full E2E workflow at the cut SHA (same detached dispatch as
# release-cut). A red suite must not fail or delay the daily.
# - PR CI and release-cut remain the gates that matter.
# - Signed AND notarized, exactly like a release. The notary round trip is the
# one slow step kept: macOS anchors a notarized app's TCC grants on identifier
# + team rather than on its cdhash, so those grants survive an update. Without
@@ -45,8 +47,8 @@ name: Daily macOS Dev Build
on:
schedule:
# Once a day, early morning Pacific. Single cron — no DST twin, no clock gate.
- cron: '15 14 * * *'
# Once a day, late morning Pacific. Single cron — no DST twin, no clock gate.
- cron: '15 18 * * *'
workflow_dispatch:
inputs:
force:
@@ -70,6 +72,9 @@ env:
jobs:
build-daily-mac:
if: github.repository == 'stablyai/orca'
outputs:
head_sha: ${{ steps.freshness.outputs.head_sha }}
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
@@ -469,3 +474,34 @@ jobs:
gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \
echo "::warning::Could not prune $tag"
done <<<"$stale"
# Why detached dispatch, not a reusable workflow in this graph: the full suite
# is currently red on main. Inlining it here would fail the daily after the
# signed build already published. Same pattern as release-cut's post-release-e2e.
#
# Why --ref main plus the SHA input: daily tags live on orca-daily, not this
# repo, so there is no cut tag for `gh workflow run --ref`. The workflow file
# comes from main; checkout uses the exact commit this daily built.
post-daily-e2e:
needs: build-daily-mac
if: ${{ needs.build-daily-mac.outputs.published == 'true' && needs.build-daily-mac.outputs.head_sha != '' }}
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Dispatch cut-scoped E2E
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ needs.build-daily-mac.outputs.head_sha }}
run: |
for attempt in 1 2 3; do
if gh workflow run e2e.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
--raw-field "ref=$SHA"; then
echo "Dispatched post-daily E2E for $SHA."
exit 0
fi
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
done
echo "::warning::Failed to dispatch post-daily E2E for $SHA after 3 attempts."
+6
View File
@@ -270,7 +270,10 @@ jobs:
src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/main/terminal-history-fish-session.node-pty.test.ts \
src/main/zsh-scoped-histfile.live-shell.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/startup-shell-portability.live-shell.test.ts \
src/shared/posix-command-path-lookup.test.ts
test:
@@ -309,7 +312,10 @@ jobs:
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.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/main/terminal-history-fish-session.node-pty.test.ts \
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
+96
View File
@@ -923,6 +923,87 @@ jobs:
retention-days: 7
if-no-files-found: ignore
skill-sharing-release-gate:
needs: cut
if: needs.cut.outputs.should_release == 'true'
name: skill sharing release gate ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- os: macos-15
platform: mac
- os: windows-2022
platform: windows
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Run skill package, transaction, and compatibility suites
env:
ORCA_REAL_PROCESS_SKILL_TEST: '1'
ORCA_REAL_WINDOWS_SKILL_TEST: ${{ runner.os == 'Windows' && '1' || '0' }}
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
- name: Archive bounded skill-sharing results
if: always()
uses: actions/upload-artifact@v7
with:
name: skill-sharing-release-${{ matrix.platform }}
path: skill-sharing-release-results.json
retention-days: 14
if-no-files-found: error
skill-sharing-linux-floor-release-gate:
needs: cut
if: needs.cut.outputs.should_release == 'true'
name: skill sharing release gate linux-glibc-2.31
runs-on: ubuntu-latest
timeout-minutes: 20
container: ubuntu:20.04
steps:
- name: Install Ubuntu 20.04 prerequisites
run: apt-get update && apt-get install -y build-essential ca-certificates git python3
- name: Checkout
uses: actions/checkout@v6
with:
ref: refs/tags/${{ needs.cut.outputs.tag }}
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Run skill package, transaction, and compatibility suites
env:
ORCA_REAL_PROCESS_SKILL_TEST: '1'
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
- name: Archive bounded skill-sharing results
if: always()
uses: actions/upload-artifact@v7
with:
name: skill-sharing-release-linux-glibc-2.31
path: skill-sharing-release-results.json
retention-days: 14
if-no-files-found: error
# Why: these broader terminal rendering repros are useful release evidence,
# but they include heavier app-like flows and must not block publishing.
terminal-rendering-release-evidence:
@@ -1026,12 +1107,14 @@ jobs:
- os: ubuntu-latest
platform: linux-x64
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always
unpacked_dir: dist/linux-unpacked
eb_cache_path: |
~/.cache/electron
~/.cache/electron-builder
- os: ubuntu-24.04-arm
platform: linux-arm64
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always
unpacked_dir: dist/linux-arm64-unpacked
eb_cache_path: |
~/.cache/electron
~/.cache/electron-builder
@@ -1209,6 +1292,17 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Load packaged node-pty on the Linux floor
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: >-
node config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs
--app-dir ${{ matrix.unpacked_dir }}
# Why: SignPath signs GitHub workflow artifacts, so Windows builds must
# upload only after the production-signed installer has been returned.
- name: Build Windows release artifacts
@@ -1896,6 +1990,8 @@ jobs:
- cut
- build
- build-mac
- skill-sharing-linux-floor-release-gate
- skill-sharing-release-gate
- terminal-rendering-golden
runs-on: ubuntu-latest
permissions:
+2
View File
@@ -94,6 +94,8 @@ docs/**
!docs/readme/
!docs/readme/**
!docs/STYLEGUIDE.md
!docs/agent-skill-sharing-implementation-checklist.md
!docs/agent-skill-sharing-installation-plan.md
!docs/ai-vault-process-isolation-plan.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
+17 -10
View File
@@ -22,8 +22,7 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
const isMacDaily = process.env.ORCA_MAC_DAILY === '1'
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
const isMacRelease =
process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
const devChannelBuildVersion = isMacHourly
@@ -92,6 +91,7 @@ const winSpeechNativeResource = {
module.exports = {
appId,
productName: 'Orca',
protocols: [{ name: 'Orca', schemes: ['orca'] }],
...(devChannelBuildVersion
? { extraMetadata: { version: devChannelBuildVersion } }
: localBuildVersion
@@ -284,8 +284,14 @@ module.exports = {
}
if (context.electronPlatformName === 'darwin') {
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
await signMacNotificationStatusHelper(
await signMacStandaloneHelper(
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
'orca-notification-status',
context.packager
)
await signMacStandaloneHelper(
join(resourcesDir, '..', 'MacOS', 'orca-keyboard-layout'),
'orca-keyboard-layout',
context.packager
)
}
@@ -399,6 +405,10 @@ module.exports = {
{
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
},
{
from: 'native/keyboard-layout-macos/.build/release/orca-keyboard-layout',
to: 'MacOS/orca-keyboard-layout'
}
],
target: [
@@ -571,10 +581,10 @@ async function signMacComputerUseHelper(helperAppPath, packager) {
})
}
async function signMacNotificationStatusHelper(helperPath, packager) {
async function signMacStandaloneHelper(helperPath, helperName, packager) {
if (!existsSync(helperPath)) {
if (isMacRelease) {
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
throw new Error(`Missing ${helperName} helper at ${helperPath}`)
}
return
}
@@ -587,12 +597,9 @@ async function signMacNotificationStatusHelper(helperPath, packager) {
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
(isMacRelease ? null : '-')
if (!identity) {
throw new Error('Missing signing identity for orca-notification-status helper')
throw new Error(`Missing signing identity for ${helperName} helper`)
}
// Why: macOS keys notification records to the code-signing identifier; the
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
// (and any later) `codesign --force` derives the correct identifier. Sign
// before the outer Orca.app is sealed, like the computer-use helper.
// Why: nested executables must be signed before the outer app bundle is sealed.
const args = ['--force', '--sign', identity]
if (isMacRelease) {
args.push('--options', 'runtime', '--timestamp')
+1 -2
View File
@@ -50,7 +50,7 @@ inline src/main/linear/issues.ts
inline src/main/linear/projects.ts
inline src/main/memory/collector.ts
inline src/main/opencode/hook-service.ts
inline src/main/persistence.ts
inline src/main/persistence/loading-store/store.ts
inline src/main/ports/advertised-url-watcher.ts
inline src/main/ports/local-workspace-port-scanner.ts
inline src/main/project-groups/nested-repo-discovery.ts
@@ -154,7 +154,6 @@ inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.ts
inline src/renderer/src/store/slices/diffComments.ts
inline src/renderer/src/store/slices/editor.ts
inline src/renderer/src/store/slices/github.ts
inline src/renderer/src/store/slices/hosted-review.ts
inline src/renderer/src/store/slices/jira.ts
+200 -28
View File
@@ -234,21 +234,29 @@
"protection": "partial",
"owner": "terminal-session-readiness",
"layer": "posix-pty-and-shell-startup",
"surfaces": ["queued terminal startup commands", "zsh, bash, and Fish startup output"],
"surfaces": [
"queued terminal startup commands",
"zsh, bash, and Fish startup output",
"Bash PROMPT_COMMAND lifecycle composition"
],
"platforms": ["macos", "linux"],
"providers": ["local", "local-daemon", "ssh-daemon", "ssh-relay", "paired-runtime"],
"coveredPlatforms": ["macos", "linux"],
"coveredProviders": ["local", "local-daemon", "ssh-relay", "paired-runtime"],
"coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. A real Fish PTY proves marker stripping is byte-exact and does not render bracketed-paste control text. An Ubuntu 20.04 container confirms compatible zsh/bash termios. Deterministic owner contracts cover local, daemon, and SSH relay identity/readiness composition plus provider-owned delivery; explicit terminal-paste remains renderer-owned. Live isolated journeys cover a macOS headed desktop owner with paired web client, headless orca serve with paired web client, and a Docker Linux OpenSSH/Bash target with one readiness-time reconnect. Linux desktop, WSL/Windows, live zsh-over-SSH, and physical remote hosts remain gaps.",
"coverageNotes": "Real macOS node-pty integration covers zsh 5.9 .zshenv/.zprofile and bash 3.2 profile replacement shells, including exec -a names, same-shell and child-process silent reads, a blocking zle-line-init hook, non-shell readline replacements, and a non-shell image renamed zsh. A real Fish PTY proves marker stripping is byte-exact and does not render bracketed-paste control text. Ubuntu 20.04/Bash 5.0 and Ubuntu 24.04/Bash 5.2 containers cover scalar and array PROMPT_COMMAND composition, including real zoxide on 24.04. Deterministic owner contracts cover local, daemon, and SSH relay identity/readiness composition plus provider-owned delivery; explicit terminal-paste remains renderer-owned. Live isolated journeys cover a macOS headed desktop owner with paired web client, headless orca serve with paired web client, and a Docker Linux OpenSSH/Bash target with one readiness-time reconnect. Linux desktop, WSL/Windows, live zsh-over-SSH, and physical remote hosts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/13767",
"https://linear.app/stably/issue/STA-4067",
"https://linear.app/stably/issue/STA-4068",
"https://github.com/stablyai/orca/pull/14161"
"https://github.com/stablyai/orca/pull/14161",
"https://github.com/stablyai/orca/issues/14448",
"https://linear.app/stably/issue/STA-4292",
"https://github.com/stablyai/orca/pull/14451"
],
"invariant": "Shell startup scanning preserves every non-Orca output byte in order across arbitrary PTY chunk boundaries. Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, proof-based recovery releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger that proof. The existing bounded compatibility timeout may enqueue input without proof, but must not publish readiness, lose or duplicate that input across reconnect, or execute it before the replacement shell can accept it.",
"oracle": "Split Fish output immediately after the ready marker and after the following ESC introducer; local and daemon owners must emit the exact ESC [ ? 2004 h bytes and headless xterm must not render literal control text. Run the real generated wrappers under node-pty with disposable zsh, bash, and Fish startup files. Plain and exec -a replacement shells must receive one queued command at their first completed line editor without waiting for the 15-second timeout. The identical oracle must time out without the startup identity and prompt fallback. Same-shell reads, child-process reads, a blocking zle-line-init hook, sqlite3, and sqlite3 renamed zsh must not trigger prompt fallback. Relay owner coverage must use the same lossless scanner composition and release through the same prompt contract.",
"invariant": "Shell startup scanning preserves every non-Orca output byte in order across arbitrary PTY chunk boundaries. Bash scalar and supported array PROMPT_COMMAND values preserve user bytes, hook order, and foreground exit status while emitting exactly one OSC 133 A/C/D lifecycle without syntax errors. Before user startup files run, the wrapper publishes its shell PID. If a later exec discards the ready marker, proof-based recovery releases queued startup input only after the launched shell's actual executable image with that exact PID owns the PTY foreground, emits its post-hook line-editor enable sequence, and exposes zle/readline termios; startup reads, line-init hooks, child foreground processes, and non-shell readline replacements cannot trigger that proof. The existing bounded compatibility timeout may enqueue input without proof, but must not publish readiness, lose or duplicate that input across reconnect, or execute it before the replacement shell can accept it.",
"oracle": "Split Fish output immediately after the ready marker and after the following ESC introducer; local and daemon owners must emit the exact ESC [ ? 2004 h bytes and headless xterm must not render literal control text. Run the real generated wrappers under node-pty with disposable zsh, bash, and Fish startup files. Exercise Bash scalar and array PROMPT_COMMAND values with empty, repeated, escaped, whitespace, control-byte, odd-backslash, status-mutating, DEBUG-trap, zoxide, and RHEL-style hooks; require exact A,C,D:0,A,C,D:1,A,C ordering and byte preservation. Plain and exec -a replacement shells must receive one queued command at their first completed line editor without waiting for the 15-second timeout. The identical oracle must time out without the startup identity and prompt fallback. Same-shell reads, child-process reads, a blocking zle-line-init hook, sqlite3, and sqlite3 renamed zsh must not trigger prompt fallback. Relay owner coverage must use the same lossless scanner composition and release through the same prompt contract.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 src/main/daemon/shell-ready.test.ts src/main/shell-startup-output-scanner.test.ts",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-startup-exec-readiness.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
@@ -256,6 +264,7 @@
],
"testFiles": [
"src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts",
"src/main/daemon/bash-prompt-command-composition.test.ts",
"src/main/line-editor-ready-output-scanner.test.ts",
"src/main/shell-startup-identity-scanner.test.ts",
"src/main/shell-startup-output-scanner.test.ts",
@@ -274,6 +283,15 @@
"tests/e2e/paired-startup-exec-readiness.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/daemon/bash-prompt-command-composition.test.ts",
"assertions": [
"scalar and array PROMPT_COMMAND values accept trailing separators and whitespace",
"escaped trailing spaces and semicolons remain byte-exact",
"BASH_REMATCH and chained DEBUG-trap command identity remain intact",
"OSC 133 A/C/D lifecycle ordering and command exit status remain exact"
]
},
{
"file": "src/main/shell-startup-output-scanner.test.ts",
"assertions": [
@@ -348,6 +366,15 @@
"durationSeconds": 6,
"summary": "473 tests passed with one local Fish skip, including every-boundary scanner composition, real exec, silent-read, line-init-hook, renamed-image, disabled-bracketed-paste, and relay-owner oracles."
},
{
"date": "2026-08-15",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts src/main/daemon/bash-prompt-command-composition.test.ts src/main/line-editor-ready-output-scanner.test.ts src/main/shell-startup-identity-scanner.test.ts src/main/shell-startup-output-scanner.test.ts src/main/shell-prompt-readiness-probe.test.ts src/shared/pty-slave-line-discipline-echo.test.ts src/shared/shell-process-readiness.test.ts src/main/daemon/session.test.ts src/main/daemon/shell-ready.test.ts src/main/providers/local-pty-shell-ready-marker-scan.test.ts src/main/providers/local-pty-shell-ready-startup-command.test.ts src/main/providers/local-pty-shell-ready-wrapper-generation.test.ts src/main/providers/local-pty-provider-shell-readiness.test.ts src/relay/pty-shell-launch.test.ts src/relay/pty-handler-startup-command-delivery.test.ts",
"result": "passed",
"durationSeconds": 6,
"summary": "233 tests passed with one local Fish skip, including exact Bash PROMPT_COMMAND byte/status/lifecycle composition across daemon, local, and relay owners."
},
{
"date": "2026-08-12",
"runner": "local",
@@ -638,7 +665,7 @@
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-filesystem-provider-stream.test.ts src/main/system-resume-broadcast.test.ts src/main/system-power-lifecycle.test.ts src/main/startup/desktop-startup-ordering.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 0.8,
"durationSeconds": 1.6,
"summary": "Four focused files and 33 tests passed, including stalled cancellation, active progress, late metadata replay, failure isolation, committed-quit bridge lifetime, and post-settlement cleanup."
}
],
@@ -1022,14 +1049,14 @@
"providers": ["lan", "tailscale", "cloud-relay"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["lan", "tailscale", "cloud-relay"],
"coverageNotes": "Deterministic React/provider tests cover catalog, missing-host, construction, cancellation, release, forget, refresh, backoff, and stale-generation outcomes. Direct sessions cover idle probing and three fair misses. Relay sessions cover zero idle polling, rate-limited foreground probes, two fair misses, authenticated activity before semantic decoding, delivery ambiguity, scheduler stalls, and write failure. Physical iOS/Android sleep, carrier/Tailscale loss, and production relay paths remain live-test gaps.",
"coverageNotes": "Deterministic React/provider tests cover catalog, missing-host, construction, cancellation, release, forget, refresh, backoff, stale-generation outcomes, and path-aware recovery presentation. Direct sessions cover idle probing and three fair misses. Relay sessions cover zero idle polling, rate-limited foreground probes, two fair misses, authenticated activity before semantic decoding, delivery ambiguity, scheduler stalls, and write failure. Physical iOS/Android sleep, carrier/Tailscale loss, and production relay paths remain live-test gaps.",
"motivatingLinks": [
"https://linear.app/stably/issue/STA-4153/mobile-app-disconnects-frequently"
],
"invariant": "A provider-wanted host without a logical client retries every current-generation pre-client failure until success or explicit cancellation, while release, disconnect, forget, refresh, and stale async continuations preserve their distinct resurrection policies. Direct sessions probe after authenticated silence and terminate after three fair misses. Relay sessions emit no periodic idle or demand-driven probes, use rate-limited foreground probes, and terminate after two fair misses. Authenticated traffic, scheduler stalls, and replacement identities cannot manufacture death.",
"oracle": "Inject rejected and never-settling catalog reads, missing profiles, synchronous construction failure, release, refresh, forget, revival, and stale completion under fake time. Require bounded 1s through 60s retry, exact current-generation publication, and no zero-owner or forgotten-client resurrection. Inject direct and relay authenticated text/binary traffic, silent probe windows, stalled clocks, replacement identities, and failed writes. Require direct three-miss tolerance; Relay zero idle and demand-driven liveness traffic, one foreground probe sequence, two fair 4s misses, a 10s voluntary rate limit, no old-session network-change probe, and delivery-unknown mutations without replay.",
"invariant": "A provider-wanted host without a logical client retries every current-generation pre-client failure until success or explicit cancellation, while release, disconnect, forget, refresh, and stale async continuations preserve their distinct resurrection policies. Direct sessions probe after authenticated silence and terminate after three fair misses. Relay sessions emit no periodic idle or demand-driven probes, use rate-limited foreground probes, and terminate after two fair misses. Authenticated traffic, scheduler stalls, and replacement identities cannot manufacture death. Relay recovery must notify presentation independently of transport-state changes, must not inherit a failed direct path's Tailscale hint, and must retain long-outage escalation.",
"oracle": "Inject rejected and never-settling catalog reads, missing profiles, synchronous construction failure, release, refresh, forget, revival, and stale completion under fake time. Require bounded 1s through 60s retry, exact current-generation publication, and no zero-owner or forgotten-client resurrection. Inject direct and relay authenticated text/binary traffic, silent probe windows, stalled clocks, replacement identities, and failed writes. Require direct three-miss tolerance; Relay zero idle and demand-driven liveness traffic, one foreground probe sequence, two fair 4s misses, a 10s voluntary rate limit, no old-session network-change probe, and delivery-unknown mutations without replay. Start and clear Relay recovery without changing transport state and require observers to rerender. Project five failed Tailscale attempts into a neutral Relay-specific verdict, then require twelve attempts to produce a Relay-specific unreachable verdict with no Tailscale hint.",
"commands": [
"pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts"
"pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/connection-health.test.ts"
],
"testFiles": [
"mobile/src/transport/client-context.test.ts",
@@ -1041,12 +1068,16 @@
"mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts",
"mobile/src/transport/mobile-endpoint-nudge-router.test.ts",
"mobile/src/transport/mobile-relay-e2ee-link.test.ts",
"mobile/src/transport/stable-logical-rpc-client.test.ts"
"mobile/src/transport/stable-logical-rpc-client.test.ts",
"mobile/src/transport/connection-health.test.ts"
],
"assertionRefs": [
{
"file": "mobile/src/transport/client-context.test.ts",
"assertions": ["a retired close-on-release owner closes an ownerless manual reconnect"]
"assertions": [
"a retired close-on-release owner closes an ownerless manual reconnect",
"a Relay recovery path change rerenders observers without a transport-state change"
]
},
{
"file": "mobile/src/transport/host-open-recovery.test.tsx",
@@ -1097,18 +1128,28 @@
},
{
"file": "mobile/src/transport/stable-logical-rpc-client.test.ts",
"assertions": ["a delivery-unknown mutation is not replayed onto a replacement Relay"]
"assertions": [
"a delivery-unknown mutation is not replayed onto a replacement Relay",
"Relay recovery path changes publish and remain pending between failed dials"
]
},
{
"file": "mobile/src/transport/connection-health.test.ts",
"assertions": [
"a Relay fallback pending over five failed Tailscale attempts remains neutral and omits the Tailscale hint",
"a prolonged Relay recovery escalates without reviving the Tailscale hint"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-14",
"date": "2026-08-16",
"runner": "local",
"platform": "macos",
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts",
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/client-context.test.ts mobile/src/transport/host-open-recovery.test.tsx mobile/src/transport/host-open-retry-scheduler.test.ts mobile/src/transport/rpc-session-liveness-watchdog.test.ts mobile/src/transport/rpc-session-liveness-integration.test.ts mobile/src/transport/mobile-relay-rpc-session.test.ts mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts mobile/src/transport/mobile-endpoint-nudge-router.test.ts mobile/src/transport/mobile-relay-e2ee-link.test.ts mobile/src/transport/stable-logical-rpc-client.test.ts mobile/src/transport/connection-health.test.ts",
"result": "passed",
"durationSeconds": 0.74,
"summary": "Ten focused files passed 66 deterministic provider lifecycle, direct/Relay liveness, traffic, and delivery-ambiguity tests."
"summary": "Eleven focused files passed 84 deterministic provider lifecycle, path presentation, direct/Relay liveness, traffic, and delivery-ambiguity tests."
}
],
"runtimeBudget": {
@@ -8299,22 +8340,25 @@
"providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos", "linux"],
"coveredProviders": [],
"coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, Electron/CDP live-PTY Sogou-style repros, and a macOS renderer/main slice for Preview kitty commits: claimed press/release pairing in both keyup orderings, and snapshot flag provenance where a proven 0, a proven non-zero, and an absent field stay three distinct facts from daemon through the sequenced remote SnapshotStart frame. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, Electron/CDP live-PTY Sogou-style repros, and macOS renderer/main slices for Preview kitty commits and layout-native Option input. The Option slice covers complete CSI-u flag fields including lock state, repeat/release event types, authoritative committed associated text, native dead-key release suppression, functional-versus-printable numpad codes, press-pinned release identity, live keyup modifiers, pending-layout text fallback, atomic layout refresh, rapid in-focus input-source changes, stable native layout characters, and both native/web preload fallbacks. Snapshot flag provenance keeps a proven 0, a proven non-zero, and an absent field distinct from daemon through the sequenced remote SnapshotStart frame. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6699",
"https://github.com/stablyai/orca/pull/6682",
"https://github.com/stablyai/orca/pull/6513",
"https://github.com/stablyai/orca/pull/6999"
],
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY. When the snapshot source proves kitty state, macOS Preview commits must use the application's effective flags after the sequenced snapshot and every proven replay/live transition, and a claimed press delivered under report_event_types must produce exactly one matching release regardless of whether keyup arrives before or after insertText and regardless of xterm's defensive internal reset.",
"oracle": "Exact outbound bytes from the REAL native-text forwarder behind installPreviewImeBridge after initial snapshot restore, replacement resync, live transitions, and both keydown->insertText->keyup and keydown->keyup->insertText orderings; a wiring-only mock is supporting evidence, not the oracle. The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY. Layout-native Option text must preserve negotiated CSI-u alternate/associated fields and lock modifiers, distinguish repeat from press, and resolve releases with the press's primary identity plus the actual keyup modifiers without exposing a partial or stale layout refresh. An in-focus macOS input-source change must invalidate both renderer authorities before the next key can use the old source. When the snapshot source proves kitty state, macOS Preview commits must use the application's effective flags after the sequenced snapshot and every proven replay/live transition, and a claimed press delivered under report_event_types must produce exactly one matching release regardless of whether keyup arrives before or after insertText and regardless of xterm's defensive internal reset.",
"oracle": "Exact outbound bytes from the REAL native-text forwarder behind installPreviewImeBridge after initial snapshot restore, replacement resync, live transitions, and both keydown->insertText->keyup and keydown->keyup->insertText orderings; a wiring-only mock is supporting evidence, not the oracle. The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. A main/preload/renderer notification chain proves every live window invalidates immediately, the old native read settles before a coalesced refresh signal, and older refreshes are generation-fenced. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts src/main/ipc/macos-keyboard-layout-snapshot.test.ts src/main/ipc/macos-keyboard-layout-change-notifications.test.ts src/preload/app-restart-checkpoint-routing.test.ts src/renderer/src/web/web-preload-api-ui.test.ts",
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"pnpm run test:e2e -- tests/e2e/terminal-option-composed-ascii.spec.ts"
],
"testFiles": [
"tests/e2e/terminal-option-composed-ascii.spec.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-macos-keybinding-dict-trace.test.ts",
@@ -8324,9 +8368,19 @@
"src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts",
"src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts",
"src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts",
"src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts",
"src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts",
"src/main/ipc/macos-keyboard-layout-snapshot.test.ts",
"src/main/ipc/macos-keyboard-layout-change-notifications.test.ts",
"src/preload/app-restart-checkpoint-routing.test.ts",
"src/renderer/src/web/web-preload-api-ui.test.ts",
"src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts",
"src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts",
"src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx",
"src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx",
"src/shared/terminal-kitty-keyboard-mode-tracker.test.ts",
"src/main/ipc/terminal-preview.test.ts",
"src/main/ipc/terminal-preview-output-stream.test.ts",
@@ -8339,13 +8393,15 @@
"file": "src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
"assertions": [
"native text commits route once to the intended PTY",
"composition/preedit bookkeeping does not leak premature text"
"composition/preedit bookkeeping does not leak premature text",
"multi-codepoint committed text is reported independently from physical key identity"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts",
"assertions": [
"synthetic and native input-source paths are classified for covered fixtures"
"synthetic and native input-source paths are classified for covered fixtures",
"report-all commits retain the unshifted physical key while associated text carries the committed substitution"
]
},
{
@@ -8396,6 +8452,89 @@
"macOS standalone Process key behavior and composition-owned key suppression stay intact"
]
},
{
"file": "tests/e2e/terminal-option-composed-ascii.spec.ts",
"assertions": [
"a live kitty-negotiated pane receives the Option-composed ASCII character, not the physical alt chord",
"an Option layer that also needs Shift still reaches the pty as text",
"configured Option-as-Alt still reports the layout-resolved physical chord",
"a non-ASCII Option glyph still reaches the application as its alt hotkey"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts",
"assertions": [
"report-all Option events encode exact alternate, repeat, release, and associated-text fields",
"report-all native commits preserve unresolved layout characters and distinguish functional from printable numpad identities",
"associated text omits forbidden control codepoints",
"release events omit associated text and sparse PC-101 alternates retain their parameter position",
"CapsLock and NumLock occupy their protocol modifier bits"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts",
"assertions": [
"native Option-composed ASCII remains text outside report-all mode while configured-Alt sides remain chords",
"pending shifted-layout data fails safe to composed text while configured-Alt ownership remains authoritative",
"dead, Process, Unidentified, composing, and keyCode-229 events stay with composition",
"configured Alt-side dead keys retain legacy chords while other IME-owned events stay with composition",
"policy-owned configured Alt uses the active layout, preserves engine-owned global legacy and form-only legacy encoding, and omits associated text",
"report-all uses layout primary/shifted identities plus committed text for ISO and Space keys"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts",
"assertions": [
"owned releases use live Shift and Option state and current alternate-key flags",
"owned releases retain the delivered press identity across layout, Shift, and NumLock changes",
"a native dead-key keyup is consumed without inventing an unmatched protocol release",
"event-type pop consumes without bytes and repeat keydowns cannot replace the original release owner"
]
},
{
"file": "src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts",
"assertions": [
"complete native layout characters replace atomically while input continues using the prior complete snapshot",
"native and browser layout sources from separate transactions are never combined",
"an input-source notification clears stale characters synchronously and fences the older refresh"
]
},
{
"file": "src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts",
"assertions": [
"a stale asynchronous input-source probe cannot overwrite the newest classification",
"an input-source notification changes the classification to unknown synchronously before refreshing"
]
},
{
"file": "src/main/ipc/macos-keyboard-layout-snapshot.test.ts",
"assertions": [
"the native snapshot parser preserves input/layout identities and both modifier layers"
]
},
{
"file": "src/main/ipc/macos-keyboard-layout-change-notifications.test.ts",
"assertions": [
"the distributed input-source notification invalidates every live window before waiting out the old helper read",
"rapid input-source changes coalesce to one latest-generation refresh signal",
"destroyed windows and teardown races cannot crash the main process, and will-quit unsubscribes exactly once",
"non-macOS processes install no native subscription"
]
},
{
"file": "src/preload/app-restart-checkpoint-routing.test.ts",
"assertions": [
"macOS keyboard reads and the change subscription remain wired after main integration",
"the two-phase change payload reaches the renderer unchanged",
"the subscription disposer removes the exact IPC listener"
]
},
{
"file": "src/renderer/src/web/web-preload-api-ui.test.ts",
"assertions": [
"web fallbacks expose no local captured chords, no native layout snapshot, and a no-op layout-change subscription"
]
},
{
"file": "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts",
"assertions": [
@@ -8423,6 +8562,12 @@
"the Preview hands the forwarder the live kitty mirror seeded from the snapshot's proven flags"
]
},
{
"file": "src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx",
"assertions": [
"native and configured-side dead-key releases remain owned by the Preview without duplicate bytes"
]
},
{
"file": "src/shared/terminal-kitty-keyboard-mode-tracker.test.ts",
"assertions": [
@@ -8467,13 +8612,40 @@
],
"evidenceRuns": [
{
"date": "2026-08-11",
"date": "2026-08-16",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-kitty-csi-u-encoding.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-option-compose.test.ts src/renderer/src/components/terminal-pane/terminal-option-kitty-release.test.ts src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts src/renderer/src/lib/keyboard-layout/option-as-alt-probe.test.ts src/main/ipc/macos-keyboard-layout-snapshot.test.ts src/main/ipc/macos-keyboard-layout-change-notifications.test.ts src/preload/app-restart-checkpoint-routing.test.ts src/renderer/src/web/web-preload-api-ui.test.ts",
"result": "passed",
"durationSeconds": 9.84,
"summary": "8 test file(s) passed, 288 tests, covering the Preview kitty slice: real forwarder bytes at snapshot-proven flags, press/release pairing across both keyup/insertText orderings, and flag provenance where a proven 0, a proven non-zero, and an absent field stay three distinct facts — asserted on the daemon snapshot and on a host-pushed remote snapshot that carries the flags with their sequence. The emitter-side rule that a remote SnapshotStart withholds flags without a seq has no direct assertion yet; the client-side gate is what these files pin."
"durationSeconds": 0.8,
"summary": "The deterministic Option/layout slice passed exact protocol bytes for filtered committed associated text, physical and alternate key identities, functional and printable numpad keys, press-pinned releases across layout and lock changes, lock modifiers, layout-aware configured Alt across individual flags and dead keys, terminal-engine-owned global legacy handling, IME-owned DOM events, ISO/Space keys, native dead-key release suppression, pending-layout text fallback, release ownership, the two-phase native-read barrier, synchronous cache invalidation, rapid-change coalescing, generation fencing, every-live-window fanout, cleanup, and native/web platform fallbacks."
},
{
"date": "2026-08-16",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/terminal-option-composed-ascii.spec.ts",
"result": "passed",
"durationSeconds": 66,
"summary": "Four live-Electron scenarios passed against a real pty whose kitty flags were armed by the application's own CSI > 1 u: Option-composed `@` and Option+Shift-composed `\\` arrive as text with no alt chord, configured Option-as-Alt still reports the layout-resolved chord, and a non-ASCII Option glyph still reaches the application as its alt hotkey. Discrimination was proven by mutation rather than assumed — restoring the pre-fix policy makes exactly the two composed-text scenarios fail with the character never reaching the pty, while both chord scenarios stay green."
},
{
"date": "2026-08-16",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"result": "passed",
"durationSeconds": 13.9,
"summary": "The Electron live-PTY IME gate passed three scenarios covering Windows IME Shift commit, candidate-selector suppression with committed Chinese text, and composed text/cursor/Backspace stability; the opt-in real-agent scenario remained skipped."
},
{
"date": "2026-08-16",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/renderer/src/components/dashboard-popout/preview-terminal-snapshot-replay.test.ts src/renderer/src/components/dashboard-popout/AgentTerminalPreview.test.tsx src/renderer/src/components/dashboard-popout/AgentTerminalPreview.option-dead-key.test.tsx src/shared/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/renderer/src/runtime/remote-runtime-terminal-snapshot-kitty-flags.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
"result": "passed",
"durationSeconds": 4.3,
"summary": "9 test files and 172 tests passed, covering the Preview kitty slice: real forwarder bytes at snapshot-proven flags, press/release pairing across both keyup/insertText orderings, native and configured-side dead-key release ownership, and flag provenance where a proven 0, a proven non-zero, and an absent field stay three distinct facts — asserted on the daemon snapshot and on a host-pushed remote snapshot that carries the flags with their sequence. The emitter-side rule that a remote SnapshotStart withholds flags without a seq has no direct assertion yet; the client-side gate is what these files pin."
},
{
"date": "2026-08-09",
@@ -8544,7 +8716,7 @@
},
"performanceBudget": {
"required": true,
"evidence": "Terminal IME hot-path audit clean on 2026-07-11: candidate guards add O(1) boolean checks plus bounded per-pane Space/digit state and one renderer-scoped currently pressed physical-letter set; ref-counted renderer and pane blur listeners are disposed with pane lifecycle; no per-pane keyboard-event fan-out, polling, IPC, subprocess work, or SSH/remote transport cost added."
"evidence": "Terminal input hot-path audit clean on 2026-08-16: candidate guards retain O(1) checks and bounded per-pane state. Layout changes add one process-lifetime native listener, two singleton renderer subscriptions, one O(live windows) broadcast per OS event, and generation-fenced refreshes whose native helper read is deduplicated in flight. The path is event-driven with will-quit/dispose cleanup and adds no polling, timers, per-key IPC, SSH/remote transport work, unbounded queues, or retained terminal data."
},
"promotionCriteria": [
"Cover deterministic byte/cell oracles first.",
@@ -0,0 +1,61 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const repoRoot = path.resolve(import.meta.dirname, '../..')
const sourcePath = path.join(repoRoot, 'native', 'keyboard-layout-macos', 'main.swift')
const defaultOutputPath = path.join(
repoRoot,
'native',
'keyboard-layout-macos',
'.build',
'release',
'orca-keyboard-layout'
)
if (process.platform !== 'darwin') {
process.exit(0)
}
const args = process.argv.slice(2)
const outputPath = readArg('--output') ?? defaultOutputPath
const singleArch = args.includes('--single-arch')
const workDir = mkdtempSync(path.join(tmpdir(), 'orca-keyboard-layout-'))
try {
const triples = singleArch
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
const builtBinaries = triples.map((triple) => {
const output = path.join(workDir, `orca-keyboard-layout-${triple}`)
execFileSync(
'swiftc',
[
'-O',
sourcePath,
'-target',
triple.replace('-apple-macosx', '-apple-macosx11.0'),
'-o',
output
],
{ stdio: 'inherit' }
)
return output
})
mkdirSync(path.dirname(outputPath), { recursive: true })
if (builtBinaries.length === 1) {
execFileSync('cp', [builtBinaries[0], outputPath])
} else {
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
}
chmodSync(outputPath, 0o755)
} finally {
rmSync(workDir, { recursive: true, force: true })
}
function readArg(name) {
const index = args.indexOf(name)
return index === -1 ? undefined : args[index + 1]
}
@@ -13,6 +13,7 @@ if (process.platform !== 'darwin') {
}
runPnpmScript('build:computer-macos')
runPnpmScript('build:keyboard-layout-macos')
runPnpmScript('build:notification-status-macos')
process.exit(0)
+1 -1
View File
@@ -60,7 +60,7 @@ export function nextDailyBuildNumber(baseVersion, releaseNames = []) {
}
/**
* `1.4.163 • 01 • Aug 9, 6:15AM • e698241` — the human-facing release title,
* `1.4.163 • 01 • Aug 9, 10:15AM • e698241` — the human-facing release title,
* shown verbatim in both the GitHub releases list and the in-app build picker.
*/
export function formatDailyReleaseName(version, buildNumber, commit, date) {
+9 -9
View File
@@ -45,27 +45,27 @@ describe('formatDailyReleaseName', () => {
formatDailyReleaseName('1.4.163-daily.x', buildNumber, commit, new Date(iso))
it('renders version, number, Pacific timestamp, and short sha', () => {
// 13:15 UTC is 6:15AM PDT in July (the daily cut time).
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
// 17:15 UTC is 10:15AM PDT in July (the daily cut time).
expect(name('2026-07-28T17:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 10:15AM • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
expect(name('2026-01-15T14:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 6:15AM • e698241')
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
expect(name('2026-01-15T18:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 10:15AM • e698241')
expect(name('2026-07-28T17:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 10:15AM • e698241')
})
it('pads to two digits and grows past them', () => {
expect(name('2026-07-28T13:15:00Z', 9)).toContain(' • 09 • ')
expect(name('2026-07-28T13:15:00Z', 42)).toContain(' • 42 • ')
expect(name('2026-07-28T17:15:00Z', 9)).toContain(' • 09 • ')
expect(name('2026-07-28T17:15:00Z', 42)).toContain(' • 42 • ')
})
it('rejects a build number that is not a positive integer', () => {
expect(() => name('2026-07-28T13:15:00Z', 0)).toThrow(/positive integer/)
expect(() => name('2026-07-28T13:15:00Z', -1)).toThrow(/positive integer/)
expect(() => name('2026-07-28T13:15:00Z', 1.5)).toThrow(/positive integer/)
expect(() => name('2026-07-28T17:15:00Z', 0)).toThrow(/positive integer/)
expect(() => name('2026-07-28T17:15:00Z', -1)).toThrow(/positive integer/)
expect(() => name('2026-07-28T17:15:00Z', 1.5)).toThrow(/positive integer/)
})
it('rejects an invalid timestamp', () => {
@@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const dailyWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/daily-mac-build.yml'), 'utf8')
)
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
describe('daily E2E dispatch contract', () => {
it('dispatches SHA-scoped full E2E only after a live daily publish', () => {
const buildJob = dailyWorkflow.jobs['build-daily-mac']
const dispatchJob = dailyWorkflow.jobs['post-daily-e2e']
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch cut-scoped E2E')
expect(dailyWorkflow.jobs.e2e).toBeUndefined()
expect(buildJob.outputs.head_sha).toBe('${{ steps.freshness.outputs.head_sha }}')
expect(buildJob.outputs.published).toBe(
"${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}"
)
expect(dispatchJob.needs).toBe('build-daily-mac')
expect(dispatchJob.if).toBe(
"${{ needs.build-daily-mac.outputs.published == 'true' && needs.build-daily-mac.outputs.head_sha != '' }}"
)
expect(dispatchJob.permissions.actions).toBe('write')
expect(dispatchStep.env.SHA).toBe('${{ needs.build-daily-mac.outputs.head_sha }}')
expect(dispatchStep.run).toContain('gh workflow run e2e.yml')
expect(dispatchStep.run).toContain('--ref main')
expect(dispatchStep.run).toContain('--raw-field "ref=$SHA"')
expect(dispatchStep.run).toContain('for attempt in 1 2 3')
expect(dispatchStep.run).toContain('[[ "$attempt" -eq 3 ]] || sleep')
expect(dispatchStep.run).toContain('::warning::Failed to dispatch post-daily E2E')
})
it('leaves test_files unset so the dispatched run takes the full suite', () => {
const dispatchJob = dailyWorkflow.jobs['post-daily-e2e']
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch cut-scoped E2E')
expect(dispatchStep.run).not.toContain('test_files')
expect(e2eWorkflow.on.workflow_call.inputs.test_files.required).toBe(false)
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
})
})
@@ -155,6 +155,20 @@ describe('electron-builder config', () => {
)
})
it('ships the mac keyboard-layout helper in Contents/MacOS, not Resources', () => {
expect(electronBuilderConfig.mac.extraFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/keyboard-layout-macos/.build/release/orca-keyboard-layout',
to: 'MacOS/orca-keyboard-layout'
})
])
)
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
expect.arrayContaining([expect.objectContaining({ to: 'orca-keyboard-layout' })])
)
})
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining([
@@ -0,0 +1,47 @@
const { join } = require('node:path')
const resourcesDirectory = process.argv[2]
if (!resourcesDirectory) {
throw new Error('linux-packaged-node-pty-floor-resources-required')
}
const pty = require(join(resourcesDirectory, 'node_modules', 'node-pty'))
const expected = 'orca-node-pty-floor-ok'
let output = ''
let settled = false
const child = pty.spawn('/bin/sh', ['-c', `printf ${expected}; sleep 0.1`], {
name: 'xterm-color',
cols: 80,
rows: 24,
cwd: '/tmp',
env: { PATH: process.env.PATH ?? '/usr/bin:/bin' }
})
const timeout = setTimeout(() => {
if (settled) {
return
}
settled = true
child.kill()
process.stderr.write('packaged node-pty floor smoke timed out\n')
process.exitCode = 1
}, 5_000)
child.onData((data) => {
output = `${output}${data}`.slice(-256)
})
child.onExit(({ exitCode }) => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (exitCode !== 0 || output !== expected) {
process.stderr.write(
`packaged node-pty floor smoke failed: exit=${exitCode} bytes=${Buffer.byteLength(output)}\n`
)
process.exitCode = 1
return
}
process.stdout.write(`${expected}\n`)
})
@@ -90,17 +90,21 @@ describe('Electron runtime package contract', () => {
}
})
it('keeps Windows and Linux package builds off the macOS native helper build', () => {
it('keeps Windows and Linux package builds off macOS native helper builds', () => {
const scripts = packageJson.scripts
expect(scripts['build:desktop']).not.toContain('build:computer-macos')
expect(scripts['build:desktop']).not.toContain('build:keyboard-layout-macos')
expect(scripts['build:win']).toContain('pnpm run build:desktop')
expect(scripts['build:win']).not.toContain('pnpm run build ')
expect(scripts['build:win']).not.toContain('build:computer-macos')
expect(scripts['build:win']).not.toContain('build:keyboard-layout-macos')
expect(scripts['build:linux']).toContain('pnpm run build:desktop')
expect(scripts['build:linux']).not.toContain('pnpm run build ')
expect(scripts['build:linux']).not.toContain('build:computer-macos')
expect(scripts['build:linux']).not.toContain('build:keyboard-layout-macos')
expect(scripts['build:mac']).toContain('pnpm run build:computer-macos')
expect(scripts['build:mac']).toContain('pnpm run build:keyboard-layout-macos')
expect(scripts['build:release']).toContain('pnpm run build:native')
expect(scripts['build:release']).not.toContain('build:computer-macos')
})
+26 -4
View File
@@ -101,6 +101,20 @@ describe('PR test LoC summary', () => {
})
})
it('colors added cells green and deleted or negative-net cells red', () => {
const block = renderLocBlock({
test: { files: 1, added: 2, deleted: 5 },
nonTest: { files: 1, added: 0, deleted: 4 }
})
expect(block).toContain(
'| Test | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b5 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b3 |'
)
expect(block).toContain(
'| Prod | 1 | 0 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b4 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b4 |'
)
})
it('replaces an existing header and prepends when missing', () => {
const totals = {
test: { files: 1, added: 2, deleted: 1 },
@@ -109,8 +123,12 @@ describe('PR test LoC summary', () => {
const block = renderLocBlock(totals)
expect(block).toContain(LOC_HANDS_OFF_COMMENT)
expect(block).toContain('| Test | 1 | +2 | −1 | +1 |')
expect(block).toContain('| Prod | 1 | +4 | 0 | +4 |')
expect(block).toContain(
'| Test | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b1 |'
)
expect(block).toContain(
'| Prod | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b4 | 0 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b4 |'
)
expect(block).not.toContain('| Total |')
expect(mergeLocBlock('## ELI5\n\nHello\n', totals)).toBe(`${block}\n\n## ELI5\n\nHello\n`)
expect(mergeLocBlock(`${block}\n\n## ELI5\n`, totals)).toBe(`${block}\n\n## ELI5\n`)
@@ -141,8 +159,12 @@ describe('PR test LoC summary', () => {
expect(result.status).toBe(0)
expect(result.stdout.startsWith('<!-- orca-pr-loc -->')).toBe(true)
expect(result.stdout).toContain(LOC_HANDS_OFF_COMMENT)
expect(result.stdout).toContain('| Test | 1 | +6 | −1 | +5 |')
expect(result.stdout).toContain('| Prod | 1 | +2 | 0 | +2 |')
expect(result.stdout).toContain(
'| Test | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b6 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b5 |'
)
expect(result.stdout).toContain(
'| Prod | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 | 0 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 |'
)
expect(result.stdout).not.toContain('| Total |')
expect(result.stdout).toContain('## ELI5\n\nHello\n')
expect(result.stdout.match(/<!-- orca-pr-loc -->/g)).toHaveLength(1)
@@ -15,6 +15,7 @@ const shellContractFiles = [
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts',
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
'src/main/zsh-scoped-histfile.live-shell.test.ts',
'src/shared/posix-command-path-lookup.test.ts'
]
const patchedNodePtyContractFiles = [
+22 -3
View File
@@ -128,8 +128,9 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
// v7: give the terminal daemon helper an Orca-specific TCC identity.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v7'
// v10: add the keyboard-layout helper. A stale copy only emits extra fields
// the parser ignores, so narrowing its schema needs no bump.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v10'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@@ -169,7 +170,8 @@ function prepareMacDevElectronApp() {
'Electron Framework.framework',
'Resources',
'icudtl.dat'
)
),
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout')
]
function copiedAppIsUsable() {
@@ -242,6 +244,23 @@ function prepareMacDevElectronApp() {
)
}
try {
execFileSync(
process.execPath,
[
path.join(repoRoot, 'config', 'scripts', 'build-keyboard-layout-macos.mjs'),
'--single-arch',
'--output',
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout')
],
{ stdio: 'inherit' }
)
} catch (error) {
console.warn(
`[orca-dev] keyboard-layout helper build failed (shifted Option composition stays conservative): ${error?.message ?? error}`
)
}
// Why: the plist edits above (and the copy itself) break the bundle's
// ad-hoc seal, and macOS refuses Notification Center registration for
// invalidly-signed apps — every dev notification fails with UNErrorDomain
@@ -0,0 +1,94 @@
import { existsSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
const FLOOR_IMAGE = 'ubuntu:20.04'
const LINUX_EXECUTABLE = 'orca-ide'
const FLOOR_PACKAGES = [
'ca-certificates',
'libasound2',
'libatspi2.0-0',
'libdrm2',
'libgbm1',
'libgtk-3-0',
'libnss3',
'libx11-xcb1',
'libxkbcommon0',
'libxss1'
]
function workspaceRelativePath(workspaceDirectory, inputPath) {
const absolutePath = resolve(workspaceDirectory, inputPath)
const relativePath = relative(workspaceDirectory, absolutePath)
if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`)) {
throw new Error('linux-packaged-node-pty-floor-app-directory-invalid')
}
return relativePath.split(sep).join('/')
}
export function packagedNodePtyFloorDockerArgs({ workspaceDirectory, appDirectory }) {
const relativeAppDirectory = workspaceRelativePath(workspaceDirectory, appDirectory)
const containerAppDirectory = `/workspace/${relativeAppDirectory}`
const command = [
'export DEBIAN_FRONTEND=noninteractive',
'apt-get update -qq',
`apt-get install -y -qq ${FLOOR_PACKAGES.join(' ')} >/dev/null`,
`ELECTRON_RUN_AS_NODE=1 ${containerAppDirectory}/${LINUX_EXECUTABLE} ` +
'/workspace/config/scripts/linux-packaged-node-pty-floor-child.cjs ' +
`${containerAppDirectory}/resources`
].join(' && ')
return [
'run',
'--rm',
'--mount',
`type=bind,src=${workspaceDirectory},dst=/workspace,readonly`,
'--workdir',
'/workspace',
FLOOR_IMAGE,
'/bin/bash',
'-lc',
command
]
}
function parseAppDirectory(argv) {
const index = argv.indexOf('--app-dir')
const value = index !== -1 ? argv[index + 1] : undefined
if (!value || isAbsolute(value) || value.startsWith('-')) {
throw new Error('Usage: run-linux-packaged-node-pty-floor-smoke.mjs --app-dir <relative-path>')
}
return value
}
export function runPackagedNodePtyFloorSmoke({
workspaceDirectory = process.cwd(),
appDirectory,
spawn = spawnSync
}) {
if (process.platform !== 'linux') {
throw new Error('linux-packaged-node-pty-floor-smoke-requires-linux')
}
const absoluteAppDirectory = resolve(workspaceDirectory, appDirectory)
if (!existsSync(resolve(absoluteAppDirectory, LINUX_EXECUTABLE))) {
throw new Error(`linux-packaged-node-pty-floor-executable-missing: ${absoluteAppDirectory}`)
}
const result = spawn(
'docker',
packagedNodePtyFloorDockerArgs({ workspaceDirectory, appDirectory }),
{ encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }
)
if (result.error || result.signal || result.status !== 0) {
const detail =
result.error?.message ||
result.stderr?.trim() ||
`status-${result.status}-signal-${result.signal ?? 'none'}`
throw new Error(`linux-packaged-node-pty-floor-smoke-failed: ${detail}`)
}
process.stdout.write(result.stdout)
}
const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url
if (isMain) {
runPackagedNodePtyFloorSmoke({ appDirectory: parseAppDirectory(process.argv.slice(2)) })
}
@@ -0,0 +1,36 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { packagedNodePtyFloorDockerArgs } from './run-linux-packaged-node-pty-floor-smoke.mjs'
describe('packaged node-pty Linux floor smoke', () => {
it('mounts the exact package read-only in an Ubuntu 20.04 container', () => {
const args = packagedNodePtyFloorDockerArgs({
workspaceDirectory: '/repo',
appDirectory: 'dist/linux-arm64-unpacked'
})
expect(args).toContain('type=bind,src=/repo,dst=/workspace,readonly')
expect(args).toContain('ubuntu:20.04')
expect(args.at(-1)).toContain('/workspace/dist/linux-arm64-unpacked/orca-ide')
expect(args.at(-1)).toContain('ELECTRON_RUN_AS_NODE=1')
})
it('rejects an app directory outside the workspace', () => {
expect(() =>
packagedNodePtyFloorDockerArgs({
workspaceDirectory: '/repo',
appDirectory: '../outside'
})
).toThrow('linux-packaged-node-pty-floor-app-directory-invalid')
})
it('loads node-pty from packaged runtime resources', () => {
const childSource = readFileSync(
new URL('./linux-packaged-node-pty-floor-child.cjs', import.meta.url),
'utf8'
)
expect(childSource).toContain("join(resourcesDirectory, 'node_modules', 'node-pty')")
expect(childSource).not.toContain("join(resourcesDirectory, 'app.asar'")
})
})
@@ -0,0 +1,82 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8'))
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
function stepNamed(job, name) {
return job.steps.find((step) => step.name === name)
}
describe('skill-sharing release workflow', () => {
it('blocks publication on native Windows, macOS, and the Linux floor', () => {
const platform = workflow.jobs['skill-sharing-release-gate']
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
const publishNeeds = workflow.jobs['publish-release'].needs
expect(platform.strategy.matrix.include).toEqual([
{ os: 'macos-15', platform: 'mac' },
{ os: 'windows-2022', platform: 'windows' }
])
expect(linux.container).toBe('ubuntu:20.04')
expect(publishNeeds).toContain('skill-sharing-release-gate')
expect(publishNeeds).toContain('skill-sharing-linux-floor-release-gate')
})
it('runs the focused contract and transaction suite with real Windows coverage', () => {
const platform = workflow.jobs['skill-sharing-release-gate']
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
const platformTest = stepNamed(
platform,
'Run skill package, transaction, and compatibility suites'
)
const linuxTest = stepNamed(linux, 'Run skill package, transaction, and compatibility suites')
const command = packageJson.scripts['test:skill-sharing:release']
expect(platformTest.env.ORCA_REAL_WINDOWS_SKILL_TEST).toContain("runner.os == 'Windows'")
expect(platformTest.env.ORCA_REAL_PROCESS_SKILL_TEST).toBe('1')
expect(linuxTest.env.ORCA_REAL_PROCESS_SKILL_TEST).toBe('1')
expect(platformTest.run).toContain('pnpm test:skill-sharing:release')
expect(linuxTest.run).toContain('pnpm test:skill-sharing:release')
expect(command).toContain('src/main/skills')
expect(command).toContain('src/relay/skill-install-handler.test.ts')
expect(command).toContain('src/shared/skill-bundle-install-contract.test.ts')
})
it('archives bounded machine-readable evidence from every platform', () => {
for (const jobName of [
'skill-sharing-release-gate',
'skill-sharing-linux-floor-release-gate'
]) {
const job = workflow.jobs[jobName]
const test = stepNamed(job, 'Run skill package, transaction, and compatibility suites')
const archive = stepNamed(job, 'Archive bounded skill-sharing results')
expect(test.run).toContain('--reporter=json')
expect(test.run).toContain('--outputFile=skill-sharing-release-results.json')
expect(archive.if).toBe('always()')
expect(archive.with['retention-days']).toBe(14)
expect(archive.with['if-no-files-found']).toBe('error')
}
})
it('loads each exact Linux package on the glibc 2.31 floor', () => {
const build = workflow.jobs.build
const smoke = stepNamed(build, 'Load packaged node-pty on the Linux floor')
const linuxEntries = build.strategy.matrix.include.filter(({ platform }) =>
platform.startsWith('linux-')
)
expect(linuxEntries).toEqual([
expect.objectContaining({ platform: 'linux-x64', unpacked_dir: 'dist/linux-unpacked' }),
expect.objectContaining({
platform: 'linux-arm64',
unpacked_dir: 'dist/linux-arm64-unpacked'
})
])
expect(smoke.if).toContain("matrix.platform == 'linux-x64'")
expect(smoke.with.command).toContain('run-linux-packaged-node-pty-floor-smoke.mjs')
expect(smoke.with.command).toContain('${{ matrix.unpacked_dir }}')
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 388 KiB

@@ -0,0 +1,97 @@
# Administer agent skill sharing
This guide describes the first-release access, lifecycle, retention, and recovery contract for
Orca skill sharing. The operator runbook remains the source of truth for incident commands and
environment-specific procedures.
## Access model
- Shared bundles are unlisted bearer resources. Orca provides no public browse, search, recipient
inventory, or package index.
- Anyone with an active, unexpired link can inspect the package and request a short-lived download
grant without signing in.
- Publishing, package/version management, owned-link inventory, revocation, and deletion require
an authenticated package owner with current organization access.
- Missing, expired, revoked, deleted, and unauthorized resources return the same non-disclosing
response.
- Desktop and remote runtimes receive no GCP identity or long-lived storage credential.
The durable share ID is a credential. Do not put it in tickets, logs, analytics, or support
bundles. Use revocation if a link may have reached an unintended recipient.
## Revocation and deletion
Revoking a share immediately blocks new resolution and download grants. A generation-bound grant
issued before revocation can work until its five-minute expiry. Already installed skills remain on
recipient machines.
Package deletion follows this order:
1. Mark the package deleted and revoke its active shares.
2. Dereference retained versions transactionally.
3. Delete only an object generation that no retained version references.
4. Reconcile bounded pending deletions after partial database or GCS failures.
A version cannot be deleted while an active pinned share references it. Deletion uses the exact
recorded GCS generation and never overwrites an immutable published key.
## User and organization departure
Packages belong to an owner tenant and record the publishing user. In an organization tenant,
another current member can manage the package after its publisher leaves; Orca does not rewrite
the recorded creator. Removing a user does not automatically revoke the organization's links,
delete packages, or remove installed copies.
Before deleting an organization tenant:
1. Disable new grants for the tenant.
2. Have an authorized operator inventory and revoke active shares.
3. Decide whether packages transfer to another authorized owner, remain retained, or are deleted.
4. Resolve legal hold, erasure, and audit-retention requirements.
5. Apply the coordinated metadata and object lifecycle; do not bypass reference checks.
The product does not yet encode a universal ownership-transfer or legal-retention policy. Privacy,
security, and the organization owner must approve the applicable policy before external rollout.
Until that decision is recorded, preserve metadata and soft-deleted generations rather than
guessing.
## Retention contract
| Data | Default behavior |
| -------------------------------------- | --------------------------------------------------------- |
| Upload policy and pending upload row | Expires after 15 minutes |
| Abandoned `uploads/` quarantine object | Deleted by GCS after one day |
| Published immutable package object | No age-based deletion; retained while referenced |
| Issued download grant | Expires after five minutes |
| Deleted package object | Recoverable through GCS soft delete for seven days |
| PostgreSQL metadata | Covered by backups and seven-day point-in-time recovery |
| Installed recipient copy | Independent local data; Cloud deletion does not remove it |
| Audit event | Follows the approved audit-retention policy |
Organization retention, legal hold, and erasure requirements take precedence over product rollback
retention. Product deletion is not a legal-hold mechanism.
## Audit and privacy
Audit records may include package/version IDs, actor IDs, event category, outcome, and timestamp.
They must not include skill contents, filenames, manifests, organization membership lists, local
paths, durable share URLs, upload policies, download grants, or credentials. Anonymous abuse
controls must not persist raw requester IP addresses.
Normal Cloud logs are limited to route, method, status, duration, and bounded failure categories.
Use seeded privacy canaries when validating staging logs and diagnostic exports.
## Recovery and incident controls
Upload, download, and remote-install operations have independent kill switches. Disable the
narrowest affected operation; existing local discovery and installs continue to work.
Coordinate PostgreSQL point-in-time recovery with GCS generation recovery. Restore metadata into
an isolated database, identify exact referenced generations, restore only matching soft-deleted
objects, verify archive and package identities, then transactionally repoint metadata. Keep grants
disabled until bearer preview and a generation-bound download pass.
See the Orca Cloud `docs/skill-sharing-runbook.md` for deployment controls, reconciliation,
saturation, signing failures, database outages, and the guarded restore workflow. Security
invariants and unresolved release gates are recorded in
[Agent skill sharing threat model](./agent-skill-sharing-threat-model.md).
@@ -0,0 +1,38 @@
# Agent skill provider paths
Last verified: 2026-08-11.
V1 supports only providers whose paths are independently established by official documentation.
The registry is deliberately small; it is not copied or synchronized from a community path table.
| Provider | Detection | Global canonical support | Workspace support | Orca placement |
| --- | --- | --- | --- | --- |
| Codex | `codex` CLI found through Orca's host-owned PATH detection | Reads `$HOME/.agents/skills` directly | Reads `.agents/skills` from the current directory through the repository root | Canonical copy only |
| Claude Code | `claude` CLI found through Orca's host-owned PATH detection | Reads `$HOME/.claude/skills` | Reads `.claude/skills` from the launch directory through the repository root, plus nested directories as files are accessed | Relative directory symlink on POSIX, directory junction on Windows, or verified independent-copy fallback |
Codex locations and symlink behavior are documented in the official OpenAI documentation:
[Build skills](https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills).
Claude Code locations, precedence, parent traversal, live detection, and symlink behavior are
documented in the official Anthropic documentation:
[Extend Claude with skills](https://code.claude.com/docs/en/skills#where-skills-live).
Codex therefore needs no provider-specific placement. Claude Code does not document
`.agents/skills` as a discovery root, so Orca reconciles its documented `.claude/skills` path back
to the canonical copy. Orca never replaces a path it does not own. If alias creation is unavailable,
the verified copy fallback is tracked in the install receipt so update and removal can detect drift.
## Registry change process
Every registry change requires normal code review and all of the following evidence:
1. Link current official provider documentation for global and workspace paths.
2. Record whether the provider reads `.agents/skills` directly and its documented link behavior.
3. Verify global and folder-workspace discovery on macOS, Linux, native Windows, and WSL where the
provider supports those platforms.
4. Exercise local, paired-runtime, and SSH host-owned path resolution.
5. Test alias denial, broken owned aliases, independent-copy drift, update, rollback, and removal.
6. Update mixed-version capability evidence if the placement contract changes.
Do not add automated upstream path-table synchronization. A provider release that changes discovery
semantics must enter through this review process.
@@ -0,0 +1,101 @@
# Agent skill sharing threat model
Status: implementation baseline for security and privacy review. This document does not constitute
security approval.
## Scope and trust model
This model covers private skill packaging, Orca Cloud publication and authorization, durable share
resolution, local and remote installation, provider placement, update, rollback, removal, and
operator recovery. It applies to macOS, Linux, native Windows, WSL, paired Orca runtimes, and SSH
targets.
Private means unlisted: an unpredictable active share ID is a bearer credential, while publication
and management remain access-controlled to authenticated Orca users and organizations. V1 is not
end-to-end encrypted from Orca Cloud operators. A skill is code from its author: `SKILL.md` can
change agent behavior and packaged scripts may be executed later by a user or agent, although the
installer itself never executes package content.
## Protected assets
- Skill contents, filenames, manifests, package metadata, release notes, and author identity.
- Organization membership, selected-user ACLs, durable share identifiers, and package existence.
- Signed upload policies, download grants, authentication tokens, database credentials, and GCP
service identities.
- Existing local skills, provider configuration, user modifications, provenance, and transaction
recovery state.
- Cloud package metadata, immutable object generations, audit records, and deletion state.
- Availability and cost of Cloud Run, Cloud SQL, GCS, connected runtimes, and local filesystems.
## Actors and boundaries
Actors include an authorized publisher, an authorized recipient, an authenticated but unauthorized
Orca user, a malicious skill author, a compromised renderer, an untrusted remote RPC caller, an
attacker controlling a network endpoint, and an operator with GCP or database access.
Trust boundaries are:
1. Source skill directory to the owner-private package staging directory.
2. Desktop renderer to the main process and its authenticated Cloud client.
3. Orca client to a paired runtime or SSH host across independently versioned protocols.
4. Orca Cloud API authorization to short-lived GCS access.
5. GCS quarantine to validated immutable publication and PostgreSQL metadata.
6. Extracted staging to canonical destination and provider placements.
7. Local diagnostic records to a user-reviewed support-bundle upload.
8. Terraform and deployment identities to staging and production resources.
## Security invariants
- Package identity is deterministic and binds normalized paths, exact bytes, executable state, and
immutable package/version IDs.
- Publication and installation accept only the `manifest.json` plus `skill/` envelope and never
execute archive content.
- Archive validation completes before destination mutation.
- Destination paths are resolved by the runtime that owns the host or workspace.
- Unowned or modified local content is never silently replaced or deleted.
- Final GCS objects are immutable, generation-fenced, and reachable only through fresh ACL checks
followed by short-lived grants.
- No remote caller can turn a desktop-local path into remote filesystem authority.
- Interrupted transactions converge to a verified old or requested version.
- Logs and support bundles exclude package contents and private authorization or filesystem data.
- Cloud sharing, downloading, and remote installation have independent kill switches.
## Threat register
| ID | Threat | Required controls and current evidence | Residual release gate |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| TM-01 | Source changes after preview or a link swaps bytes during packaging. | Observe the specific source directory, copy without following links, re-observe staged bytes, compare every identity, and bind preview to the final digest. Source-drift and link/special-file tests cover rejection and cleanup. | Repeat race and permission tests on every supported filesystem. |
| TM-02 | Archive traversal, drive paths, links, devices, duplicate paths, Unicode/case collisions, or decompression/resource exhaustion escape staging. | Streaming parser rejects unsafe entry classes and normalized collisions; compressed, extracted, entry, file, depth, and per-file limits are enforced during parsing and extraction. Boundary, malformed, checksum, and fuzz tests run before destination mutation. | Keep package safety suites required in release CI. |
| TM-03 | A forged manifest lies about names, bytes, executable state, or package identity. | Parse the manifest before trusting entries, require `skill/SKILL.md`, hash extracted bytes independently, recompute package identity, and compare package/version IDs and digest. | Cross-platform identical-byte digest evidence remains required. |
| TM-04 | A client chooses another home, workspace, WSL distro, SSH path, or escapes a destination root. | The executing runtime resolves home and workspace identities, realpath-checks directories, uses platform-native joins, and rejects client-supplied remote paths. `local-file` ingress is trusted in-process only. | Real SSH and mixed-version topology tests remain required. |
| TM-05 | Installation overwrites or deletion removes unowned or locally modified content. | Planner distinguishes missing, unchanged, clean, modified, unowned, external-link, broken-link, and collision states. Provenance lives outside installed content. Replacement requires an explicit conflict decision; removal revalidates ownership and digest. | Complete the remaining real junction, external-link, copy-drift, and permission matrix. |
| TM-06 | A crash between renames or receipt publication loses both old and new versions. | Same-filesystem staging, durable journals, backups, flushed receipt replacement, bounded recovery, and ownership tokens protect every commit boundary. Failure injection covers every journal transition. | Real process termination and disk/antivirus contention tests remain required. |
| TM-07 | A grant leaks through redirects, userinfo, an insecure scheme, DNS/host confusion, or an oversized stream. | Cloud requests reject redirects. Package downloads require configured origins and HTTPS, reject URL credentials and cross-origin redirects, cap redirect count, recheck expiry, stream exact expected bytes, and verify archive/package digests. | Validate approved production origins and exercise malicious network cases in staging. |
| TM-08 | Reuse of an upload ID, wrong tenant, wrong key, stale generation, or quarantine object publishes attacker-selected bytes. | Random tenant-bound upload rows, signed POST conditions, expiry, exact metadata/key/type/size validation, generation-fenced reads, streamed validation, and idempotent finalization fail closed. | Staging GCS integration must cover stale generations and lifecycle deletion. |
| TM-09 | IDOR, stale organization membership, or a guessed identifier exposes package management data or bearer-protected content. | Management requests authenticate and re-evaluate current ownership. Recipient preview and grants require the exact unpredictable, active bearer share ID and ignore legacy ACL rows. Not-found responses hide unauthorized existence. | Human authorization review and organization-departure policy approval remain required. |
| TM-10 | Content-addressed deduplication discloses another tenant's package through response shape or timing. | Tenant-scoped APIs never expose GCS keys, generations, or whether an object already existed; existing-object reuse verifies both archive and logical package identity. Cross-tenant tests prove identical archives use separate tenant-hashed objects and response shapes disclose no reuse. | Keep tenant-isolation and response-disclosure coverage required in release CI. |
| TM-11 | A compromised renderer, old client, or arbitrary RPC caller sends credentials, local paths, unknown opcodes, or unsupported operations to a host. | Main owns auth tokens and grants; remote requests use strict schemas and capabilities; package transfer is separate from install; no stream opcode was added; mixed versions fail with update-required results. | Real client-newer/server-newer and SSH parity gates remain required. |
| TM-12 | Transfer replay, overlap, disconnect, or abandoned staging consumes disk or commits different bytes. | Session count, idle time, total bytes, and chunk size are bounded. Offsets are monotonic, identical retry is idempotent, changed replay fails, commit hashes the exact staged file, and cancellation/disconnect cleanup is bounded. | Exercise offline-GCS and real disconnects at every transfer boundary. |
| TM-13 | Provider aliases or junctions escape canonical storage or point at external content. | POSIX aliases are relative from real parents, Windows junctions use absolute canonical targets, targets are revalidated, and unowned/broken/external links are conflicts. Copy fallback is independently hashed and receipt-owned. Real Windows and WSL coverage includes existing, broken, external, denied, and drifted placement behavior. | Preserve the physical placement matrix in release coverage. |
| TM-14 | Skill instructions or scripts are mistaken for trusted Orca code or executed during install. | Share/install previews identify author, organization, scripts, executable files, digest, and version. Installation never runs scripts. Trust copy says the package is code from its author. | Security and design must approve the final trust wording and accessibility behavior. |
| TM-15 | Telemetry, logs, or support bundles leak instructions, filenames, paths, ACLs, grants, policies, or credentials. | Desktop install diagnostics map values to bounded categories before tracing. Deployed staging logs contain route templates and bounded request metadata only, and the logging exclusion removes bearer URLs. Support-bundle tests inject private canaries and prove they are absent from collected output. | Preserve deployed-log and support-bundle privacy checks for future changes. |
| TM-16 | Permissive staging permissions expose package bytes to another local user. | Package archives, downloads, extraction, relayed uploads, locks, journals, and receipts use owner-private modes on POSIX; Windows uses owner-profile paths and inherited ACLs. Existing POSIX download roots are tightened before use. Real Windows, WSL, Ubuntu-floor, and SSH validation passed. | Preserve owner-private staging checks across supported hosts. |
| TM-17 | Deletion, revocation, retention, or user departure leaves unauthorized grants or unrecoverable metadata/blob divergence. | Revocation blocks new grants immediately; existing grants expire within five minutes. Database references govern final deletion, quarantine lifecycle cleans abandonment, and GCS soft delete supplies recovery. Local installs remain independent. | Approve departure/legal-retention policy and exercise coordinated database/GCS recovery. |
| TM-18 | Broad IAM, public bucket access, service-account keys, or a compromised deployment identity bypasses application authorization. | Uniform bucket access, public-access prevention, bucket-scoped object access, service-account-scoped signing, skill-secret-only access, Cloud SQL client role, no desktop IAM, and no long-lived keys are Terraform-defined. | Review the staging and production plans and verify live IAM before rollout. |
| TM-19 | Unbounded validation or request concurrency causes memory, CPU, database, storage, or egress denial of service. | Fixed streaming buffers, package limits, per-instance finalization semaphore, rate/quota limits, bounded transfer sessions, Cloud Run instance limits, lifecycle cleanup, and independent kill switches constrain work. | Complete load testing, dashboards, alerts, and budget thresholds. |
| TM-20 | Operator recovery, diagnostics, or legal workflows bypass tenant isolation or leak content. | Runbooks require generation-specific recovery, coordinated PostgreSQL/GCS restoration, audited lifecycle actions, and no package contents in normal logs. | Security/privacy approval and restricted break-glass procedure remain required. |
## Required review evidence
Security and privacy approval must not rely on this document alone. Reviewers need:
- Package/admission schemas and stable failure categories.
- Archive parser, extraction containment, transaction, provenance, and removal tests.
- Cloud authorization, object-generation, tenant-isolation, deletion, and recovery tests.
- Terraform plans plus live staging IAM, bucket, Cloud Run, Secret Manager, and Cloud SQL evidence.
- Real macOS, Linux-floor, Windows, WSL, paired-runtime, mixed-version, and SSH results.
- Captured staging logs, metrics, traces, and support bundles with seeded private canaries absent.
- Load-test results and independently tested upload, download, and remote-install kill switches.
Approval owners record findings and accepted residual risks outside this implementation document.
The external rollout gate remains closed until those findings are resolved or explicitly accepted.
@@ -0,0 +1,43 @@
# Agent skill sharing upstream boundary
Status: proposed for formal engineering and legal review.
Date: 2026-08-11.
## Context
Orca needs private, durable, cross-machine skill sharing with bounded archive ingestion,
host-owned destination resolution, crash-safe transactions, provenance, and mixed-version remote
support. `vercel-labs/skills` exposes a CLI and does not provide the Cloud authorization or local
transaction contract Orca requires.
The behavioral assessment used upstream commit
`c6f69c631292444cc541ac6d91e2226b0ff247da`.
## Decision
Orca implements its package, Cloud, installation, provider-placement, and recovery behavior
independently. The upstream project is a behavioral reference only.
Do not copy or mechanically translate upstream source, tests, fixtures, registry entries, provider
path tables, comments, or documentation. Derive provider paths from official provider
documentation and verify them with real installations. Orca does not depend on the upstream CLI,
npm package, or an unsupported programmatic API.
If a future change proposes incorporating upstream material, stop and review the exact material,
license, attribution, notices, and maintenance implications before implementation or merge.
## Consequences
- Orca owns stability, security, compatibility, and maintenance of this narrower installer.
- There is no automatic upstream synchronization job.
- Similar behavior is acceptable when independently derived from requirements and official
provider contracts; textual or structural copying is not.
- Provider registry changes require normal code review plus official-documentation and real-host
evidence.
- The pull request template requires reviewers to confirm this boundary for relevant changes.
## Review record
Product chose the reference-only approach during planning. Formal engineering and legal reviewers
remain to be named before external rollout.
@@ -0,0 +1,20 @@
# Relay regional placement
Orca selects a Relay region in the Electron main process before requesting a new assignment. The
director publishes an allowlisted region catalog containing only HTTPS cell subdomains of that
director; Orca takes three bounded `/health` latency samples per region and caches the stable choice
for 24 hours. A cached region changes only when the alternative is materially faster.
The assignment request sends only `preferredRegion`. It does not send latency, IP address, country,
pairing data, or credentials. Catalog, probe, and cache failures fall back to an assignment without
a region preference. A rolled-back director that rejects the new field is retried once without
only that field while preserving reconnect behavior.
The selection measures the desktop network path. Folder workspaces and SSH workspaces share the
same local broker and do not run probes on remote hosts. The phone continues to connect to the
exact cell URL in the desktop pairing payload, so its location is not measured independently and
no mobile protocol update is required.
For deterministic local diagnostics, set `ORCA_RELAY_REGION_OVERRIDE` to `us-central1` or
`asia-east2` before launching Orca. The override is not an end-user setting and is not written to
the preference cache.
+88
View File
@@ -0,0 +1,88 @@
# Share and install agent skills
Orca can put one skill or a bundle of skills behind one unlisted, revocable link. Shared bundles
do not appear in search, a catalog, or a public index. Anyone who has an active link can inspect
and install its contents without signing in, so treat the link like a credential.
## Share skills
Publishing and link management require an Orca account in the desktop app.
1. Open **Skills** and choose **Share skills**.
2. Select one or more skills. One link can contain a large collection, such as 30 skills.
3. Review the bundle name, included skills and files, scripts, executable files, digest, account,
and optional release notes.
4. Choose **Publish skill**, **Publish bundle**, or **Publish new version**, then copy the link.
Orca publishes an immutable version. Later changes do not silently alter a link's current bytes;
publish a new version to update the Cloud package.
Use **Settings → Share Skills** to copy or revoke active links. Revocation blocks new previews and
download grants. A grant issued immediately before revocation can remain usable for up to five
minutes, and revocation does not remove copies that recipients already installed.
## Install from a link
Opening an Orca skill link shows a preview before changing any files. You can also open **Skills**,
choose **Install from link**, and paste the URL.
1. Verify the author and organization.
2. Review the version, release notes, included skills, scripts, executable files, and digest.
3. Select all skills or only the ones you want.
4. Choose the destination machine and either global or workspace scope.
5. Review new, unchanged, updated, and conflicting skills, then choose **Install N skills**.
Supported destinations include the local machine, paired Orca runtimes, WSL, and SSH hosts. The
destination runtime resolves its own home and workspace paths, so folder workspaces and remote
filesystems do not borrow paths from the client machine.
Orca keeps one canonical installed copy and places it where supported agents can discover it.
Current provider coverage is documented in
[Agent skill provider paths](./agent-skill-provider-paths.md).
## Conflicts, updates, and rollback
**Keep local** is the default when an existing skill differs. Orca replaces modified content only
after you explicitly choose to discard it.
Open **Skills → Manage installs** to inspect managed skills and their immutable version history.
Installing the latest version performs an update; selecting an older retained version performs a
rollback. Both use the same protected install transaction. If a bundle changes between versions,
Orca updates only the selected skills that still exist in that version.
An interrupted install is recovered on restart. If Orca reports a conflict or partial result,
review the named skill and retry; completed skills do not need to be installed again.
## Remove an installed skill
Use **Skills → Manage installs → Remove**. Orca removes only copies and provider placements that it
owns and can verify. Modified or unowned files are preserved and reported. Discarding modified
content requires a separate explicit confirmation.
Removing a local install does not revoke its share or delete its Cloud package. Likewise,
revoking or deleting Cloud data does not reach into recipients' machines.
## Retention and deletion
- Upload grants expire after 15 minutes.
- Abandoned upload bytes are removed from quarantine after one day.
- Published versions have no automatic age-based deletion.
- Deleting a package revokes its links before unreferenced objects are deleted.
- Deleted GCS objects remain operator-recoverable through a seven-day soft-delete window.
- Installed copies remain until someone removes them on each destination machine.
Organization legal or retention requirements can override normal rollback and deletion timing.
## Trust and privacy
A skill is code from its author. `SKILL.md` can change agent behavior, and included scripts or
executables may run later when a person or agent uses the skill. Orca validates the package and
never executes its contents during installation, but you should install only from people you trust
and review unexpected scripts or executable files.
Orca records bounded operational identifiers and outcomes. Normal logs, telemetry, and support
bundles exclude skill contents, filenames, manifests, local paths, share URLs, upload policies,
download grants, credentials, and access lists.
If a link no longer works, ask its owner for an active link. Missing, expired, revoked, and deleted
links intentionally show the same response so Orca does not disclose private package existence.
+4 -1
View File
@@ -32,6 +32,7 @@ import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
import {
useLastConnectedAt,
usePendingConnectionPath,
useReconnectAttempt
} from '../../../src/transport/client-context-connection-metrics'
import {
@@ -138,6 +139,7 @@ export function HostScreen({
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const pendingConnectionPath = usePendingConnectionPath(hostId)
const clientRef = useRef<RpcClient | null>(null)
const fetchWorktreesInFlightRef = useRef(false)
// Why: useRef, not useMemo — React may discard memoized values, which would silently
@@ -817,7 +819,8 @@ export function HostScreen({
const headerVerdict = classifyConnection({
state: connState,
reconnectAttempts,
lastConnectedAt
lastConnectedAt,
pendingPath: pendingConnectionPath
})
return (
<>
@@ -61,6 +61,7 @@ import {
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import {
useLastConnectedAt,
usePendingConnectionPath,
useReconnectAttempt
} from '../../../../src/transport/client-context-connection-metrics'
import {
@@ -736,6 +737,7 @@ export default function SessionScreen() {
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const pendingConnectionPath = usePendingConnectionPath(hostId)
const forceReconnectHost = useForceReconnect()
const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({
client,
@@ -4149,7 +4151,8 @@ export default function SessionScreen() {
state: connState,
reconnectAttempts,
lastConnectedAt,
endpoint: hostEndpoint
endpoint: hostEndpoint,
pendingPath: pendingConnectionPath
})
const showConnectionRetry =
connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable'
+4 -1
View File
@@ -37,6 +37,7 @@ import type { RpcSuccess } from '../../../src/transport/types'
import { useHostClient } from '../../../src/transport/client-context'
import {
useLastConnectedAt,
usePendingConnectionPath,
useReconnectAttempt
} from '../../../src/transport/client-context-connection-metrics'
import { classifyConnection } from '../../../src/transport/connection-health'
@@ -2073,6 +2074,7 @@ export default function MobileTasksScreen() {
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const pendingConnectionPath = usePendingConnectionPath(hostId)
const clientRef = useRef<RpcClient | null>(null)
const loadGenerationRef = useRef(0)
const taskResumeRef = useRef<TaskResumeState>({})
@@ -8673,7 +8675,8 @@ export default function MobileTasksScreen() {
const headerVerdict = classifyConnection({
state: connState,
reconnectAttempts,
lastConnectedAt
lastConnectedAt,
pendingPath: pendingConnectionPath
})
const emptyLabel =
connState !== 'connected'
+6 -1
View File
@@ -259,6 +259,10 @@ export default function HomeScreen() {
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
[allClients]
)
const hostPendingPaths = useMemo(
() => Object.fromEntries(allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])),
[allClients]
)
const disconnectHostClient = useDisconnectHostClient()
const forgetHostClient = useForgetHostClient()
const forceReconnectHost = useForceReconnect()
@@ -765,7 +769,8 @@ export default function HomeScreen() {
state,
reconnectAttempts: attempts,
lastConnectedAt,
endpoint: item.endpoint
endpoint: item.endpoint,
pendingPath: hostPendingPaths[item.id] ?? null
})
return (
<MobileHostCard
@@ -94,22 +94,23 @@ describe('MobileHostCard', () => {
it('names the relay while the dial is still in flight', async () => {
const lines = await renderCard(undefined, {
state: 'connecting',
verdict: { kind: 'normal', label: 'Connecting…' },
verdict: { kind: 'normal', label: 'Connecting via Relay…' },
path: 'relay'
})
expect(lines).toContain('Connecting…')
expect(lines).toContain(' · Orca Relay')
expect(lines).toContain('Connecting via Relay…')
expect(lines).not.toContain(' · Orca Relay')
})
it('names the relay while a failed direct dial is still retrying', async () => {
const lines = await renderCard(undefined, {
state: 'reconnecting',
verdict: { kind: 'normal', label: 'Reconnecting…' },
verdict: { kind: 'normal', label: 'Connecting via Relay…' },
path: 'relay'
})
expect(lines).toContain(' · Orca Relay')
expect(lines).toContain('Connecting via Relay…')
expect(lines).not.toContain(' · Orca Relay')
})
it('leaves an idle disconnected host unlabelled', async () => {
+1 -6
View File
@@ -25,11 +25,6 @@ export function MobileHostCard(props: {
const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable'
const credentialMissing = props.credentialStatus === 'missing'
const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing
// Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the
// path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is
// named — 'lan' doubles as the unknown-path default, so it would be a guess before connect.
const dialingPath =
['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay'
const isError =
credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
const statusLabel = credentialMissing
@@ -44,7 +39,7 @@ export function MobileHostCard(props: {
: props.verdict
const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo)
const connectionPathLabel =
!credentialMissing && !credentialUnavailable && (connected || dialingPath)
!credentialMissing && !credentialUnavailable && connected
? mobileConnectionPathLabel(props.path)
: null
const discoveryHint =
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useRpcClientContext, type RpcClientContextValue } from './client-context'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
export function useReconnectAttempt(hostId: string | undefined): number {
return useHostMetric(hostId, (context, id) => context.getReconnectAttempt(id), 0)
@@ -9,6 +10,10 @@ export function useLastConnectedAt(hostId: string | undefined): number | null {
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
}
export function usePendingConnectionPath(hostId: string | undefined): MobileConnectionPath | null {
return useHostMetric(hostId, (context, id) => context.getPendingPath(id), null)
}
function useHostMetric<T>(
hostId: string | undefined,
read: (context: RpcClientContextValue, hostId: string) => T,
@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionState } from './types'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
const connectMock = vi.fn()
const loadHostsMock = vi.fn()
@@ -31,12 +32,15 @@ import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
type FakeClient = RpcClient & {
emitState: (state: ConnectionState) => void
emitPendingPath: (path: MobileConnectionPath | null) => void
closeMock: ReturnType<typeof vi.fn>
}
function makeFakeClient(initialState: ConnectionState): FakeClient {
let state = initialState
let pendingPath: MobileConnectionPath | null = null
const listeners = new Set<(state: ConnectionState) => void>()
const pathListeners = new Set<() => void>()
const closeMock = vi.fn()
return {
sendRequest: vi.fn(),
@@ -45,6 +49,12 @@ function makeFakeClient(initialState: ConnectionState): FakeClient {
getState: () => state,
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
getActivePath: () => 'tailscale',
getPendingPath: () => pendingPath,
onConnectionPathChange: (listener: () => void) => {
pathListeners.add(listener)
return () => pathListeners.delete(listener)
},
onStateChange: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
@@ -57,6 +67,12 @@ function makeFakeClient(initialState: ConnectionState): FakeClient {
for (const listener of listeners) {
listener(next)
}
},
emitPendingPath: (next) => {
pendingPath = next
for (const listener of pathListeners) {
listener()
}
}
} as FakeClient
}
@@ -393,6 +409,30 @@ describe('useHostClient', () => {
})
describe('useAllHostClients', () => {
it('rerenders when Relay becomes pending without a transport-state change', async () => {
const client = makeFakeClient('reconnecting')
connectMock.mockReturnValue(client)
loadHostsMock.mockResolvedValue([HOST])
let pendingPath: MobileConnectionPath | null | undefined
let renderer!: ReturnType<typeof create>
function Probe(): null {
pendingPath = useAllHostClients([HOST.id])[0]?.pendingPath
return null
}
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(pendingPath).toBeNull()
act(() => client.emitPendingPath('relay'))
expect(pendingPath).toBe('relay')
act(() => renderer.unmount())
})
it('only opens the requested startup subset', async () => {
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
connectMock.mockReturnValue(makeFakeClient('connected'))
+8 -12
View File
@@ -76,6 +76,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
primedHostsRef.current.delete(hostId)
}
entry?.unsubState()
entry?.unsubConnectionPath()
storeRef.current.delete(hostId)
entry?.client.close()
notifyHostState(hostId, 'disconnected')
@@ -242,6 +243,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
manualDemandRef.current.add(hostId)
if (entry) {
entry.unsubState()
entry.unsubConnectionPath()
entry.client.close()
storeRef.current.delete(hostId)
}
@@ -260,8 +262,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
[openEntry]
)
const { getKnownState, getState, getReconnectAttempt, getLastConnectedAt, getActivePath } =
useMemo(() => createHostClientSelectors(storeRef.current, pendingOpensRef.current), [])
const selectors = useMemo(
() => createHostClientSelectors(storeRef.current, pendingOpensRef.current),
[]
)
const subscribeHostState = useCallback(
(hostId: string, listener: (state: ConnectionState) => void) =>
@@ -318,11 +322,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
refreshHostClient,
forgetHostClient,
disconnectHostClient,
getState,
getKnownState,
getReconnectAttempt,
getLastConnectedAt,
getActivePath,
...selectors,
subscribeHostState,
getAllClients,
subscribeAllHosts,
@@ -337,11 +337,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
refreshHostClient,
forgetHostClient,
disconnectHostClient,
getState,
getKnownState,
getReconnectAttempt,
getLastConnectedAt,
getActivePath,
selectors,
subscribeHostState,
getAllClients,
subscribeAllHosts,
+33 -1
View File
@@ -25,7 +25,8 @@ describe('classifyConnection Tailscale hint', () => {
const verdict = classifyConnection({
...base,
reconnectAttempts: 3,
endpoint: 'ws://100.65.9.106:6768'
endpoint: 'ws://100.65.9.106:6768',
pendingPath: null
})
expect(verdict).toMatchObject({ kind: 'warning', hint: 'check Tailscale' })
})
@@ -69,6 +70,37 @@ describe('classifyConnection Tailscale hint', () => {
})
expect(verdict).toEqual({ kind: 'normal', label: 'Connected' })
})
it('presents an in-flight relay fallback instead of the failed Tailscale path', () => {
const incident = {
...base,
reconnectAttempts: 5,
endpoint: 'ws://100.88.90.25:6768',
pendingPath: 'relay' as const
}
for (const state of ['connecting', 'handshaking', 'reconnecting', 'disconnected'] as const) {
const verdict = classifyConnection({ ...incident, state })
expect(verdict).toEqual({ kind: 'normal', label: 'Connecting via Relay…' })
expect(verdictDisplayLabel(verdict)).not.toContain('Tailscale')
}
})
it('escalates a prolonged relay recovery without reviving the Tailscale hint', () => {
const verdict = classifyConnection({
...base,
reconnectAttempts: 12,
endpoint: 'ws://100.88.90.25:6768',
pendingPath: 'relay'
})
expect(verdict).toEqual({
kind: 'unreachable',
label: "Can't connect via Relay",
reason: 'never-connected'
})
expect(verdictDisplayLabel(verdict)).not.toContain('Tailscale')
})
})
// Issue #10119: every redial re-enters 'connecting', which used to revert an
+14
View File
@@ -1,4 +1,5 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionState } from './types'
// Why: thresholds for escalating connection UX from neutral
@@ -49,6 +50,7 @@ export function classifyConnection(args: {
// Optional pinned host endpoint — enables the Tailscale hint on
// warning/unreachable verdicts. Callers without it get plain labels.
endpoint?: string | null
pendingPath?: MobileConnectionPath | null
nowMs?: number
}): ConnectionVerdict {
const { state, reconnectAttempts, lastConnectedAt } = args
@@ -65,6 +67,18 @@ export function classifyConnection(args: {
return { kind: 'normal', label: 'Connected' }
}
if (args.pendingPath === 'relay') {
if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) {
if (lastConnectedAt == null) {
return { kind: 'unreachable', label: "Can't connect via Relay", reason: 'never-connected' }
}
if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) {
return { kind: 'unreachable', label: "Can't connect via Relay", reason: 'stale' }
}
}
return { kind: 'normal', label: 'Connecting via Relay…' }
}
if (state === 'disconnected') {
return { kind: 'normal', label: 'Disconnected' }
}
@@ -84,7 +84,9 @@ export function createHostClientSelectors(
getLastConnectedAt: (hostId: string): number | null =>
entries.get(hostId)?.client.getLastConnectedAt() ?? null,
getActivePath: (hostId: string): MobileConnectionPath =>
clientActivePath(entries.get(hostId)?.client)
clientActivePath(entries.get(hostId)?.client),
getPendingPath: (hostId: string): MobileConnectionPath | null =>
clientPendingPath(entries.get(hostId)?.client)
}
}
@@ -96,3 +98,8 @@ export function clientActivePath(client: RpcClient | undefined): MobileConnectio
// Why: during migration the pending path is what the user is waiting on.
return logical.getPendingPath?.() ?? logical.getActivePath()
}
export function clientPendingPath(client: RpcClient | undefined): MobileConnectionPath | null {
const logical = client as Partial<StableLogicalRpcClient> | undefined
return logical?.getPendingPath?.() ?? null
}
+12 -1
View File
@@ -4,6 +4,7 @@ import { openHostLogicalClient } from './host-logical-client'
import type { HostClientOpenRegistry } from './host-client-open-registry'
import type { HostOpenRetryScheduler } from './host-open-retry-scheduler'
import type { RpcClient } from './rpc-client'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
export type HostClientStoreEntry = {
@@ -11,6 +12,7 @@ export type HostClientStoreEntry = {
state: ConnectionState
refCount: number
unsubState: () => void
unsubConnectionPath: () => void
}
type HostEntryOpenerState = {
@@ -113,11 +115,20 @@ export async function openHostClientEntry(
current.state = next
state.notifyHostState(hostId, next)
})
const logical = client as Partial<StableLogicalRpcClient>
const unsubConnectionPath =
logical.onConnectionPathChange?.(() => {
const current = state.store.get(hostId)
if (current) {
state.notifyHostState(hostId, current.state)
}
}) ?? (() => {})
const entry: HostClientStoreEntry = {
client,
state: client.getState(),
refCount: state.pendingAcquisitions.get(hostId) ?? 0,
unsubState
unsubState,
unsubConnectionPath
}
state.pendingAcquisitions.delete(hostId)
state.store.set(hostId, entry)
@@ -0,0 +1,45 @@
import type { MobileConnectionPath } from './stable-logical-rpc-client'
export class LogicalClientConnectionPath {
private migration: MobileConnectionPath | null = null
private recovery: MobileConnectionPath | null = null
private readonly listeners = new Set<() => void>()
constructor(private readonly isConnected: () => boolean) {}
pending(): MobileConnectionPath | null {
return this.isConnected() ? null : (this.migration ?? this.recovery)
}
setMigration(path: MobileConnectionPath | null): void {
this.update(() => {
this.migration = path
})
}
clearMigrationAfterConnected(): void {
this.migration = null
}
setRecovery(path: MobileConnectionPath | null): void {
this.update(() => {
this.recovery = path
})
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private update(apply: () => void): void {
const previous = this.pending()
apply()
if (previous === this.pending()) {
return
}
for (const listener of this.listeners) {
listener()
}
}
}
@@ -65,6 +65,7 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi
export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private recoveryPath: MobileConnectionPath | null = null
private generation = 1
constructor(state: ConnectionState, path: MobileConnectionPath) {
@@ -95,8 +96,11 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl
)
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
// This fake migrates instantly, so no dial is ever in flight to name.
getPendingPath = () => null
getPendingPath = () => this.recoveryPath
setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => {
this.recoveryPath = path
})
onConnectionPathChange = vi.fn(() => () => {})
getGeneration = () => this.generation
}
@@ -91,6 +91,24 @@ describe('mobile endpoint supervisor', () => {
supervisor.stop()
})
it('keeps Relay pending through a failed dial cooldown and clears it on stop', async () => {
const logical = new FakeLogicalClient('reconnecting', 'lan')
const deps = dependencies({
openRelay: vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
expect(logical.getPendingPath()).toBe('relay')
await vi.advanceTimersByTimeAsync(249)
expect(logical.getPendingPath()).toBe('relay')
supervisor.stop()
expect(logical.getPendingPath()).toBeNull()
})
it('does not spend a queued relay retry while direct authentication is progressing', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
@@ -146,6 +146,7 @@ export class MobileEndpointSupervisor {
}
this.unsubscribeState = this.logical.onStateChange((state) => {
if (state === 'connected') {
this.logical.setRecoveryPath(null)
this.directGrace.clear()
if (this.logical.getActivePath() !== 'relay') {
void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
@@ -154,12 +155,16 @@ export class MobileEndpointSupervisor {
} else {
// Why: the direct client enters reconnecting after its first failed
// dial and may never publish disconnected while its retry loop lives.
if (this.relayReconnect.needsRecovery(state)) {
this.logical.setRecoveryPath('relay')
}
this.relayReconnect.handleStateFailure(this.logical, state)
}
})
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
// Why: the first direct dial can fail while encrypted relay credentials
// are still loading, before the supervisor subscribes to state changes.
this.logical.setRecoveryPath('relay')
await this.recoverRelay()
} else {
this.directProbe.schedule()
@@ -171,6 +176,9 @@ export class MobileEndpointSupervisor {
const wasForeground = this.foreground
this.foreground = foreground
if (foreground) {
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
this.logical.setRecoveryPath('relay')
}
this.relayReconnect.handleForeground(this.logical, wasForeground)
this.directProbe.schedule(0)
this.directGrace.arm()
@@ -181,6 +189,7 @@ export class MobileEndpointSupervisor {
this.relayReconnect.clear()
this.leaseRotation.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
}
}
@@ -191,11 +200,11 @@ export class MobileEndpointSupervisor {
stop(): void {
this.stopped = true
this.unsubscribeState?.()
this.unsubscribeState = null
this.directProbe.clear()
this.relayReconnect.clear()
this.leaseRotation.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
}
// forceReplacement: dial past the "direct still looks live" guard — a lease
@@ -90,6 +90,8 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
private path: MobileConnectionPath
private recoveryPath: MobileConnectionPath | null = null
private generation = 1
constructor(state: ConnectionState, path: MobileConnectionPath) {
super(state)
@@ -102,10 +104,18 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
throw new Error(`replacement session ${session.getState()}`)
}
this.path = path
this.recoveryPath = null
this.generation += 1
this.publishState('connected')
})
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
getActivePath = () => this.path
getPendingPath = () => this.recoveryPath
setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => {
this.recoveryPath = path
})
onConnectionPathChange = vi.fn(() => () => {})
getGeneration = () => this.generation
}
const relay = {
@@ -21,6 +21,7 @@ export type RpcClientContextValue = {
getReconnectAttempt: (hostId: string) => number
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
getPendingPath: (hostId: string) => MobileConnectionPath | null
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => { hostId: string; client: RpcClient }[]
subscribeAllHosts: (listener: () => void) => () => void
@@ -4,7 +4,8 @@ import type { RpcClient } from './rpc-client'
import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import {
createStableLogicalRpcClient,
LogicalClientCutoverError
LogicalClientCutoverError,
type MobileConnectionPath
} from './stable-logical-rpc-client'
class FakeSession implements RpcClient {
@@ -259,10 +260,13 @@ describe('stable logical RPC client', () => {
const client = createStableLogicalRpcClient(direct, 'lan')
direct.setState('reconnecting')
const states: ConnectionState[] = []
const paths: (MobileConnectionPath | null)[] = []
client.onStateChange((next) => states.push(next))
client.onConnectionPathChange(() => paths.push(client.getPendingPath()))
const migrating = client.migrateTo(replacement, 'relay')
expect(client.getPendingPath()).toBe('relay')
expect(paths).toEqual(['relay'])
replacement.setState('connecting')
replacement.setState('handshaking')
@@ -278,6 +282,26 @@ describe('stable logical RPC client', () => {
expect(client.getActivePath()).toBe('relay')
})
it('publishes recovery-path changes and keeps Relay pending between failed dials', async () => {
const direct = new FakeSession('reconnecting')
const replacement = new FakeSession('connecting')
const client = createStableLogicalRpcClient(direct, 'tailscale')
const paths: (MobileConnectionPath | null)[] = []
client.onConnectionPathChange(() => paths.push(client.getPendingPath()))
client.setRecoveryPath('relay')
const migrating = client.migrateTo(replacement, 'relay')
replacement.setState('disconnected')
await expect(migrating).rejects.toThrow(/disconnected/)
expect(client.getPendingPath()).toBe('relay')
expect(paths).toEqual(['relay'])
client.setRecoveryPath(null)
expect(client.getPendingPath()).toBeNull()
expect(paths).toEqual(['relay', null])
})
it('drops the pending path when the previous session recovers mid-dial', async () => {
const direct = new FakeSession('connected')
const replacement = new FakeSession('connecting')
@@ -6,6 +6,7 @@ import {
} from './migration-dial-state-forwarder'
import { waitForAuthenticated } from './replacement-session-authentication'
import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection'
import { LogicalClientConnectionPath } from './logical-client-connection-path'
export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay'
@@ -47,9 +48,10 @@ export type StableLogicalRpcClient = RpcClient & {
): Promise<void>
suspendActiveSession(): void
getActivePath(): MobileConnectionPath
// Non-null only while a migration dial is publishing its own phases — the path the
// user is waiting on, which the still-bound active path can't name.
// The path the user is waiting on while migration or scheduled recovery is active.
getPendingPath(): MobileConnectionPath | null
setRecoveryPath(path: MobileConnectionPath | null): void
onConnectionPathChange(listener: () => void): () => void
getGeneration(): number
}
@@ -59,7 +61,6 @@ export function createStableLogicalRpcClient(
): StableLogicalRpcClient {
let activeSession = initialSession
let activePath = initialPath
let pendingPath: MobileConnectionPath | null = null
let generation = 1
let closed = false
let suspended = false
@@ -69,6 +70,7 @@ export function createStableLogicalRpcClient(
const pendingRequests = new Set<PendingRequest>()
const stateListeners = new Set<(state: ConnectionState) => void>()
let state = initialSession.getState()
const connectionPath = new LogicalClientConnectionPath(() => state === 'connected')
bindActiveState(initialSession, generation)
@@ -207,7 +209,7 @@ export function createStableLogicalRpcClient(
// (direct dial fails) sits in 'reconnecting' — already amber, so forwarding adds
// nothing, but the user still has no idea relay is what's being tried.
if (suspended || state !== 'connected') {
pendingPath = path
connectionPath.setMigration(path)
}
const forwarder = forwardMigrationDialState({
session: nextSession,
@@ -259,6 +261,7 @@ export function createStableLogicalRpcClient(
}
pendingRequests.clear()
state = nextSession.getState()
connectionPath.clearMigrationAfterConnected()
for (const listener of stateListeners) {
listener(state)
}
@@ -268,7 +271,9 @@ export function createStableLogicalRpcClient(
getActivePath: () => activePath,
// Why: a previous session that recovers mid-dial makes the pending path a lie —
// once we're connected the user is no longer waiting on anything.
getPendingPath: () => (state === 'connected' ? null : pendingPath),
getPendingPath: () => connectionPath.pending(),
setRecoveryPath: (path) => connectionPath.setRecovery(path),
onConnectionPathChange: (listener) => connectionPath.subscribe(listener),
getGeneration: () => generation
}
@@ -276,7 +281,9 @@ export function createStableLogicalRpcClient(
function endDialForwarding(forwarder: MigrationDialStateForwarder, failed: boolean): void {
forwarder.stop()
pendingPath = null
if (failed) {
connectionPath.setMigration(null)
}
// Why: only walk back phases we published ourselves — a 'connected' here came from
// the still-live previous session and outranks the dead dial.
if (failed && forwarder.forwarded() && state !== 'connected') {
+10 -1
View File
@@ -136,10 +136,19 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
pendingPath: MobileConnectionPath | null
}>((hostId) => {
const client = clientsByHostId.get(hostId)
return client
? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }]
? [
{
hostId,
client,
state: ctx.getState(hostId),
path: ctx.getActivePath(hostId),
pendingPath: ctx.getPendingPath(hostId)
}
]
: []
})
}, [ctx, hostIds, tick])
+110
View File
@@ -0,0 +1,110 @@
import Carbon
import Foundation
struct KeyCharacters: Codable {
let unmodified: String?
let shifted: String?
}
struct KeyboardLayoutSnapshot: Codable {
let inputSourceId: String?
let layoutSourceId: String?
let keyCharacters: [String: KeyCharacters]
}
let domKeyCodes: [(String, UInt16)] = [
("KeyA", 0), ("KeyS", 1), ("KeyD", 2), ("KeyF", 3), ("KeyH", 4), ("KeyG", 5),
("KeyZ", 6), ("KeyX", 7), ("KeyC", 8), ("KeyV", 9), ("KeyB", 11), ("KeyQ", 12),
("KeyW", 13), ("KeyE", 14), ("KeyR", 15), ("KeyY", 16), ("KeyT", 17),
("Digit1", 18), ("Digit2", 19), ("Digit3", 20), ("Digit4", 21), ("Digit6", 22),
("Digit5", 23), ("Equal", 24), ("Digit9", 25), ("Digit7", 26), ("Minus", 27),
("Digit8", 28), ("Digit0", 29), ("BracketRight", 30), ("KeyO", 31), ("KeyU", 32),
("BracketLeft", 33), ("KeyI", 34), ("KeyP", 35), ("KeyL", 37), ("KeyJ", 38),
("Quote", 39), ("KeyK", 40), ("Semicolon", 41), ("Backslash", 42), ("Comma", 43),
("Slash", 44), ("KeyN", 45), ("KeyM", 46), ("Period", 47), ("Space", 49),
("Backquote", 50), ("IntlBackslash", 10), ("IntlYen", 93), ("IntlRo", 94)
]
func inputSourceId(_ source: TISInputSource?) -> String? {
guard let source,
let rawId = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else {
return nil
}
return Unmanaged<CFString>.fromOpaque(rawId).takeUnretainedValue() as String
}
func translatedCharacter(
layout: UnsafePointer<UCKeyboardLayout>,
keyCode: UInt16,
modifiers: UInt32
) -> String? {
var deadKeyState: UInt32 = 0
var length = 0
var characters = [UniChar](repeating: 0, count: 8)
let status = UCKeyTranslate(
layout,
keyCode,
UInt16(kUCKeyActionDown),
modifiers,
UInt32(LMGetKbdType()),
OptionBits(kUCKeyTranslateNoDeadKeysMask),
&deadKeyState,
characters.count,
&length,
&characters
)
guard status == noErr, length > 0 else {
return nil
}
return String(utf16CodeUnits: characters, count: length)
}
func readStableSnapshot() -> KeyboardLayoutSnapshot? {
let currentInputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
let currentLayoutSource = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue()
let capturedInputSourceId = inputSourceId(currentInputSource)
let capturedLayoutSourceId = inputSourceId(currentLayoutSource)
var keyCharacters: [String: KeyCharacters] = [:]
if let currentLayoutSource,
let rawData = TISGetInputSourceProperty(
currentLayoutSource,
kTISPropertyUnicodeKeyLayoutData
) {
let data = Unmanaged<CFData>.fromOpaque(rawData).takeUnretainedValue()
if let bytes = CFDataGetBytePtr(data) {
let layout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
for (code, keyCode) in domKeyCodes {
keyCharacters[code] = KeyCharacters(
unmodified: translatedCharacter(layout: layout, keyCode: keyCode, modifiers: 0),
shifted: translatedCharacter(
layout: layout,
keyCode: keyCode,
modifiers: UInt32(shiftKey >> 8)
)
)
}
}
}
let finalInputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
let finalLayoutSource = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue()
guard capturedInputSourceId == inputSourceId(finalInputSource),
capturedLayoutSourceId == inputSourceId(finalLayoutSource) else {
return nil
}
return KeyboardLayoutSnapshot(
inputSourceId: capturedInputSourceId,
layoutSourceId: capturedLayoutSourceId,
keyCharacters: keyCharacters
)
}
for _ in 0..<2 {
if let snapshot = readStableSnapshot() {
let encoded = try JSONEncoder().encode(snapshot)
print(String(decoding: encoded, as: UTF8.self))
exit(0)
}
}
exit(1)
+4 -2
View File
@@ -25,6 +25,7 @@
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
"prepare": "husky",
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts",
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
@@ -55,6 +56,7 @@
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
"build:relay": "node config/scripts/build-relay.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
"build:keyboard-layout-macos": "node config/scripts/build-keyboard-layout-macos.mjs",
"build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs",
"build:native": "node config/scripts/build-native-for-platform.mjs",
"smoke:computer": "node config/scripts/computer-use-smoke.mjs",
@@ -81,8 +83,8 @@
"build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir",
"build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win",
"build:icons": "bash resources/icon-source/generate.sh",
"build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs",
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:keyboard-layout-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs",
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:keyboard-layout-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb",
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless",
"test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
+7 -7
View File
@@ -40,18 +40,18 @@
{
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
"releaseRevision": 36,
"packageDigest": "6eaa0624f45402646d87d9a559f2b3dbbc0efd1d5e56cdfde219671ad62d320e",
"gitTreeSha": "a5a90c9731fbee834eca6d655b39eaa795dd2323",
"releaseRevision": 37,
"packageDigest": "d5648df9c29b479bbbe0dea68f0506b2b0bad0827ce551451fb8f300c113b305",
"gitTreeSha": "ee3a35c76f875ea6ebaf68947af13ba6346b13f2",
"files": [
{
"path": "SKILL.md",
"size": 3913,
"size": 3944,
"executable": false,
"classification": "text",
"exactSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"textNormalizedSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
"exactSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"textNormalizedSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"identitySha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163"
}
]
},
+16
View File
@@ -577,6 +577,22 @@
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
}
]
},
{
"releaseRevision": 37,
"packageDigest": "d5648df9c29b479bbbe0dea68f0506b2b0bad0827ce551451fb8f300c113b305",
"gitTreeSha": "ee3a35c76f875ea6ebaf68947af13ba6346b13f2",
"files": [
{
"path": "SKILL.md",
"size": 3944,
"executable": false,
"classification": "text",
"exactSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"textNormalizedSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
"identitySha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163"
}
]
}
],
"orchestration": [
+36 -3
View File
@@ -2,12 +2,12 @@
name: orca-cli
description: >-
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
terminals, repos, automations, artifacts, worktree comments, and the browser
terminals, repos, automations, artifacts, skill sharing, worktree comments, and the browser
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
"share HTML/Markdown", "public artifact link", or "control the browser inside
"share HTML/Markdown", "public artifact link", "share skills", or "control the browser inside
Orca". Prefer this over raw `git worktree`, ad hoc
PTYs, Playwright, or Computer Use when the task touches Orca-managed state.
Use Computer Use for browser windows, webviews, or desktop UI outside Orca's
@@ -272,6 +272,39 @@ ORCA artifacts delete <id> --json
- `ORCA_CLOUD_AUTH_TOKEN` is a development-only authentication override. Prefer the active
Orca profile's normal PropelAuth session and never expose the token in logs or agent output.
## Skill Sharing
Agents can publish one or more installed skills behind one unlisted link through the
signed-in Orca account. The user must first grant the separate, default-off permission in
Settings → Share Skills ("Allow agents and the Orca CLI to publish skill links"). There is
no CLI or RPC way to grant it. Manual publishing from the reviewed desktop flow remains
available without this agent permission.
```text
ORCA skills installed --json
ORCA skills share --skill <selector> [--skill <selector> ...] --bundle-name <name> --json
```
- `skills installed` returns safe discovery IDs and names. It does not expose local skill
paths in CLI output. Sharing then verifies that each `SKILL.md` declares a portable
lowercase name containing only letters, numbers, and hyphens.
- Each `--skill` must be an exact discovery ID or an unambiguous installed-skill name.
Use IDs when names collide.
- Multiple `--skill` flags create one bundle and one link. `--all` and arbitrary paths are
intentionally unsupported; name every skill the user asked to publish.
- Skill folders can contain scripts, configuration, credentials, or other private files.
Treat the permission as authority, not blanket intent: publish only the explicitly
requested skills and never widen the selection.
- A denied command fails with `agent_skill_sharing_disabled`. Do not retry; ask the user to
enable the switch in the desktop app if they want this action.
- Orca stages one agent-published bundle at a time per host. If another publish is active,
wait for it to finish before retrying `agent_skill_sharing_busy`.
- Run the command in an Orca terminal on the machine that stores the skills. Forwarded WSL,
SSH, and paired-runtime invocations fail before discovery so Orca cannot read from the
wrong filesystem.
- The JSON result contains the unlisted URL and public share/package/version IDs. It never
includes cloud authentication tokens.
## Built-In Browser
The built-in browser is Orca's embedded browser tab surface, scoped to Orca worktrees; it is not Chrome/Safari or desktop app UI.
@@ -343,7 +376,7 @@ Common recoveries:
## Next Action
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, `artifacts list/share`, or built-in browser `snapshot`.
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, `artifacts list/share`, `skills installed/share`, or built-in browser `snapshot`.
## Mobile Emulator (iOS Simulator via serve-sim)
+2 -2
View File
@@ -2,12 +2,12 @@
name: orca-cli
description: >-
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
terminals, repos, automations, artifacts, worktree comments, and the browser
terminals, repos, automations, artifacts, skill sharing, worktree comments, and the browser
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
"share HTML/Markdown", "public artifact link", or "control the browser inside
"share HTML/Markdown", "public artifact link", "share skills", or "control the browser inside
Orca". Prefer this over raw `git worktree`, ad hoc
PTYs, Playwright, or Computer Use when the task touches Orca-managed state.
Use Computer Use for browser windows, webviews, or desktop UI outside Orca's
File diff suppressed because one or more lines are too long
+5
View File
@@ -235,6 +235,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
keys: ['vm recipe doctor'],
load: async () => (await import('./handlers/vm.js')).VM_HANDLERS
},
{
name: 'skill-sharing',
keys: ['skills installed', 'skills share'],
load: async () => (await import('./handlers/skill-sharing.js')).SKILL_SHARING_HANDLERS
},
{
name: 'skills',
keys: ['skills list', 'skills get', 'skills install', 'skills update'],
+267
View File
@@ -0,0 +1,267 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { REPEATED_FLAG_SEPARATOR } from '../args'
import { RuntimeRpcFailureError } from '../runtime-client'
import { SKILL_SHARING_HANDLERS } from './skill-sharing'
const successMeta = { runtimeId: 'runtime-1' }
function context(
call: ReturnType<typeof vi.fn>,
flags = new Map<string, string | boolean>(),
options: { isRemote?: boolean; json?: boolean } = {}
) {
return {
client: { call, isRemote: options.isRemote ?? false },
cwd: '/repo',
flags,
json: options.json ?? false
} as never
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
describe('skill sharing CLI handlers', () => {
it('lists safe installed-skill selectors without local paths', async () => {
const call = vi.fn().mockResolvedValue({
id: 'request-1',
ok: true,
result: {
skills: [
{
id: 'skill-id',
name: 'alpha',
description: 'Alpha skill',
providers: ['codex'],
sourceKind: 'home',
sourceLabel: 'Codex',
rootPath: '/secret/root',
directoryPath: '/secret/root/alpha',
skillFilePath: '/secret/root/alpha/SKILL.md',
installed: true,
updatedAt: null
}
],
sources: [],
scannedAt: 1
},
_meta: successMeta
})
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
await SKILL_SHARING_HANDLERS['skills installed']!(context(call, new Map(), { json: true }))
expect(call).toHaveBeenCalledWith('skills.discover', { cwd: '/repo' })
const output = String(log.mock.calls[0][0])
expect(output).toContain('skill-id')
expect(output).not.toContain('/secret/root')
})
it('denies publishing at preflight before invoking skills.share', async () => {
const call = vi.fn().mockResolvedValue({
id: 'request-1',
ok: true,
result: { settings: { agentSkillSharingEnabled: false } },
_meta: successMeta
})
await expect(
SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', 'alpha'],
['bundle-name', 'team-skills']
])
)
)
).rejects.toMatchObject({ code: 'agent_skill_sharing_disabled' })
expect(call).toHaveBeenCalledExactlyOnceWith('settings.get')
})
it('publishes multiple explicit skills and prints only public output', async () => {
const call = vi
.fn()
.mockResolvedValueOnce({
id: 'settings',
ok: true,
result: { settings: { agentSkillSharingEnabled: true } },
_meta: successMeta
})
.mockResolvedValueOnce({
id: 'share',
ok: true,
result: {
status: 'ok',
value: {
share: { id: 'shr_public', url: 'https://share.onorca.dev/skills/share/shr_public' },
version: {
packageId: 'pkg_public',
versionId: 'ver_public',
name: 'team-skills'
},
selectedSkills: [
{ id: 'alpha-id', name: 'alpha', description: null },
{ id: 'beta-id', name: 'beta', description: null }
]
}
},
_meta: successMeta
})
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
await SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', `alpha${REPEATED_FLAG_SEPARATOR}beta`],
['bundle-name', 'team-skills'],
['release-notes', 'Initial bundle']
]),
{ json: true }
)
)
expect(call).toHaveBeenLastCalledWith(
'skills.share',
{
skillSelectors: ['alpha', 'beta'],
bundleName: 'team-skills',
releaseNotes: 'Initial bundle',
target: { cwd: '/repo' }
},
{ timeoutMs: 600_000 }
)
const output = String(log.mock.calls[0][0])
expect(output).toContain('shr_public')
expect(output).toContain('pkg_public')
expect(output).not.toMatch(/token|secret/i)
})
it('normalizes a human-readable bundle name before publishing', async () => {
const call = vi
.fn()
.mockResolvedValueOnce({
id: 'settings',
ok: true,
result: { settings: { agentSkillSharingEnabled: true } },
_meta: successMeta
})
.mockResolvedValueOnce({
id: 'share',
ok: true,
result: {
status: 'ok',
value: {
share: { id: 'shr_public', url: 'https://share.onorca.dev/skills/share/shr_public' },
version: {
packageId: 'pkg_public',
versionId: 'ver_public',
name: 'team-skills-v2.0'
},
selectedSkills: [{ id: 'alpha-id', name: 'alpha', description: null }]
}
},
_meta: successMeta
})
vi.spyOn(console, 'log').mockImplementation(() => undefined)
await SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', 'alpha'],
['bundle-name', 'Téam Skills -- v2.0']
])
)
)
expect(call).toHaveBeenLastCalledWith(
'skills.share',
{
skillSelectors: ['alpha'],
bundleName: 'team-skills-v2.0',
releaseNotes: '',
target: { cwd: '/repo' }
},
{ timeoutMs: 600_000 }
)
})
it('rejects a bundle name that cannot produce a valid package name', async () => {
const call = vi.fn()
await expect(
SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', 'alpha'],
['bundle-name', '🔥']
])
)
)
).rejects.toMatchObject({
code: 'invalid_argument',
message: '--bundle-name must contain at least one English letter or number.'
})
expect(call).not.toHaveBeenCalled()
})
it.each([
['a forwarded shell', false, '/remote/repo'],
['a paired runtime', true, undefined]
])('rejects %s before any discovery or publish call', async (_label, isRemote, forwardedCwd) => {
if (forwardedCwd) {
vi.stubEnv('ORCA_CLI_CWD', forwardedCwd)
}
const call = vi.fn()
await expect(
SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', 'alpha'],
['bundle-name', 'alpha']
]),
{ isRemote }
)
)
).rejects.toMatchObject({ code: 'invalid_environment' })
expect(call).not.toHaveBeenCalled()
})
it('gives an actionable upgrade error for an older runtime', async () => {
const call = vi
.fn()
.mockResolvedValueOnce({
id: 'settings',
ok: true,
result: { settings: {} },
_meta: successMeta
})
.mockRejectedValueOnce(
new RuntimeRpcFailureError({
id: 'share',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: successMeta
})
)
await expect(
SKILL_SHARING_HANDLERS['skills share']!(
context(
call,
new Map([
['skill', 'alpha'],
['bundle-name', 'alpha']
])
)
)
).rejects.toMatchObject({ code: 'update_required' })
})
})
+185
View File
@@ -0,0 +1,185 @@
import type {
AgentSkillShareOperation,
AgentSkillShareResult
} from '../../shared/agent-skill-sharing-contract'
import {
AGENT_SKILL_SHARING_DISABLED_CODE,
AGENT_SKILL_SHARING_DISABLED_MESSAGE,
AGENT_SKILL_SHARING_DISABLED_NEXT_STEPS
} from '../../shared/agent-skill-sharing-gate'
import { normalizeSkillBundleName } from '../../shared/skill-bundle-name'
import type { SkillCloudOperation } from '../../shared/skill-cloud-contract'
import type { SkillDiscoveryResult } from '../../shared/skills'
import type { CommandHandler, HandlerContext } from '../dispatch'
import { getRepeatedStringFlag } from '../flags'
import { printResult } from '../format'
import {
RuntimeClientError,
RuntimeRpcFailureError,
type RuntimeRpcSuccess
} from '../runtime-client'
const SHARE_TIMEOUT_MS = 10 * 60_000
type InstalledSkillSummary = Pick<
SkillDiscoveryResult['skills'][number],
'id' | 'name' | 'description' | 'providers' | 'sourceKind' | 'sourceLabel'
>
type SharedSkillSummary = {
url: string
shareId: string
packageId: string
versionId: string
bundleName: string
skills: AgentSkillShareResult['selectedSkills']
}
function stringFlag(ctx: HandlerContext, name: string): string | undefined {
const value = ctx.flags.get(name)
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function rejectForwardedSkillFilesystem(ctx: HandlerContext, command: string): void {
if (!process.env.ORCA_CLI_CWD && !ctx.client.isRemote) {
return
}
throw new RuntimeClientError(
'invalid_environment',
`orca skills ${command} must run on the machine whose installed skills you want to use. Run the command from an Orca terminal on that machine.`
)
}
async function preflightPublishCapability(ctx: HandlerContext): Promise<void> {
let enabled: unknown
try {
const response = await ctx.client.call<{
settings?: { agentSkillSharingEnabled?: boolean }
}>('settings.get')
enabled = response.result?.settings?.agentSkillSharingEnabled
} catch {
return
}
if (enabled === false) {
throw new RuntimeClientError(
AGENT_SKILL_SHARING_DISABLED_CODE,
AGENT_SKILL_SHARING_DISABLED_MESSAGE,
{ nextSteps: [...AGENT_SKILL_SHARING_DISABLED_NEXT_STEPS] }
)
}
}
function requireCloudOperation<T>(operation: SkillCloudOperation<T>): T {
if (operation.status === 'ok') {
return operation.value
}
if (operation.status === 'reconnect-required') {
throw new RuntimeClientError('authentication_required', 'Sign in to Orca and try again.')
}
throw new RuntimeClientError('authentication_unconfigured', operation.message)
}
function installedSummary(result: SkillDiscoveryResult): InstalledSkillSummary[] {
return result.skills.map(({ id, name, description, providers, sourceKind, sourceLabel }) => ({
id,
name,
description,
providers,
sourceKind,
sourceLabel
}))
}
function formatInstalledSkills(skills: InstalledSkillSummary[]): string {
if (skills.length === 0) {
return 'No installed skills found.'
}
return skills
.map(
(skill) =>
`${skill.name} (${skill.id})\n ${skill.description ?? 'No description'}\n ${skill.sourceLabel}`
)
.join('\n')
}
function sharedSummary(result: AgentSkillShareResult): SharedSkillSummary {
return {
url: result.share.url,
shareId: result.share.id,
packageId: result.version.packageId,
versionId: result.version.versionId,
bundleName: result.version.name,
skills: result.selectedSkills
}
}
function formatSharedSkill(result: SharedSkillSummary): string {
return `Shared ${result.skills.length} skill${result.skills.length === 1 ? '' : 's'}: ${result.url}`
}
async function callShare(
ctx: HandlerContext,
params: {
skillSelectors: string[]
bundleName: string
releaseNotes: string
target: { cwd: string }
}
): Promise<RuntimeRpcSuccess<AgentSkillShareOperation>> {
try {
return await ctx.client.call<AgentSkillShareOperation>('skills.share', params, {
timeoutMs: SHARE_TIMEOUT_MS
})
} catch (error) {
if (error instanceof RuntimeRpcFailureError && error.code === 'method_not_found') {
throw new RuntimeClientError(
'update_required',
'The connected Orca runtime does not support agent skill sharing yet. Update Orca on that machine and try again.'
)
}
throw error
}
}
export const SKILL_SHARING_HANDLERS: Record<string, CommandHandler> = {
'skills installed': async (ctx) => {
rejectForwardedSkillFilesystem(ctx, 'installed')
const response = await ctx.client.call<SkillDiscoveryResult>('skills.discover', {
cwd: ctx.cwd
})
const skills = installedSummary(response.result)
printResult({ ...response, result: { skills } }, ctx.json, (value) =>
formatInstalledSkills(value.skills)
)
},
'skills share': async (ctx) => {
rejectForwardedSkillFilesystem(ctx, 'share')
const skillSelectors = getRepeatedStringFlag(ctx.flags, 'skill')
if (skillSelectors.length === 0) {
throw new RuntimeClientError(
'invalid_argument',
'Select at least one installed skill with --skill. Run `orca skills installed` to list them.'
)
}
const bundleLabel = stringFlag(ctx, 'bundle-name')
if (!bundleLabel) {
throw new RuntimeClientError('invalid_argument', 'Missing required --bundle-name.')
}
const bundleName = normalizeSkillBundleName(bundleLabel)
if (!bundleName) {
throw new RuntimeClientError(
'invalid_argument',
'--bundle-name must contain at least one English letter or number.'
)
}
await preflightPublishCapability(ctx)
const response = await callShare(ctx, {
skillSelectors,
bundleName,
releaseNotes: stringFlag(ctx, 'release-notes') ?? '',
target: { cwd: ctx.cwd }
})
const value = sharedSummary(requireCloudOperation(response.result))
printResult({ ...response, result: value }, ctx.json, formatSharedSkill)
}
}
+2
View File
@@ -23,6 +23,8 @@ Accounts:
account list List managed Claude and Codex accounts on this Orca host
Skills:
skills installed List installed skill selectors
skills share Publish selected skills behind one unlisted link
skills list List version-matched skill guides bundled with this Orca CLI
skills get Print a version-matched skill guide as Markdown
skills install Install bundled Orca skills globally via the community skills CLI
@@ -21,6 +21,7 @@ import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SKILL_INSTALL_RESULT_V2_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from '../../shared/protocol-version'
@@ -67,6 +68,7 @@ describe('CLI remote WebSocket transport', () => {
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SKILL_INSTALL_RESULT_V2_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
+2 -2
View File
@@ -214,7 +214,7 @@ describe('orca skills CLI', () => {
'Usage: orca skills get <topic> [--full] [--json]'
)
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
'Commands:\n list List version-matched skill guides'
'Commands:\n installed List installed skill selectors'
)
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
'get Print a version-matched skill guide'
@@ -225,7 +225,7 @@ describe('orca skills CLI', () => {
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
'update Update already-installed Orca skills'
)
expect(String(logSpy.mock.calls[2]?.[0])).toContain('Skills:\n skills list')
expect(String(logSpy.mock.calls[2]?.[0])).toContain('Skills:\n skills installed')
expect(String(logSpy.mock.calls[2]?.[0])).toContain('skills update')
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { effectiveAllowedFlags } from '../args'
import { formatCommandHelp } from '../help'
import { SKILL_COMMAND_SPECS } from './skills'
function spec(path: string): (typeof SKILL_COMMAND_SPECS)[number] {
const found = SKILL_COMMAND_SPECS.find((entry) => entry.path.join(' ') === path)
if (!found) {
throw new Error(`Missing skill spec: ${path}`)
}
return found
}
describe('skill command specs', () => {
it('requires explicit selectors for sharing and exposes no bulk or path flag', () => {
const flags = effectiveAllowedFlags(spec('skills share'))
expect(flags).toContain('skill')
expect(flags).toContain('bundle-name')
expect(flags).not.toContain('all')
expect(flags).not.toContain('path')
expect(formatCommandHelp(spec('skills share'))).toContain(
'Only discovered skill directories can be selected'
)
})
})
+30
View File
@@ -2,6 +2,36 @@ import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const SKILL_COMMAND_SPECS: CommandSpec[] = [
{
path: ['skills', 'installed'],
summary: 'List installed skill selectors',
usage: 'orca skills installed [--json]',
allowedFlags: [...GLOBAL_FLAGS],
notes: [
'Lists discovery IDs and names without reading skill contents into the CLI.',
'Package metadata is validated when the selected skills are shared.',
'Use an exact ID or an unambiguous name with `orca skills share --skill <selector>`.'
]
},
{
path: ['skills', 'share'],
summary: 'Publish explicitly selected installed skills behind one unlisted link',
usage:
'orca skills share --skill <selector> [--skill <selector> ...] --bundle-name <name> ' +
'[--release-notes <text>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'skill', 'bundle-name', 'release-notes'],
notes: [
'Requires the default-off permission in Settings → Share Skills.',
'The bundle name may be human-readable; Orca converts it to a portable lowercase package name.',
'Selectors are exact discovery IDs or unambiguous names from `orca skills installed`.',
'Only discovered skill directories can be selected; arbitrary paths and --all are not supported.',
'The resulting link is unlisted. Anyone with it can inspect and install the bundle.'
],
examples: [
'orca skills share --skill frontend --bundle-name "Frontend Skills"',
'orca skills share --skill frontend --skill testing --bundle-name "Team Toolkit" --json'
]
},
{
path: ['skills', 'list'],
summary: 'List version-matched skill guides bundled with this Orca CLI',
+179
View File
@@ -0,0 +1,179 @@
export const BASH_PROMPT_COMMAND_COMPOSITION_BLOCK = `__orca_normalize_prompt_command_part() {
local __orca_value="$1" __orca_output_name="$2" __orca_character __orca_chunk
local __orca_value_length=\${#1} __orca_suffix_length=0 __orca_backslash_length=0
local __orca_output_length __orca_scan_start
while (( __orca_value_length - __orca_suffix_length >= 1024 )); do
__orca_scan_start=$(( __orca_value_length - __orca_suffix_length - 1024 ))
__orca_chunk="\${__orca_value:__orca_scan_start:1024}"
case "$__orca_chunk" in
*[!$' \\t\\n;']*) break ;;
*) __orca_suffix_length=$(( __orca_suffix_length + 1024 )) ;;
esac
done
while (( __orca_suffix_length < __orca_value_length )); do
__orca_character="\${__orca_value: -__orca_suffix_length - 1:1}"
case "$__orca_character" in
' '|$'\\t'|$'\\n'|';') __orca_suffix_length=$(( __orca_suffix_length + 1 )) ;;
*) break ;;
esac
done
__orca_output_length=$(( \${#__orca_value} - __orca_suffix_length ))
while (( __orca_output_length - __orca_backslash_length >= 1024 )); do
__orca_scan_start=$(( __orca_output_length - __orca_backslash_length - 1024 ))
__orca_chunk="\${__orca_value:__orca_scan_start:1024}"
case "$__orca_chunk" in
*[!\\\\]*) break ;;
*) __orca_backslash_length=$(( __orca_backslash_length + 1024 )) ;;
esac
done
while (( __orca_backslash_length < __orca_output_length )); do
__orca_character="\${__orca_value:__orca_output_length - __orca_backslash_length - 1:1}"
[[ "$__orca_character" == '\\' ]] || break
__orca_backslash_length=$(( __orca_backslash_length + 1 ))
done
# Preserve the first separator when an odd backslash run escapes it.
if (( __orca_suffix_length > 0 && __orca_backslash_length % 2 == 1 )); then
__orca_suffix_length=$(( __orca_suffix_length - 1 ))
__orca_backslash_length=0
fi
__orca_output_length=$(( \${#__orca_value} - __orca_suffix_length ))
__orca_value="\${__orca_value:0:__orca_output_length}"
# Bash 4.4-5.0 scalar prompt evaluation preserves an odd terminal backslash.
if (( __orca_suffix_length == 0 && ((BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] == 0)) && __orca_backslash_length % 2 == 1 )); then
__orca_value="$__orca_value\\\\"
fi
printf -v "$__orca_output_name" '%s' "$__orca_value"
}
__orca_restore_prompt_status() {
return "$1"
}
__orca_update_user_debug_trap() {
local __orca_debug_trap_spec="$1" __orca_unchanged_debug_trap_spec="$2"
local __orca_debug_trap_command
[[ "$__orca_debug_trap_spec" != "$__orca_unchanged_debug_trap_spec" ]] || return 0
[[ "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]] || return 0
if [[ -z "$__orca_debug_trap_spec" ]]; then
__orca_user_debug_trap=""
unset __orca_chained_debug_trap
return 0
fi
__orca_debug_trap_command="\${__orca_debug_trap_spec#trap -- }"
__orca_debug_trap_command="\${__orca_debug_trap_command% DEBUG}"
eval "__orca_user_debug_trap=$__orca_debug_trap_command"
unset __orca_chained_debug_trap
}
__orca_run_user_debug_trap() {
if [[ -n "\${__orca_user_debug_trap:-}" ]]; then
eval "$__orca_user_debug_trap" || true
fi
}
__orca_adopt_outer_debug_trap() {
local __orca_debug_trap_spec="\${__orca_outer_debug_trap_spec:-}"
unset __orca_outer_debug_trap_spec
__orca_update_user_debug_trap "$__orca_debug_trap_spec" "trap -- '__orca_osc133_preexec' DEBUG"
}
__orca_run_prompt_command_array() {
local __orca_exit_code="\${__orca_prompt_status:-$?}" __orca_prompt_part __orca_prompt_index __orca_user_count
local __orca_suffix_part
local __orca_final_prompt_command
local __orca_in_prompt_dispatch=1 __orca_dispatching_user_prompt_command=""
unset __orca_prompt_status
__orca_adopt_outer_debug_trap
trap '__orca_osc133_preexec' DEBUG
for __orca_prompt_part in "\${__orca_prompt_command_prefix[@]+"\${__orca_prompt_command_prefix[@]}"}"; do
if (( __orca_exit_code == 0 )); then
eval "$__orca_prompt_part"
else
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
fi
done
__orca_user_count=0
for __orca_prompt_part in "\${__orca_prompt_command_array[@]+"\${__orca_prompt_command_array[@]}"}"; do
__orca_user_count=$(( __orca_user_count + 1 ))
done
for (( __orca_prompt_index = 0; __orca_prompt_index + 1 < __orca_user_count; __orca_prompt_index++ )); do
__orca_prompt_part="\${__orca_prompt_command_array[__orca_prompt_index]}"
__orca_dispatching_user_prompt_command=1
if (( __orca_exit_code == 0 )); then
eval "$__orca_prompt_part"
else
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
fi
__orca_dispatching_user_prompt_command=""
done
if (( __orca_user_count > 0 )); then
__orca_prompt_part="\${__orca_prompt_command_array[__orca_user_count - 1]}"
# Why: keep the final user hook and Orca suffixes in one status-preserving eval.
__orca_final_prompt_command='eval "$__orca_prompt_part"'
for __orca_suffix_part in "\${__orca_prompt_command_suffix[@]+"\${__orca_prompt_command_suffix[@]}"}"; do
__orca_final_prompt_command+=$'\\n'"$__orca_suffix_part"
done
__orca_dispatching_user_prompt_command=1
if (( __orca_exit_code == 0 )); then
eval "$__orca_final_prompt_command"
else
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_final_prompt_command"
fi
__orca_dispatching_user_prompt_command=""
else
for __orca_prompt_part in "\${__orca_prompt_command_suffix[@]+"\${__orca_prompt_command_suffix[@]}"}"; do
if (( __orca_exit_code == 0 )); then
eval "$__orca_prompt_part"
else
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
fi
done
fi
return "$__orca_exit_code"
}
__orca_finish_legacy_prompt_dispatch() {
local __orca_suffix_part
if [[ -n "\${__orca_in_prompt_command:-}" ]]; then
for __orca_suffix_part in "\${__orca_prompt_command_suffix[@]+"\${__orca_prompt_command_suffix[@]}"}"; do
eval "$__orca_suffix_part"
done
fi
trap '__orca_osc133_preexec' DEBUG
unset __orca_in_legacy_prompt_wrapper
}
__orca_normalize_prompt_command() {
[[ -z "\${__orca_prompt_command_normalized:-}" ]] || return 0
local __orca_prompt_part
local -a __orca_normalized=()
for __orca_prompt_part in "\${PROMPT_COMMAND[@]+"\${PROMPT_COMMAND[@]}"}"; do
__orca_normalize_prompt_command_part "$__orca_prompt_part" __orca_prompt_part
[[ -n "$__orca_prompt_part" ]] && __orca_normalized+=("$__orca_prompt_part")
done
__orca_prompt_command_normalized=1
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
PROMPT_COMMAND=("\${__orca_normalized[@]+"\${__orca_normalized[@]}"}")
else
__orca_prompt_command_array=("\${__orca_normalized[@]+"\${__orca_normalized[@]}"}")
__orca_prompt_command_prefix=()
__orca_prompt_command_suffix=()
unset PROMPT_COMMAND
# Why: PID scope distinguishes legacy prompt dispatch from ordinary user command text.
__orca_prompt_status_variable="__orca_prompt_status_$$"
__orca_prompt_status_capture_command="$__orca_prompt_status_variable=\\$?"
__orca_prompt_status_value="\\\${$__orca_prompt_status_variable}"
PROMPT_COMMAND="$__orca_prompt_status_capture_command; __orca_prompt_status=$__orca_prompt_status_value"'; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace; __orca_run_prompt_command_array; __orca_finish_legacy_prompt_dispatch'
fi
}
__orca_prepend_prompt_command() {
local command="$1"
__orca_normalize_prompt_command
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
PROMPT_COMMAND=("$command" "\${PROMPT_COMMAND[@]+"\${PROMPT_COMMAND[@]}"}")
else
__orca_prompt_command_prefix=("$command" "\${__orca_prompt_command_prefix[@]+"\${__orca_prompt_command_prefix[@]}"}")
fi
}
__orca_append_prompt_command() {
local command="$1"
__orca_normalize_prompt_command
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
PROMPT_COMMAND+=("$command")
else
__orca_prompt_command_suffix+=("$command")
fi
}`
@@ -73,6 +73,9 @@ describe('ClaudeRuntimeAuthService', () => {
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
expect(service.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
ubuntuAuthPath
)
const preparation = await service.prepareForClaudeLaunch()
expect(preparation).toMatchObject({
@@ -151,8 +151,8 @@ export class ClaudeRuntimeAuthService {
})
}
getRuntimeConfigDir(): string {
return this.pathResolver.getRuntimePaths().configDir
getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string {
return this.getPreparation(target).configDir
}
private initializeLastSyncedState(): void {
+4
View File
@@ -148,6 +148,10 @@ export class ClaudeAccountService {
return this.cancelPendingClaudeLogin?.() ?? false
}
getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string {
return this.runtimeAuth.getRuntimeConfigDir(target)
}
private serializeMutation<T>(fn: () => Promise<T>): Promise<T> {
const next = this.mutationQueue.then(fn, fn)
this.mutationQueue = next.catch(() => {})
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearCrashBreadcrumbsForTest,
getCrashBreadcrumbSnapshot,
recordCoalescedCrashBreadcrumb,
recordCrashBreadcrumb
} from './crash-breadcrumb-store'
const COALESCE_WINDOW_MS = 30_000
const MAX_COALESCE_KEYS = 128
const ORPHAN_KEY = 'terminal_safe_fit_retry_exhausted'
function recordOrphanCandidate(livePanes: number): void {
recordCoalescedCrashBreadcrumb({
name: ORPHAN_KEY,
data: { livePanes },
coalesceKey: ORPHAN_KEY,
minIntervalMs: COALESCE_WINDOW_MS
})
}
function resumeOrphanCandidate(livePanes: number): { suppressedSinceLast: number } | undefined {
return recordCoalescedCrashBreadcrumb({
name: ORPHAN_KEY,
data: { livePanes },
coalesceKey: ORPHAN_KEY,
minIntervalMs: COALESCE_WINDOW_MS
})
}
function recordSuppressedBurst(): void {
recordOrphanCandidate(1)
recordOrphanCandidate(2)
recordOrphanCandidate(3)
}
function orphanFromRing(): void {
for (let index = 0; index < 30; index += 1) {
recordCrashBreadcrumb(`renderer_error_${index}`, { index })
}
getCrashBreadcrumbSnapshot()
}
function orphanFromSnapshotBudget(): void {
for (let index = 0; index < 4; index += 1) {
recordCrashBreadcrumb('renderer_memory_highwater', {
rendererSurface: `surface-${index}`,
thresholdPct: 80
})
}
for (let index = 0; index < 29; index += 1) {
recordCrashBreadcrumb(`renderer_error_${index}`, { index })
}
getCrashBreadcrumbSnapshot()
}
function expireWithUnrelatedKey(): void {
vi.advanceTimersByTime(COALESCE_WINDOW_MS + 1)
recordCoalescedCrashBreadcrumb({
name: 'agent_state_changed',
data: { agentType: 'claude', state: 'working' },
coalesceKey: 'agent:claude:working',
minIntervalMs: COALESCE_WINDOW_MS
})
}
function evictWithUnrelatedKeys(): void {
for (let index = 0; index < MAX_COALESCE_KEYS; index += 1) {
recordCoalescedCrashBreadcrumb({
name: 'renderer_error',
data: { message: `error-${index}` },
coalesceKey: `renderer_error:error-${index}`,
minIntervalMs: COALESCE_WINDOW_MS
})
}
}
function expectSuppressedBurstPreserved(): void {
const recovered = getCrashBreadcrumbSnapshot().find(
(breadcrumb) => breadcrumb.name === ORPHAN_KEY && breadcrumb.data?.suppressedSinceLast === 2
)
expect(recovered?.data).toEqual({ livePanes: 3, suppressedSinceLast: 2 })
}
describe('orphaned coalesced breadcrumb cleanup', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-22T12:00:00.000Z'))
})
afterEach(() => {
clearCrashBreadcrumbsForTest()
vi.useRealTimers()
})
it('preserves ring-orphaned repeats through unrelated expiry cleanup', () => {
recordSuppressedBurst()
orphanFromRing()
expireWithUnrelatedKey()
expectSuppressedBurstPreserved()
})
it('preserves data-less repeats through orphan cleanup', () => {
for (let index = 0; index < 3; index += 1) {
recordCoalescedCrashBreadcrumb({
name: ORPHAN_KEY,
coalesceKey: ORPHAN_KEY,
minIntervalMs: COALESCE_WINDOW_MS
})
}
orphanFromRing()
expireWithUnrelatedKey()
const recovered = getCrashBreadcrumbSnapshot().find(
(breadcrumb) => breadcrumb.name === ORPHAN_KEY
)
expect(recovered?.data).toEqual({ suppressedSinceLast: 2 })
})
it('preserves snapshot-budget-orphaned repeats through unrelated expiry cleanup', () => {
recordSuppressedBurst()
orphanFromSnapshotBudget()
expireWithUnrelatedKey()
expectSuppressedBurstPreserved()
})
it('preserves ring-orphaned repeats through LRU cleanup', () => {
recordSuppressedBurst()
orphanFromRing()
evictWithUnrelatedKeys()
expectSuppressedBurstPreserved()
})
it('preserves snapshot-budget-orphaned repeats through LRU cleanup', () => {
recordSuppressedBurst()
orphanFromSnapshotBudget()
evictWithUnrelatedKeys()
expectSuppressedBurstPreserved()
})
it('materializes only repeats not already claimed by an earlier snapshot', () => {
recordOrphanCandidate(1)
recordOrphanCandidate(2)
const firstSnapshot = getCrashBreadcrumbSnapshot()
recordOrphanCandidate(3)
recordOrphanCandidate(4)
orphanFromRing()
expireWithUnrelatedKey()
expect(firstSnapshot[0]?.data).toEqual({ livePanes: 2, suppressedSinceLast: 1 })
const recovered = getCrashBreadcrumbSnapshot().find(
(breadcrumb) => breadcrumb.name === ORPHAN_KEY
)
expect(recovered?.data).toEqual({ livePanes: 4, suppressedSinceLast: 2 })
expect(resumeOrphanCandidate(5)).toEqual({ suppressedSinceLast: 0 })
})
})
@@ -125,6 +125,63 @@ describe('crash breadcrumb store', () => {
vi.useRealTimers()
})
it('expires the coalescing window after a backward wall-clock step', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-20T12:00:00.000Z'))
const hit = (): { suppressedSinceLast: number } | undefined =>
recordCoalescedCrashBreadcrumb({
name: 'agent_state_changed',
coalesceKey: 'agent:claude:working',
minIntervalMs: 30_000
})
hit()
vi.advanceTimersByTime(10_000)
expect(hit()).toBeUndefined()
vi.setSystemTime(new Date('2025-05-20T12:00:00.000Z'))
vi.advanceTimersByTime(20_000)
expect(hit()).toEqual({ suppressedSinceLast: 1 })
expect(getCrashBreadcrumbSnapshot()).toHaveLength(2)
})
it('does not collapse the coalescing window after a forward wall-clock step', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-20T12:00:00.000Z'))
const hit = (): { suppressedSinceLast: number } | undefined =>
recordCoalescedCrashBreadcrumb({
name: 'agent_state_changed',
coalesceKey: 'agent:claude:working',
minIntervalMs: 30_000
})
hit()
vi.advanceTimersByTime(10_000)
vi.setSystemTime(new Date('2027-05-20T12:00:00.000Z'))
expect(hit()).toBeUndefined()
vi.advanceTimersByTime(20_000)
expect(hit()).toEqual({ suppressedSinceLast: 1 })
})
it('folds data-less repeats into the emitted breadcrumb', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-20T12:00:00.000Z'))
recordCoalescedCrashBreadcrumb({
name: 'terminal_safe_fit_retry_exhausted',
coalesceKey: 'terminal_safe_fit_retry_exhausted',
minIntervalMs: 30_000
})
recordCoalescedCrashBreadcrumb({
name: 'terminal_safe_fit_retry_exhausted',
coalesceKey: 'terminal_safe_fit_retry_exhausted',
minIntervalMs: 30_000
})
expect(getCrashBreadcrumbSnapshot()[0]?.data).toEqual({ suppressedSinceLast: 1 })
})
// Windows crash F0BKR84AHEH: two `terminal_safe_fit_retry_exhausted` bursts
// (34 crumbs in 76ms, 34 in 56ms) flushed the pre-crash trail out of a
// 30-entry ring. Every hidden pane is display:none, so it measures 0x0, fails
@@ -315,6 +372,181 @@ describe('crash breadcrumb store', () => {
expect(bursts[1].data).toEqual({ livePanes: 3, suppressedSinceLast: 1 })
})
// A crash report filed mid-window snapshots the ring, which folds the
// suppressed repeats into the emitted crumb. Re-claiming those repeats on
// the next emit would report one burst twice across two crumbs.
it('does not re-claim repeats a snapshot already folded into the crumb', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-22T12:00:00.000Z'))
const hit = (livePanes: number): { suppressedSinceLast: number } | undefined =>
recordCoalescedCrashBreadcrumb({
name: 'terminal_safe_fit_retry_exhausted',
data: { livePanes },
coalesceKey: 'terminal_safe_fit_retry_exhausted',
minIntervalMs: 30_000
})
hit(1)
vi.advanceTimersByTime(10)
hit(2)
getCrashBreadcrumbSnapshot()
vi.advanceTimersByTime(31_000)
const resumed = hit(3)
expect(resumed).toEqual({ suppressedSinceLast: 0 })
const bursts = getCrashBreadcrumbSnapshot().filter(
(entry) => entry.name === 'terminal_safe_fit_retry_exhausted'
)
expect(bursts[0].data).toEqual({ livePanes: 2, suppressedSinceLast: 1 })
expect(bursts[1].data).toEqual({ livePanes: 3 })
// The re-emitted crumb was born claiming nothing, so a fold onto it must
// claim only the new repeat — not the one the first crumb already owns.
vi.advanceTimersByTime(10)
hit(4)
const resolved = getCrashBreadcrumbSnapshot().filter(
(entry) => entry.name === 'terminal_safe_fit_retry_exhausted'
)
expect(resolved[1].data).toEqual({ livePanes: 4, suppressedSinceLast: 1 })
})
// A re-emitted crumb is born already claiming the previous window's count;
// a later fold must add to that claim, not overwrite it away.
it('keeps the carried count when a fold resolves onto a re-emitted crumb', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-22T12:00:00.000Z'))
const hit = (livePanes: number): void => {
recordCoalescedCrashBreadcrumb({
name: 'terminal_safe_fit_retry_exhausted',
data: { livePanes },
coalesceKey: 'terminal_safe_fit_retry_exhausted',
minIntervalMs: 30_000
})
}
hit(1)
vi.advanceTimersByTime(10)
hit(2)
vi.advanceTimersByTime(31_000)
hit(3)
vi.advanceTimersByTime(10)
hit(4)
const bursts = getCrashBreadcrumbSnapshot().filter(
(entry) => entry.name === 'terminal_safe_fit_retry_exhausted'
)
expect(bursts[1].data).toEqual({ livePanes: 4, suppressedSinceLast: 2 })
})
// A crash storm records other breadcrumbs too; if they push the burst crumb
// out of the 30-entry ring mid-window, a snapshot's fold lands in evidence
// no snapshot can see. Marking those repeats resolved anyway would let the
// next emit claim nothing and the burst vanish from the record entirely.
it('re-claims repeats on the next emit when the burst crumb was evicted from the ring', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-22T12:00:00.000Z'))
const hit = (livePanes: number): { suppressedSinceLast: number } | undefined =>
recordCoalescedCrashBreadcrumb({
name: 'terminal_safe_fit_retry_exhausted',
data: { livePanes },
coalesceKey: 'terminal_safe_fit_retry_exhausted',
minIntervalMs: 30_000
})
hit(1)
vi.advanceTimersByTime(10)
hit(2)
hit(3)
for (let index = 0; index < 30; index += 1) {
recordCrashBreadcrumb(`renderer_error_${index}`, { index })
}
getCrashBreadcrumbSnapshot()
vi.advanceTimersByTime(31_000)
const resumed = hit(4)
expect(resumed).toEqual({ suppressedSinceLast: 2 })
const burst = getCrashBreadcrumbSnapshot().find(
(entry) => entry.name === 'terminal_safe_fit_retry_exhausted'
)
expect(burst?.data).toEqual({ livePanes: 4, suppressedSinceLast: 2 })
})
// Retained high-water profiles occupy snapshot slots, so the oldest ring
// entries past that budget are invisible to every future snapshot even
// though they are still in the array; folding there loses the burst too.
it('re-claims repeats when retained profiles push the burst crumb past the snapshot budget', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-22T12:00:00.000Z'))
const hit = (livePanes: number): { suppressedSinceLast: number } | undefined =>
recordCoalescedCrashBreadcrumb({
name: 'terminal_safe_fit_retry_exhausted',
data: { livePanes },
coalesceKey: 'terminal_safe_fit_retry_exhausted',
minIntervalMs: 30_000
})
hit(1)
vi.advanceTimersByTime(10)
hit(2)
hit(3)
for (let index = 0; index < 4; index += 1) {
recordCrashBreadcrumb('renderer_memory_highwater', {
rendererSurface: `surface-${index}`,
thresholdPct: 80
})
}
// Ring stays at 30 (burst crumb still at index 0) but only the newest 26
// ring entries fit a snapshot alongside the 4 retained profiles.
for (let index = 0; index < 29; index += 1) {
recordCrashBreadcrumb(`renderer_error_${index}`, { index })
}
getCrashBreadcrumbSnapshot()
vi.advanceTimersByTime(31_000)
const resumed = hit(4)
expect(resumed).toEqual({ suppressedSinceLast: 2 })
})
// Two crash reports filed inside one window are immutable cumulative views;
// the next window must still start from zero unresolved debt.
it('keeps immutable snapshots cumulative without re-claiming resolved debt', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-22T12:00:00.000Z'))
const hit = (livePanes: number): { suppressedSinceLast: number } | undefined =>
recordCoalescedCrashBreadcrumb({
name: 'terminal_safe_fit_retry_exhausted',
data: { livePanes },
coalesceKey: 'terminal_safe_fit_retry_exhausted',
minIntervalMs: 30_000
})
hit(1)
vi.advanceTimersByTime(10)
hit(2)
hit(3)
const firstSnapshot = getCrashBreadcrumbSnapshot()
vi.advanceTimersByTime(10)
hit(4)
const secondFold = getCrashBreadcrumbSnapshot().find(
(entry) => entry.name === 'terminal_safe_fit_retry_exhausted'
)
expect(firstSnapshot[0]?.data).toEqual({ livePanes: 3, suppressedSinceLast: 2 })
expect(secondFold?.data).toEqual({ livePanes: 4, suppressedSinceLast: 3 })
vi.advanceTimersByTime(31_000)
const resumed = hit(5)
expect(resumed).toEqual({ suppressedSinceLast: 0 })
// A fold onto the fresh crumb must claim only its own window's repeat —
// over-resolving in the first window would push this claim negative.
vi.advanceTimersByTime(10)
hit(6)
const resolved = getCrashBreadcrumbSnapshot().filter(
(entry) => entry.name === 'terminal_safe_fit_retry_exhausted'
)
expect(resolved[1].data).toEqual({ livePanes: 6, suppressedSinceLast: 1 })
})
// A key that ages out loses its only handle on the ring entry it owns, so
// the newest suppressed payload must be folded in before the entry is
// dropped from the map.
@@ -13,9 +13,19 @@ const MAX_RETAINED_BREADCRUMBS = 4
// Bound the coalesce map the same way ProcessGoneDedupe bounds its key map.
const MAX_COALESCE_KEYS = 128
// Why: wall-clock corrections must not stretch or collapse suppression windows.
const monotonicNow = (): number => performance.now()
type CoalescedBreadcrumbState = {
recordedAt: number
/** Name needed to materialize unresolved repeats if the owned crumb is orphaned. */
name: string
windowStartedAtMs: number
suppressed: number
/** Count the crumb was emitted claiming (the previous window's repeats). */
carried: number
/** Of `suppressed`, how many a resolve already folded into the crumb, so a
* later emit never claims the same repeats a snapshot attributed. */
resolved: number
/** Ring entry this key owns, refreshed in place while suppressing. */
emitted?: CrashReportBreadcrumb
/** Newest suppressed payload, sanitized only if a snapshot actually asks for it. */
@@ -82,9 +92,9 @@ export function recordCoalescedCrashBreadcrumb({
coalesceKey: string
minIntervalMs: number
}): { suppressedSinceLast: number } | undefined {
const now = Date.now()
const now = monotonicNow()
const previous = coalescedBreadcrumbs.get(coalesceKey)
if (previous && now - previous.recordedAt < minIntervalMs) {
if (previous && now - previous.windowStartedAtMs < minIntervalMs) {
previous.suppressed += 1
// Stash the newest payload for the entry this key already owns: the burst
// still costs exactly one ring slot, but the retained crumb ends up
@@ -94,14 +104,8 @@ export function recordCoalescedCrashBreadcrumb({
// this coalescing was built to prevent. Sanitizing here would put that cost
// on every suppressed hit of a 1459/min crash loop; the snapshot resolves it
// once instead, on the rare path that actually reads breadcrumbs.
if (previous.emitted) {
previous.pending = data
}
// Re-anchor recency without touching recordedAt: a suppressed key is the
// hottest key in the map, but only the emit path below moves position, so
// a continuously-suppressed key would keep its original slot and be first
// out under high-cardinality churn. recordedAt stays put so the suppression
// window still expires on schedule instead of renewing on every hit.
previous.pending = data
// A hot key stays LRU-recent without renewing its fixed suppression window.
coalescedBreadcrumbs.delete(coalesceKey)
coalescedBreadcrumbs.set(coalesceKey, previous)
return undefined
@@ -112,28 +116,37 @@ export function recordCoalescedCrashBreadcrumb({
// recency so only genuinely idle keys are evicted. Resolve first: an expiring
// key is about to lose its only handle on the ring entry it owns.
for (const [key, entry] of coalescedBreadcrumbs) {
if (now - entry.recordedAt >= minIntervalMs) {
if (now - entry.windowStartedAtMs >= minIntervalMs) {
// Why the key check: this key is about to emit a fresh crumb carrying
// `suppressedSinceLast`, so folding the same events into its old slot
// too would report one burst twice.
if (key !== coalesceKey) {
resolvePendingCoalescedBreadcrumb(entry)
preservePendingCoalescedBreadcrumb(entry)
}
coalescedBreadcrumbs.delete(key)
}
}
coalescedBreadcrumbs.delete(coalesceKey)
const state: CoalescedBreadcrumbState = { recordedAt: now, suppressed: 0 }
// Claim only repeats no resolve has already folded into the previous crumb —
// a snapshot mid-window attributes them there, and forensic totals must not
// count one burst twice.
const suppressedSinceLast = previous ? previous.suppressed - previous.resolved : 0
const state: CoalescedBreadcrumbState = {
name,
windowStartedAtMs: now,
suppressed: 0,
carried: suppressedSinceLast,
resolved: 0
}
coalescedBreadcrumbs.set(coalesceKey, state)
while (coalescedBreadcrumbs.size > MAX_COALESCE_KEYS) {
const oldest = coalescedBreadcrumbs.entries().next()
if (oldest.done) {
break
}
resolvePendingCoalescedBreadcrumb(oldest.value[1])
preservePendingCoalescedBreadcrumb(oldest.value[1])
coalescedBreadcrumbs.delete(oldest.value[0])
}
const suppressedSinceLast = previous?.suppressed ?? 0
state.emitted = recordCrashBreadcrumb(
name,
suppressedSinceLast > 0 ? { ...data, suppressedSinceLast } : data
@@ -141,15 +154,63 @@ export function recordCoalescedCrashBreadcrumb({
return { suppressedSinceLast }
}
/** Whether any future snapshot can still include this crumb. The ring only
* appends and the retained map only grows toward its cap, so an entry pushed
* past the snapshot budget is invisible forever, not just for now. */
function isCoalescedCrumbStillInEvidence(crumb: CrashReportBreadcrumb): boolean {
const visibleFrom = breadcrumbs.length - (MAX_BREADCRUMBS - retainedBreadcrumbs.size)
const index = breadcrumbs.indexOf(crumb)
if (index !== -1 && index >= visibleFrom) {
return true
}
for (const retained of retainedBreadcrumbs.values()) {
if (retained === crumb) {
return true
}
}
return false
}
/** Fold a key's newest suppressed payload into the ring entry it owns. */
function resolvePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): void {
if (!state.pending || !state.emitted) {
// `data` is optional, so the count—not pending payload presence—marks unresolved work.
if (!state.emitted || state.suppressed <= state.resolved) {
return
}
// Eviction can orphan the crumb mid-window; folding into it would mark the
// repeats resolved into evidence no snapshot can see, and the next emit would
// then claim nothing — the burst vanishes from the record entirely. Drop the
// handle (keeping `resolved` for folds that landed while it was live) so a
// later emit or bounded cleanup can materialize the unclaimed repeats.
if (!isCoalescedCrumbStillInEvidence(state.emitted)) {
state.emitted = undefined
return
}
// The crumb's claim is a running total: what it was born claiming plus every
// repeat folded since. Dropping `carried` would erase the previous window's
// count from the record; omitting `resolved` bookkeeping would let the next
// emit claim these repeats a second time.
state.emitted.data = sanitizeCrashReportDetails({
...state.pending,
suppressedSinceLast: state.suppressed
suppressedSinceLast: state.carried + state.suppressed
})
state.resolved = state.suppressed
state.pending = undefined
}
/** Resolve into the owned crumb, or emit the unclaimed repeats before bounded
* cleanup drops an orphan's last accounting state. */
function preservePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): void {
resolvePendingCoalescedBreadcrumb(state)
const unresolved = state.suppressed - state.resolved
if (state.emitted || unresolved <= 0) {
return
}
recordCrashBreadcrumb(state.name, {
...state.pending,
suppressedSinceLast: unresolved
})
state.resolved = state.suppressed
state.pending = undefined
}
@@ -215,4 +215,51 @@ describe('CrashReportStore', () => {
const entries = await fs.readdir(path.dirname(filePath))
expect(entries.filter((entry) => entry.endsWith('.tmp'))).toEqual([])
})
describe('attachDetails', () => {
it('merges late minidump details without dropping the originals', async () => {
const { store } = await createStore()
const report = await store.record(input())
const updated = await store.attachDetails(report.id, {
minidumpStatus: 'captured',
minidumpCheckFile: 'render_frame_impl.cc'
})
expect(updated?.details).toMatchObject({
code: 5,
minidumpStatus: 'captured',
minidumpCheckFile: 'render_frame_impl.cc'
})
expect((await store.getById(report.id))?.details.minidumpCheckFile).toBe(
'render_frame_impl.cc'
)
})
it('sanitizes attached details the same way recorded ones are', async () => {
const { store } = await createStore()
const report = await store.record(input())
const updated = await store.attachDetails(report.id, {
minidumpPath: '/Users/alice/Library/Application Support/Orca/reports/abc.dmp'
})
expect(updated?.details.minidumpPath).toBe('[redacted-path]')
})
it('leaves the report status untouched so the crash prompt still fires', async () => {
const { store } = await createStore()
const report = await store.record(input())
await store.attachDetails(report.id, { minidumpStatus: 'absent' })
expect((await store.getLatestPending())?.id).toBe(report.id)
})
it('returns null for a report that has already been evicted', async () => {
const { store } = await createStore()
expect(await store.attachDetails('missing-id', { minidumpStatus: 'absent' })).toBeNull()
})
})
})
@@ -105,6 +105,31 @@ export class CrashReportStore {
})
}
/**
* Merges late-arriving details into an existing report. Crashpad finishes
* writing the minidump after the process-gone event that created the record,
* so the signature can only be folded in afterwards.
*/
async attachDetails(
id: string,
extraDetails: Record<string, unknown>
): Promise<CrashReportRecord | null> {
return this.withWrite(async (reports) => {
let result: CrashReportRecord | null = null
const nextReports = reports.map((report) => {
if (report.id !== id) {
return report
}
result = {
...report,
details: { ...report.details, ...sanitizeCrashReportDetails(extraDetails) }
}
return result
})
return { reports: nextReports, result }
})
}
async getLatestPending(): Promise<CrashReportRecord | null> {
const reports = await this.readReports()
return reports.find((report) => report.status === 'pending') ?? null
@@ -0,0 +1,264 @@
import { mkdtemp, mkdir, readdir, rm, utimes, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const parseMinidumpCrashSignatureMock = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({
app: { getPath: () => '/unused-in-tests' },
crashReporter: { start: vi.fn() }
}))
vi.mock('./minidump-crash-signature', () => ({
parseMinidumpCrashSignature: parseMinidumpCrashSignatureMock
}))
import {
_pruneCrashpadDumpsForTest,
_setCrashpadCaptureStateForTest,
captureMinidumpSignature,
waitForCrashMinidump
} from './crashpad-capture'
let dumpDir: string
/** Minimal but valid minidump header with a zero-stream directory. */
function emptyDump(): Buffer {
const buf = Buffer.alloc(32)
buf.writeUInt32LE(0x504d444d, 0)
buf.writeUInt32LE(0xa793, 4)
buf.writeUInt32LE(0, 8)
buf.writeUInt32LE(32, 12)
return buf
}
async function writeDump(relativePath: string, mtimeMs: number, contents = emptyDump()) {
const filePath = path.join(dumpDir, relativePath)
await mkdir(path.dirname(filePath), { recursive: true })
await writeFile(filePath, contents)
const seconds = mtimeMs / 1000
await utimes(filePath, seconds, seconds)
return filePath
}
const CRASHED_AT = 1_700_000_000_000
beforeEach(async () => {
parseMinidumpCrashSignatureMock.mockReset()
parseMinidumpCrashSignatureMock.mockReturnValue({ annotations: {} })
dumpDir = await mkdtemp(path.join(os.tmpdir(), 'orca-crashpad-'))
_setCrashpadCaptureStateForTest({ dumpDirectory: dumpDir, started: true })
})
afterEach(async () => {
_setCrashpadCaptureStateForTest(null)
await rm(dumpDir, { recursive: true, force: true })
})
describe('waitForCrashMinidump', () => {
it('finds a dump in the nested reports directory Crashpad writes to', async () => {
const expected = await writeDump(path.join('reports', 'abc.dmp'), CRASHED_AT + 100)
const dump = await waitForCrashMinidump(CRASHED_AT, { now: () => CRASHED_AT })
expect(dump?.filePath).toBe(expected)
})
it('picks the newest dump when several exist', async () => {
await writeDump(path.join('reports', 'old.dmp'), CRASHED_AT - 1_000)
const newest = await writeDump(path.join('reports', 'new.dmp'), CRASHED_AT + 500)
const dump = await waitForCrashMinidump(CRASHED_AT, { now: () => CRASHED_AT })
expect(dump?.filePath).toBe(newest)
})
it('ignores dumps that predate the crash by more than the recency window', async () => {
await writeDump(path.join('reports', 'stale.dmp'), CRASHED_AT - 120_000)
const dump = await waitForCrashMinidump(CRASHED_AT, {
timeoutMs: 0,
now: () => CRASHED_AT
})
expect(dump).toBeNull()
})
it('accepts a dump written just before the crash was observed', async () => {
// Crashpad writes from the handler process, so the dump can land first.
const expected = await writeDump(path.join('reports', 'race.dmp'), CRASHED_AT - 200)
const dump = await waitForCrashMinidump(CRASHED_AT, { now: () => CRASHED_AT })
expect(dump?.filePath).toBe(expected)
})
it('polls until the handler finishes writing, then returns the dump', async () => {
let clock = CRASHED_AT
let written = false
const sleep = async (ms: number) => {
clock += ms
if (!written) {
await writeDump(path.join('reports', 'late.dmp'), CRASHED_AT + 300)
written = true
}
}
const dump = await waitForCrashMinidump(CRASHED_AT, {
timeoutMs: 8_000,
now: () => clock,
sleep
})
expect(dump?.filePath).toBe(path.join(dumpDir, 'reports', 'late.dmp'))
})
it('gives up at the deadline instead of polling forever', async () => {
let clock = CRASHED_AT
const sleep = async (ms: number) => {
clock += ms
}
const dump = await waitForCrashMinidump(CRASHED_AT, {
timeoutMs: 1_000,
now: () => clock,
sleep
})
expect(dump).toBeNull()
expect(clock).toBeLessThanOrEqual(CRASHED_AT + 1_250)
})
it('ignores non-dump files in the directory', async () => {
await writeDump(path.join('reports', 'settings.dat'), CRASHED_AT + 100)
const dump = await waitForCrashMinidump(CRASHED_AT, { timeoutMs: 0, now: () => CRASHED_AT })
expect(dump).toBeNull()
})
it('returns null when capture never started', async () => {
_setCrashpadCaptureStateForTest(null)
expect(await waitForCrashMinidump(CRASHED_AT)).toBeNull()
})
it('returns null when the dump directory does not exist', async () => {
_setCrashpadCaptureStateForTest({
dumpDirectory: path.join(dumpDir, 'missing'),
started: true
})
const dump = await waitForCrashMinidump(CRASHED_AT, { timeoutMs: 0, now: () => CRASHED_AT })
expect(dump).toBeNull()
})
})
describe('captureMinidumpSignature', () => {
it('returns the parsed signature for the paired dump', async () => {
await writeDump(path.join('reports', 'crash.dmp'), CRASHED_AT + 100)
const captured = await captureMinidumpSignature(CRASHED_AT, { now: () => CRASHED_AT })
expect(captured?.signature.annotations).toEqual({})
expect(captured?.sizeBytes).toBe(32)
})
it('returns null when the file on disk is not a minidump', async () => {
await writeDump(path.join('reports', 'garbage.dmp'), CRASHED_AT + 100, Buffer.from('nope'))
parseMinidumpCrashSignatureMock.mockReturnValueOnce(null)
const captured = await captureMinidumpSignature(CRASHED_AT, {
timeoutMs: 0,
now: () => CRASHED_AT
})
expect(captured).toBeNull()
})
it('claims a dump once so another report cannot reuse it', async () => {
const expected = await writeDump(path.join('reports', 'crash.dmp'), CRASHED_AT + 100)
const first = await captureMinidumpSignature(CRASHED_AT, {
timeoutMs: 0,
now: () => CRASHED_AT
})
const second = await captureMinidumpSignature(CRASHED_AT, {
timeoutMs: 0,
now: () => CRASHED_AT
})
expect(first?.filePath).toBe(expected)
expect(second).toBeNull()
})
it('skips a newer dump from the wrong process type', async () => {
const rendererDump = await writeDump(
path.join('reports', 'renderer.dmp'),
CRASHED_AT + 100,
Buffer.from('renderer')
)
await writeDump(path.join('reports', 'gpu.dmp'), CRASHED_AT + 200, Buffer.from('gpu-process'))
parseMinidumpCrashSignatureMock.mockImplementation((dump: Buffer) => ({
processType: dump.toString('utf8'),
annotations: {}
}))
const captured = await captureMinidumpSignature(CRASHED_AT, {
expectedProcessType: 'renderer',
timeoutMs: 0,
now: () => CRASHED_AT
})
expect(captured?.filePath).toBe(rendererDump)
expect(captured?.signature.processType).toBe('renderer')
})
it('leaves a mismatched dump available for its own process report', async () => {
const gpuDump = await writeDump(
path.join('reports', 'gpu.dmp'),
CRASHED_AT + 100,
Buffer.from('gpu-process')
)
parseMinidumpCrashSignatureMock.mockReturnValue({
processType: 'gpu-process',
annotations: {}
})
const renderer = await captureMinidumpSignature(CRASHED_AT, {
expectedProcessType: 'renderer',
timeoutMs: 0,
now: () => CRASHED_AT
})
const gpu = await captureMinidumpSignature(CRASHED_AT, {
expectedProcessType: 'gpu-process',
timeoutMs: 0,
now: () => CRASHED_AT
})
expect(renderer).toBeNull()
expect(gpu?.filePath).toBe(gpuDump)
})
it('returns null rather than throwing when no dump was produced', async () => {
const captured = await captureMinidumpSignature(CRASHED_AT, {
timeoutMs: 0,
now: () => CRASHED_AT
})
expect(captured).toBeNull()
})
})
describe('Crashpad dump pruning', () => {
it('keeps the newest dumps within the byte budget', async () => {
await writeDump(path.join('reports', 'old.dmp'), CRASHED_AT, Buffer.alloc(8))
await writeDump(path.join('reports', 'middle.dmp'), CRASHED_AT + 100, Buffer.alloc(8))
await writeDump(path.join('reports', 'new.dmp'), CRASHED_AT + 200, Buffer.alloc(8))
await _pruneCrashpadDumpsForTest(16)
expect((await readdir(path.join(dumpDir, 'reports'))).sort()).toEqual(['middle.dmp', 'new.dmp'])
})
})
@@ -0,0 +1,289 @@
// Starts Electron's Crashpad handler and pairs a written minidump with the
// `render-process-gone` / `child-process-gone` event that reported the death.
//
// Upload stays off: dumps contain process memory, and the only transport we
// have (observability/diagnostic-bundle-upload) is a user-initiated 4 MiB text
// bundle. We keep dumps on disk and lift the *text* signature out of them, so
// a CHECK failure becomes nameable without shipping raw memory anywhere.
import type { Dirent } from 'node:fs'
import { readdir, readFile, rm, stat } from 'node:fs/promises'
import path from 'node:path'
import { app, crashReporter } from 'electron'
import {
parseMinidumpCrashSignature,
type MinidumpCrashSignature
} from './minidump-crash-signature'
// Why: Crashpad writes the dump from the handler process while Electron
// delivers process-gone on the main thread; the two race. Poll a short window
// rather than sampling once and losing the dump most of the time.
const DUMP_WAIT_TIMEOUT_MS = 8_000
const DUMP_POLL_INTERVAL_MS = 250
// A dump older than this belongs to an earlier crash, not the one we're pairing.
const DUMP_RECENCY_WINDOW_MS = 30_000
// Renderer dumps run ~1-15 MiB; well past that means we mis-picked a file.
const MAX_DUMP_BYTES = 64 * 1024 * 1024
// Match Crashpad's default budget, but enforce it after crashes instead of
// waiting for its first 10-minute and later daily pruning passes.
const MAX_STORED_DUMP_BYTES = 128 * 1024 * 1024
const DUMP_PRUNE_DELAY_MS = 2_000
type DumpCandidate = {
readonly filePath: string
readonly mtimeMs: number
readonly size: number
}
// Why: `app.getPath('crashDumps')` is derived from userData, which shifts when
// app.setName runs at whenReady. Snapshot where Crashpad was actually pointed.
let crashpadDumpDirectory: string | null = null
let captureStarted = false
let captureStartedAtMs: number | null = null
const claimedDumpPaths = new Map<string, number>()
const reservedDumpPaths = new Set<string>()
let dumpPruneTimer: NodeJS.Timeout | null = null
export type CrashpadCaptureOptions = {
/** Overrides Electron's default so tests need no real Crashpad handler. */
readonly dumpDirectory?: string
}
/**
* Must run before `app.whenReady()`. Safe to call twice; the second call is a
* no-op so a re-entrant startup path cannot restart the handler.
*/
export function startCrashpadCapture(options: CrashpadCaptureOptions = {}): boolean {
if (captureStarted) {
return true
}
try {
crashReporter.start({
// Why: no submitURL is configured anywhere, and uploadToServer:true with
// an unset URL makes Crashpad retry against a bogus endpoint forever.
uploadToServer: false,
// Keep the OS handler (WER / Apple crash reporter) in the loop; it costs
// nothing and is the only signal left if Crashpad itself fails to init.
ignoreSystemCrashHandler: false,
compress: false
})
captureStarted = true
captureStartedAtMs = Date.now()
} catch (error) {
console.error('[crash-reporting] Crashpad start failed:', error)
return false
}
crashpadDumpDirectory = options.dumpDirectory ?? resolveDumpDirectory()
return true
}
function resolveDumpDirectory(): string | null {
try {
return app.getPath('crashDumps')
} catch {
return null
}
}
export function getCrashpadDumpDirectory(): string | null {
return crashpadDumpDirectory
}
/** Test seam; production callers go through startCrashpadCapture. */
export function _setCrashpadCaptureStateForTest(
state: { dumpDirectory: string | null; started: boolean; startedAtMs?: number } | null
): void {
crashpadDumpDirectory = state?.dumpDirectory ?? null
captureStarted = state?.started ?? false
captureStartedAtMs = state?.started ? (state.startedAtMs ?? Number.NEGATIVE_INFINITY) : null
claimedDumpPaths.clear()
reservedDumpPaths.clear()
if (dumpPruneTimer) {
clearTimeout(dumpPruneTimer)
dumpPruneTimer = null
}
}
async function collectDumpCandidates(directory: string): Promise<DumpCandidate[]> {
let entries: Dirent[]
try {
entries = await readdir(directory, {
withFileTypes: true,
recursive: true
})
} catch {
return []
}
const candidates: DumpCandidate[] = []
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.dmp')) {
continue
}
// `recursive` yields nested names relative to parentPath, not directory.
const filePath = path.join(entry.parentPath ?? directory, entry.name)
try {
const stats = await stat(filePath)
candidates.push({ filePath, mtimeMs: stats.mtimeMs, size: stats.size })
} catch {
// Crashpad renames dumps as it promotes them; a vanished file is normal.
}
}
return candidates
}
async function pruneCrashpadDumps(maxBytes = MAX_STORED_DUMP_BYTES): Promise<void> {
const directory = crashpadDumpDirectory
if (!directory) {
return
}
const candidates = (await collectDumpCandidates(directory)).sort(
(left, right) => right.mtimeMs - left.mtimeMs
)
let retainedBytes = 0
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index]
const mustKeep = index === 0 || reservedDumpPaths.has(candidate.filePath)
if (mustKeep || retainedBytes + candidate.size <= maxBytes) {
retainedBytes += candidate.size
continue
}
try {
await rm(candidate.filePath, { force: true })
} catch {
// Crashpad can still be promoting a dump; its own later pass will retry.
}
}
}
/** Coalesces crash-burst pruning; there is no timer or directory scan while idle. */
export function scheduleCrashpadDumpPrune(): void {
if (!crashpadDumpDirectory || dumpPruneTimer) {
return
}
dumpPruneTimer = setTimeout(() => {
void pruneCrashpadDumps()
.catch((error) => {
console.error('[crash-reporting] Crashpad dump pruning failed:', error)
})
.finally(() => {
dumpPruneTimer = null
})
}, DUMP_PRUNE_DELAY_MS)
dumpPruneTimer.unref()
}
/** Test seam for byte-budget behavior without a real Crashpad database. */
export async function _pruneCrashpadDumpsForTest(maxBytes: number): Promise<void> {
await pruneCrashpadDumps(maxBytes)
}
type DumpPollingOptions = {
readonly timeoutMs?: number
readonly now?: () => number
readonly sleep?: (ms: number) => Promise<void>
}
export type CrashMinidumpCaptureOptions = DumpPollingOptions & {
readonly expectedProcessType?: string
}
function freshDumpCandidates(candidates: DumpCandidate[], crashedAtMs: number): DumpCandidate[] {
const floorMs = Math.max(
crashedAtMs - DUMP_RECENCY_WINDOW_MS,
captureStartedAtMs ?? Number.NEGATIVE_INFINITY
)
for (const [filePath, mtimeMs] of claimedDumpPaths) {
if (mtimeMs < floorMs) {
claimedDumpPaths.delete(filePath)
}
}
return candidates
.filter((candidate) => candidate.mtimeMs >= floorMs && candidate.size <= MAX_DUMP_BYTES)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
}
async function pollDumpCandidates<T>(
crashedAtMs: number,
options: DumpPollingOptions,
select: (candidate: DumpCandidate) => Promise<T | null>
): Promise<T | null> {
const directory = crashpadDumpDirectory
if (!directory) {
return null
}
const timeoutMs = options.timeoutMs ?? DUMP_WAIT_TIMEOUT_MS
const now = options.now ?? Date.now
const sleep =
options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
const deadline = now() + timeoutMs
for (;;) {
const fresh = freshDumpCandidates(await collectDumpCandidates(directory), crashedAtMs)
for (const candidate of fresh) {
const selected = await select(candidate)
if (selected !== null) {
return selected
}
}
if (now() >= deadline) {
return null
}
await sleep(DUMP_POLL_INTERVAL_MS)
}
}
/**
* Waits for the dump Crashpad writes for a crash observed at `crashedAtMs`.
* Resolves null when capture is off, the handler wrote nothing, or the only
* dumps on disk predate this crash.
*/
export async function waitForCrashMinidump(
crashedAtMs: number,
options: DumpPollingOptions = {}
): Promise<DumpCandidate | null> {
return pollDumpCandidates(crashedAtMs, options, async (candidate) => candidate)
}
export type CapturedMinidump = {
readonly filePath: string
readonly sizeBytes: number
readonly signature: MinidumpCrashSignature
}
/** Finds the dump for a crash and parses its signature. Never throws. */
export async function captureMinidumpSignature(
crashedAtMs: number,
options: CrashMinidumpCaptureOptions = {}
): Promise<CapturedMinidump | null> {
const rejectedDumpPaths = new Set<string>()
try {
return await pollDumpCandidates(crashedAtMs, options, async (dump) => {
if (
rejectedDumpPaths.has(dump.filePath) ||
claimedDumpPaths.has(dump.filePath) ||
reservedDumpPaths.has(dump.filePath)
) {
return null
}
reservedDumpPaths.add(dump.filePath)
try {
const signature = parseMinidumpCrashSignature(await readFile(dump.filePath))
if (
!signature ||
(options.expectedProcessType !== undefined &&
signature.processType !== options.expectedProcessType)
) {
rejectedDumpPaths.add(dump.filePath)
return null
}
claimedDumpPaths.set(dump.filePath, dump.mtimeMs)
return { filePath: dump.filePath, sizeBytes: dump.size, signature }
} finally {
reservedDumpPaths.delete(dump.filePath)
}
})
} catch (error) {
console.error('[crash-reporting] minidump signature capture failed:', error)
return null
}
}
@@ -0,0 +1,372 @@
import { describe, expect, it } from 'vitest'
import { minidumpSignatureDetails, parseMinidumpCrashSignature } from './minidump-crash-signature'
const STREAM_TYPE_MODULE_LIST = 4
const STREAM_TYPE_EXCEPTION = 6
const STREAM_TYPE_CRASHPAD_INFO = 0x43500001
/**
* Builds real Crashpad-layout minidumps so the parser is tested against the
* byte format rather than a mock. Regions are appended and referenced by RVA,
* matching how Crashpad emits them.
*/
class MinidumpBuilder {
private regions: Buffer[] = []
private cursor = 0
constructor(private readonly headerAndDirectoryBytes: number) {
this.cursor = headerAndDirectoryBytes
}
append(buf: Buffer): number {
const rva = this.cursor
this.regions.push(buf)
this.cursor += buf.length
return rva
}
utf8String(value: string): number {
const data = Buffer.from(value, 'utf8')
const buf = Buffer.alloc(4 + data.length + 1)
buf.writeUInt32LE(data.length, 0)
data.copy(buf, 4)
return this.append(buf)
}
utf16String(value: string): number {
const data = Buffer.from(value, 'utf16le')
const buf = Buffer.alloc(4 + data.length + 2)
buf.writeUInt32LE(data.length, 0)
data.copy(buf, 4)
return this.append(buf)
}
byteArray(value: string): number {
const data = Buffer.from(value, 'utf8')
const buf = Buffer.alloc(4 + data.length)
buf.writeUInt32LE(data.length, 0)
data.copy(buf, 4)
return this.append(buf)
}
build(streams: { type: number; size: number; rva: number }[]): Buffer {
const header = Buffer.alloc(32)
header.writeUInt32LE(0x504d444d, 0)
header.writeUInt32LE(0xa793, 4)
header.writeUInt32LE(streams.length, 8)
header.writeUInt32LE(32, 12)
const directory = Buffer.alloc(streams.length * 12)
streams.forEach((stream, index) => {
directory.writeUInt32LE(stream.type, index * 12)
directory.writeUInt32LE(stream.size, index * 12 + 4)
directory.writeUInt32LE(stream.rva, index * 12 + 8)
})
const body = Buffer.concat(this.regions)
const prefix = Buffer.concat([header, directory])
expect(prefix.length).toBe(this.headerAndDirectoryBytes)
return Buffer.concat([prefix, body])
}
}
function location(size: number, rva: number): Buffer {
const buf = Buffer.alloc(8)
buf.writeUInt32LE(size, 0)
buf.writeUInt32LE(rva, 4)
return buf
}
const EMPTY_LOCATION = location(0, 0)
type BuiltDump = { dump: Buffer }
/**
* @param annotations key/value pairs written as MinidumpAnnotation objects
* hanging off a module's crashpad info, which is where Chromium crash keys
* (including LOG_FATAL) actually live.
*/
function buildDump(options: {
annotations?: Record<string, string>
simpleAnnotations?: Record<string, string>
exception?: { code: number; address: bigint }
modules?: { base: bigint; size: number; name: string }[]
}): BuiltDump {
const streamCount =
1 + (options.exception ? 1 : 0) + (options.modules && options.modules.length > 0 ? 1 : 0)
const builder = new MinidumpBuilder(32 + streamCount * 12)
const streams: { type: number; size: number; rva: number }[] = []
// Module-level annotation objects.
const annotationEntries = Object.entries(options.annotations ?? {})
const annotationRecords = annotationEntries.map(([name, value]) => ({
nameRva: builder.utf8String(name),
valueRva: builder.byteArray(value)
}))
const annotationListBuf = Buffer.alloc(4 + annotationRecords.length * 12)
annotationListBuf.writeUInt32LE(annotationRecords.length, 0)
annotationRecords.forEach((record, index) => {
const at = 4 + index * 12
annotationListBuf.writeUInt32LE(record.nameRva, at)
annotationListBuf.writeUInt16LE(1, at + 4) // kString
annotationListBuf.writeUInt16LE(0, at + 6)
annotationListBuf.writeUInt32LE(record.valueRva, at + 8)
})
const annotationListRva = builder.append(annotationListBuf)
// Process-level simple string dictionary.
const simpleEntries = Object.entries(options.simpleAnnotations ?? {})
const simplePairs = simpleEntries.map(([key, value]) => ({
keyRva: builder.utf8String(key),
valueRva: builder.utf8String(value)
}))
const simpleBuf = Buffer.alloc(4 + simplePairs.length * 8)
simpleBuf.writeUInt32LE(simplePairs.length, 0)
simplePairs.forEach((pair, index) => {
simpleBuf.writeUInt32LE(pair.keyRva, 4 + index * 8)
simpleBuf.writeUInt32LE(pair.valueRva, 4 + index * 8 + 4)
})
const simpleRva = builder.append(simpleBuf)
// MinidumpModuleCrashpadInfo (version, list_annotations, simple, objects).
const moduleInfoBuf = Buffer.concat([
(() => {
const v = Buffer.alloc(4)
v.writeUInt32LE(1, 0)
return v
})(),
EMPTY_LOCATION,
EMPTY_LOCATION,
location(annotationListBuf.length, annotationListRva)
])
const moduleInfoRva = builder.append(moduleInfoBuf)
const moduleLinkBuf = Buffer.alloc(4 + 12)
moduleLinkBuf.writeUInt32LE(1, 0)
moduleLinkBuf.writeUInt32LE(0, 4) // minidump module index
moduleLinkBuf.writeUInt32LE(moduleInfoBuf.length, 8)
moduleLinkBuf.writeUInt32LE(moduleInfoRva, 12)
const moduleLinkRva = builder.append(moduleLinkBuf)
const crashpadInfoBuf = Buffer.concat([
(() => {
const v = Buffer.alloc(4 + 16 + 16)
v.writeUInt32LE(1, 0)
return v
})(),
location(simpleBuf.length, simpleRva),
location(moduleLinkBuf.length, moduleLinkRva)
])
const crashpadInfoRva = builder.append(crashpadInfoBuf)
streams.push({
type: STREAM_TYPE_CRASHPAD_INFO,
size: crashpadInfoBuf.length,
rva: crashpadInfoRva
})
if (options.modules && options.modules.length > 0) {
const nameRvas = options.modules.map((module) => builder.utf16String(module.name))
const listBuf = Buffer.alloc(4 + options.modules.length * 108)
listBuf.writeUInt32LE(options.modules.length, 0)
options.modules.forEach((module, index) => {
const at = 4 + index * 108
listBuf.writeBigUInt64LE(module.base, at)
listBuf.writeUInt32LE(module.size, at + 8)
listBuf.writeUInt32LE(nameRvas[index], at + 20)
})
streams.push({
type: STREAM_TYPE_MODULE_LIST,
size: listBuf.length,
rva: builder.append(listBuf)
})
}
if (options.exception) {
const exceptionBuf = Buffer.alloc(168)
exceptionBuf.writeUInt32LE(1234, 0) // ThreadId
exceptionBuf.writeUInt32LE(options.exception.code, 8)
exceptionBuf.writeBigUInt64LE(options.exception.address, 24)
streams.push({
type: STREAM_TYPE_EXCEPTION,
size: exceptionBuf.length,
rva: builder.append(exceptionBuf)
})
}
return { dump: builder.build(streams) }
}
const FATAL_LINE =
'[8104:1234:0815/143022.123456:FATAL:render_frame_impl.cc(4821)] Check failed: !is_detached_.'
const ELECTRON_43_CHECK_LINE =
'[29136:0815/232206.330:ERROR:third_party\\blink\\common\\chrome_debug_urls.cc:180] Intentionally causing CHECK because user navigated to chrome://checkcrash/'
describe('parseMinidumpCrashSignature', () => {
it('names the failing CHECK from the LOG_FATAL annotation', () => {
const { dump } = buildDump({
annotations: { LOG_FATAL: FATAL_LINE, ptype: 'renderer' }
})
const signature = parseMinidumpCrashSignature(dump)
expect(signature?.checkMessage).toBe(FATAL_LINE)
expect(signature?.checkFile).toBe('render_frame_impl.cc')
expect(signature?.checkLine).toBe(4821)
expect(signature?.processType).toBe('renderer')
})
it('recovers a CHECK line from Electron 43 dump memory without LOG_FATAL', () => {
const { dump } = buildDump({ annotations: { ptype: 'renderer' } })
const dumpWithMemory = Buffer.concat([
dump,
Buffer.from(`\0${ELECTRON_43_CHECK_LINE}\0`, 'utf8')
])
const signature = parseMinidumpCrashSignature(dumpWithMemory)
expect(signature?.checkMessage).toBe(ELECTRON_43_CHECK_LINE)
expect(signature?.checkFile).toBe('chrome_debug_urls.cc')
expect(signature?.checkLine).toBe(180)
expect(signature?.processType).toBe('renderer')
})
it('does not promote an unrelated Chromium ERROR line containing CHECK', () => {
const { dump } = buildDump({})
const unrelated =
'[29136:0815/232206.330:ERROR:settings.cc:44] Opened the CHECK settings panel.'
const dumpWithMemory = Buffer.concat([dump, Buffer.from(`\0${unrelated}\0`, 'utf8')])
expect(parseMinidumpCrashSignature(dumpWithMemory)?.checkMessage).toBeUndefined()
})
it('prefers the structured annotation over a dump-memory candidate', () => {
const { dump } = buildDump({ annotations: { LOG_FATAL: FATAL_LINE } })
const dumpWithMemory = Buffer.concat([
dump,
Buffer.from(`\0${ELECTRON_43_CHECK_LINE}\0`, 'utf8')
])
expect(parseMinidumpCrashSignature(dumpWithMemory)?.checkMessage).toBe(FATAL_LINE)
})
it('reads annotations from the process-level simple string dictionary', () => {
const { dump } = buildDump({
simpleAnnotations: {
ptype: 'gpu-process',
'gpu-gl-vendor': 'Intel Inc.'
}
})
const signature = parseMinidumpCrashSignature(dump)
expect(signature?.processType).toBe('gpu-process')
expect(signature?.annotations['gpu-gl-vendor']).toBe('Intel Inc.')
})
it('drops annotations outside the allowlist', () => {
const { dump } = buildDump({
annotations: {
LOG_FATAL: FATAL_LINE,
'switch-3': '--user-data-dir=/Users/someone/secret'
}
})
const signature = parseMinidumpCrashSignature(dump)
expect(signature?.annotations['switch-3']).toBeUndefined()
expect(Object.keys(signature?.annotations ?? {})).toEqual(['LOG_FATAL'])
})
it('resolves the faulting module from the exception address', () => {
const { dump } = buildDump({
exception: { code: 0x80000003, address: 0x7ff8_0000_1234n },
modules: [
{
base: 0x7ff7_0000_0000n,
size: 0x1000,
name: 'C:\\Program Files\\Orca\\Orca.exe'
},
{
base: 0x7ff8_0000_0000n,
size: 0x10_0000,
name: 'C:\\Program Files\\Orca\\chrome_elf.dll'
}
]
})
const signature = parseMinidumpCrashSignature(dump)
expect(signature?.exceptionCode).toBe(0x80000003)
expect(signature?.exceptionAddress).toBe('0x7ff800001234')
expect(signature?.faultingModule).toBe('chrome_elf.dll')
expect(signature?.faultingModuleOffset).toBe('0x1234')
})
it('omits the faulting module when no image range covers the address', () => {
const { dump } = buildDump({
exception: { code: 11, address: 0x10n },
modules: [{ base: 0x7ff7_0000_0000n, size: 0x1000, name: '/opt/orca/orca' }]
})
const signature = parseMinidumpCrashSignature(dump)
expect(signature?.exceptionAddress).toBe('0x10')
expect(signature?.faultingModule).toBeUndefined()
})
it('returns null for a buffer that is not a minidump', () => {
expect(parseMinidumpCrashSignature(Buffer.from('not a dump at all', 'utf8'))).toBeNull()
expect(parseMinidumpCrashSignature(Buffer.alloc(0))).toBeNull()
})
it('degrades instead of throwing on a truncated dump', () => {
const { dump } = buildDump({ annotations: { LOG_FATAL: FATAL_LINE } })
const truncated = dump.subarray(0, 48)
expect(() => parseMinidumpCrashSignature(truncated)).not.toThrow()
expect(parseMinidumpCrashSignature(truncated)?.checkMessage).toBeUndefined()
})
it('degrades instead of throwing when stream counts are corrupt', () => {
const { dump } = buildDump({ annotations: { LOG_FATAL: FATAL_LINE } })
const corrupt = Buffer.from(dump)
corrupt.writeUInt32LE(0xffff_ffff, 8)
expect(() => parseMinidumpCrashSignature(corrupt)).not.toThrow()
expect(parseMinidumpCrashSignature(corrupt)?.annotations).toEqual({})
})
})
describe('minidumpSignatureDetails', () => {
it('flattens the check location and faulting module into detail keys', () => {
const { dump } = buildDump({
annotations: {
LOG_FATAL: FATAL_LINE,
ptype: 'renderer',
channel: 'stable'
},
exception: { code: 0x80000003, address: 0x7ff8_0000_1234n },
modules: [{ base: 0x7ff8_0000_0000n, size: 0x10_0000, name: 'chrome_elf.dll' }]
})
const details = minidumpSignatureDetails(parseMinidumpCrashSignature(dump)!)
expect(details).toMatchObject({
minidumpCheckMessage: FATAL_LINE,
minidumpCheckFile: 'render_frame_impl.cc',
minidumpCheckLine: 4821,
minidumpProcessType: 'renderer',
minidumpExceptionCode: '0x80000003',
minidumpFaultingModule: 'chrome_elf.dll',
minidumpAnnotation_channel: 'stable'
})
})
it('does not duplicate the fatal line into an annotation key', () => {
const { dump } = buildDump({ annotations: { LOG_FATAL: FATAL_LINE } })
const details = minidumpSignatureDetails(parseMinidumpCrashSignature(dump)!)
expect(details.minidumpAnnotation_LOG_FATAL).toBeUndefined()
})
})
@@ -0,0 +1,261 @@
// Extracts the diagnosable parts of a Crashpad minidump without symbols.
//
// Why: a Chromium CHECK/DCHECK surfaces to `render-process-gone` as exit code
// 0x80000003 (STATUS_BREAKPOINT) and nothing else, so the exit code alone can
// never name the failing check. Some Crashpad builds expose the fatal line as
// `LOG_FATAL`; Electron 43 on Windows only carries it in captured process
// memory. Both forms are recoverable without symbols or minidump_stackwalk.
//
// Layouts are from Crashpad's minidump_extensions.h and the Windows
// MINIDUMP_* structs. Everything here is bounds-checked and returns null
// rather than throwing: a truncated dump must degrade, not break crash
// reporting.
import { findStream, isMinidump, MAX_MODULES, MinidumpView } from './minidump-stream-reader'
import { readCrashpadAnnotations } from './minidump-crashpad-annotations'
const STREAM_TYPE_MODULE_LIST = 4
const STREAM_TYPE_EXCEPTION = 6
const MODULE_RECORD_SIZE = 108
const MODULE_BASE_OFFSET = 0
const MODULE_SIZE_OFFSET = 8
const MODULE_NAME_RVA_OFFSET = 20
// MINIDUMP_EXCEPTION_STREAM: ThreadId u32, __alignment u32, then MINIDUMP_EXCEPTION.
const EXCEPTION_RECORD_OFFSET = 8
const EXCEPTION_CODE_OFFSET = EXCEPTION_RECORD_OFFSET + 0
const EXCEPTION_ADDRESS_OFFSET = EXCEPTION_RECORD_OFFSET + 16
const CHROMIUM_LOG_MARKERS = [
Buffer.from(':FATAL:', 'ascii'),
Buffer.from(':CHECK:', 'ascii'),
Buffer.from(':DFATAL:', 'ascii'),
Buffer.from(':ERROR:', 'ascii')
]
const MAX_LOG_PREFIX_BYTES = 96
const MAX_CHECK_LOG_BYTES = 4_000
const MAX_MARKERS_PER_SEVERITY = 256
const CHECK_LOG_PATTERN =
/^\[(?:\d+:){1,2}\d{4}\/\d{6}\.\d{3,6}:(FATAL|CHECK|DFATAL|ERROR)(?::[^:\]\r\n]{1,80})*:([^:\]\r\n]{1,512}?)(?:\((\d+)\)|:(\d+))\]\s*(.+)$/
const ERROR_CHECK_PATTERN = /\b(?:Check failed:|D?CHECK failed:|Intentionally causing D?CHECK\b)/i
export type MinidumpCrashSignature = {
/** Chromium's fatal log line, e.g. `[...:FATAL:node.cc(123)] Check failed: !x.` */
readonly checkMessage?: string
/** Source file basename parsed out of `checkMessage`. */
readonly checkFile?: string
readonly checkLine?: number
/** Crashpad `ptype`: `renderer`, `gpu-process`, `browser`. */
readonly processType?: string
/** Win32 exception code / POSIX signal, e.g. 0x80000003 STATUS_BREAKPOINT. */
readonly exceptionCode?: number
readonly exceptionAddress?: string
/** Module whose image range contains `exceptionAddress`. */
readonly faultingModule?: string
readonly faultingModuleOffset?: string
/** Allowlisted Crashpad annotations, verbatim. */
readonly annotations: Readonly<Record<string, string>>
}
type ModuleRecord = {
readonly base: bigint
readonly size: number
readonly name: string
}
function readModules(view: MinidumpView): ModuleRecord[] {
const stream = findStream(view, STREAM_TYPE_MODULE_LIST)
if (!stream) {
return []
}
const count = view.u32(stream.rva)
if (count === null || count > MAX_MODULES) {
return []
}
const modules: ModuleRecord[] = []
for (let index = 0; index < count; index += 1) {
const record = stream.rva + 4 + index * MODULE_RECORD_SIZE
const base = view.u64(record + MODULE_BASE_OFFSET)
const size = view.u32(record + MODULE_SIZE_OFFSET)
const nameRva = view.u32(record + MODULE_NAME_RVA_OFFSET)
if (base === null || size === null || nameRva === null) {
break
}
const name = view.utf16String(nameRva, 2_048)
modules.push({ base, size, name: name ?? 'unknown' })
}
return modules
}
function moduleBasename(modulePath: string): string {
const separator = Math.max(modulePath.lastIndexOf('/'), modulePath.lastIndexOf('\\'))
return separator >= 0 ? modulePath.slice(separator + 1) : modulePath
}
function toHex(value: bigint): string {
return `0x${value.toString(16)}`
}
type LocatedCheckMessage = {
readonly message: string
readonly file?: string
readonly line?: number
}
function isPrintableLogByte(value: number): boolean {
return value === 0x09 || (value >= 0x20 && value <= 0x7e)
}
/** Electron 43 omits LOG_FATAL but keeps Chromium's formatted log line in memory. */
function findEmbeddedCheckMessage(dump: Buffer): LocatedCheckMessage | undefined {
for (const marker of CHROMIUM_LOG_MARKERS) {
let from = 0
for (let inspected = 0; inspected < MAX_MARKERS_PER_SEVERITY; inspected += 1) {
const markerAt = dump.indexOf(marker, from)
if (markerAt === -1) {
break
}
from = markerAt + marker.length
const start = dump.lastIndexOf(0x5b, markerAt)
if (start === -1 || markerAt - start > MAX_LOG_PREFIX_BYTES) {
continue
}
let end = markerAt + marker.length
const limit = Math.min(dump.length, start + MAX_CHECK_LOG_BYTES)
while (end < limit && isPrintableLogByte(dump[end])) {
end += 1
}
const candidate = dump.subarray(start, end).toString('utf8')
const match = CHECK_LOG_PATTERN.exec(candidate)
if (!match || (match[1] === 'ERROR' && !ERROR_CHECK_PATTERN.test(match[5]))) {
continue
}
const line = Number.parseInt(match[3] ?? match[4], 10)
return {
message: candidate,
file: moduleBasename(match[2]),
line: Number.isFinite(line) ? line : undefined
}
}
}
return undefined
}
/** Parses the annotation form, which uses `file.cc(123)`. */
function parseCheckLocation(checkMessage: string): {
file?: string
line?: number
} {
const match = /:(?:FATAL|CHECK|DFATAL)(?::[^:\]]*)?:([^:()\s]+)\((\d+)\)/.exec(checkMessage)
if (!match) {
return {}
}
const line = Number.parseInt(match[2], 10)
return { file: match[1], line: Number.isFinite(line) ? line : undefined }
}
function findFaultingModule(
modules: ModuleRecord[],
address: bigint
): { name: string; offset: string } | undefined {
for (const module of modules) {
if (address >= module.base && address < module.base + BigInt(module.size)) {
return {
name: moduleBasename(module.name),
offset: toHex(address - module.base)
}
}
}
return undefined
}
/**
* Parses a Crashpad minidump into the fields that make a CHECK failure
* nameable. Returns null when the buffer is not a minidump.
*/
export function parseMinidumpCrashSignature(dump: Buffer): MinidumpCrashSignature | null {
if (!isMinidump(dump)) {
return null
}
const view = new MinidumpView(dump)
const annotations = readCrashpadAnnotations(view)
const signature: {
-readonly [K in keyof MinidumpCrashSignature]: MinidumpCrashSignature[K]
} = { annotations }
const annotatedCheckMessage = annotations['LOG_FATAL'] ?? annotations['abort-message']
const embeddedCheck = annotatedCheckMessage ? undefined : findEmbeddedCheckMessage(dump)
const checkMessage = annotatedCheckMessage ?? embeddedCheck?.message
if (checkMessage) {
signature.checkMessage = checkMessage
const location = embeddedCheck ?? parseCheckLocation(checkMessage)
if (location.file) {
signature.checkFile = location.file
}
if (location.line !== undefined) {
signature.checkLine = location.line
}
}
if (annotations['ptype']) {
signature.processType = annotations['ptype']
}
const exception = findStream(view, STREAM_TYPE_EXCEPTION)
if (exception) {
const code = view.u32(exception.rva + EXCEPTION_CODE_OFFSET)
const address = view.u64(exception.rva + EXCEPTION_ADDRESS_OFFSET)
if (code !== null) {
signature.exceptionCode = code
}
if (address !== null) {
signature.exceptionAddress = toHex(address)
const faulting = findFaultingModule(readModules(view), address)
if (faulting) {
signature.faultingModule = faulting.name
signature.faultingModuleOffset = faulting.offset
}
}
}
return signature
}
/** Flattens a signature into `CrashReportRecord.details` keys. */
export function minidumpSignatureDetails(
signature: MinidumpCrashSignature
): Record<string, string | number> {
const details: Record<string, string | number> = {}
if (signature.checkMessage) {
details.minidumpCheckMessage = signature.checkMessage
}
if (signature.checkFile) {
details.minidumpCheckFile = signature.checkFile
}
if (signature.checkLine !== undefined) {
details.minidumpCheckLine = signature.checkLine
}
if (signature.processType) {
details.minidumpProcessType = signature.processType
}
if (signature.exceptionCode !== undefined) {
details.minidumpExceptionCode = `0x${(signature.exceptionCode >>> 0).toString(16)}`
}
if (signature.exceptionAddress) {
details.minidumpExceptionAddress = signature.exceptionAddress
}
if (signature.faultingModule) {
details.minidumpFaultingModule = signature.faultingModule
}
if (signature.faultingModuleOffset) {
details.minidumpFaultingModuleOffset = signature.faultingModuleOffset
}
for (const [key, value] of Object.entries(signature.annotations)) {
if (key === 'LOG_FATAL' || key === 'abort-message' || key === 'ptype') {
continue
}
details[`minidumpAnnotation_${key.replace(/-/g, '_')}`] = value
}
return details
}
@@ -0,0 +1,178 @@
// Reads Chromium's crash keys out of a Crashpad minidump's annotation streams.
//
// Some Chromium builds expose the fatal log line as `LOG_FATAL`; the signature
// parser also handles builds that carry it only in captured process memory.
// Layouts are from Crashpad's minidump_extensions.h.
import {
findStream,
MAX_MODULES,
type LocationDescriptor,
type MinidumpView
} from './minidump-stream-reader'
// Why: a dump claiming an absurd annotation count is corrupt; cap before iterating.
const MAX_ANNOTATIONS = 512
const STREAM_TYPE_CRASHPAD_INFO = 0x43500001
const CRASHPAD_INFO_MIN_SIZE = 52
const CRASHPAD_INFO_SIMPLE_ANNOTATIONS_OFFSET = 36
const CRASHPAD_INFO_MODULE_LIST_OFFSET = 44
const MODULE_CRASHPAD_INFO_LINK_SIZE = 12
const MODULE_CRASHPAD_INFO_MIN_SIZE = 28
// list_annotations at +4 is a keyless legacy RVA list; nothing we can attribute.
const MODULE_CRASHPAD_INFO_SIMPLE_ANNOTATIONS_OFFSET = 12
const MODULE_CRASHPAD_INFO_ANNOTATION_OBJECTS_OFFSET = 20
const ANNOTATION_RECORD_SIZE = 12
const ANNOTATION_TYPE_STRING = 1
/**
* Crashpad annotations we copy into a crash report.
*
* Why an allowlist: annotations are an open key space and some Chromium keys
* (`switch-N`, extension ids) carry command lines and user data. Diagnostic
* value here is concentrated in a handful of keys, so default-deny.
*/
const ANNOTATION_ALLOWLIST = new Set([
// The whole point of this module — Chromium's fatal log line.
'LOG_FATAL',
'abort-message',
// Which process died, independent of what Electron told us.
'ptype',
'ver',
'prod',
'plat',
'osarch',
'channel',
// Distinguishes a one-off from a crash loop.
'crash-loop-before',
'first-crash-time',
// The Linux SIGBUS/SIGSEGV reports are GPU-adjacent; driver identity matters.
'gpu-gl-vendor',
'gpu-gl-renderer',
'gpu-driver-version',
'gpu-vendor-id',
'gpu-device-id',
'gpu-generation-intel'
])
/** MinidumpSimpleStringDictionary: u32 count, then {key rva, value rva} pairs. */
function readSimpleAnnotations(
view: MinidumpView,
location: LocationDescriptor | null,
into: Record<string, string>
): void {
if (!location) {
return
}
const count = view.u32(location.rva)
if (count === null || count > MAX_ANNOTATIONS || 4 + count * 8 > location.size) {
return
}
for (let index = 0; index < count; index += 1) {
const entry = location.rva + 4 + index * 8
const keyRva = view.u32(entry)
const valueRva = view.u32(entry + 4)
if (keyRva === null || valueRva === null) {
return
}
const key = view.utf8String(keyRva, 256)
if (key === null || !ANNOTATION_ALLOWLIST.has(key)) {
continue
}
const value = view.utf8String(valueRva)
if (value !== null) {
into[key] = value
}
}
}
/** MinidumpAnnotationList: u32 count, then MinidumpAnnotation records. */
function readAnnotationObjects(
view: MinidumpView,
location: LocationDescriptor | null,
into: Record<string, string>
): void {
if (!location) {
return
}
const count = view.u32(location.rva)
if (
count === null ||
count > MAX_ANNOTATIONS ||
4 + count * ANNOTATION_RECORD_SIZE > location.size
) {
return
}
for (let index = 0; index < count; index += 1) {
const entry = location.rva + 4 + index * ANNOTATION_RECORD_SIZE
const nameRva = view.u32(entry)
const type = view.u16(entry + 4)
const valueRva = view.u32(entry + 8)
if (nameRva === null || type === null || valueRva === null) {
return
}
if (type !== ANNOTATION_TYPE_STRING || valueRva === 0) {
continue
}
const name = view.utf8String(nameRva, 256)
if (name === null || !ANNOTATION_ALLOWLIST.has(name)) {
continue
}
const raw = view.byteArray(valueRva)
if (raw) {
// Annotation strings are not NUL-terminated; trim a trailing one anyway.
let value = raw.toString('utf8')
while (value.endsWith('\0')) {
value = value.slice(0, -1)
}
into[name] = value
}
}
}
export function readCrashpadAnnotations(view: MinidumpView): Record<string, string> {
const annotations: Record<string, string> = {}
const info = findStream(view, STREAM_TYPE_CRASHPAD_INFO)
if (!info || info.size < CRASHPAD_INFO_MIN_SIZE) {
return annotations
}
readSimpleAnnotations(
view,
view.location(info.rva + CRASHPAD_INFO_SIMPLE_ANNOTATIONS_OFFSET),
annotations
)
const moduleList = view.location(info.rva + CRASHPAD_INFO_MODULE_LIST_OFFSET)
if (!moduleList) {
return annotations
}
const moduleCount = view.u32(moduleList.rva)
if (moduleCount === null || moduleCount > MAX_MODULES) {
return annotations
}
for (let index = 0; index < moduleCount; index += 1) {
const link = moduleList.rva + 4 + index * MODULE_CRASHPAD_INFO_LINK_SIZE
const moduleInfo = view.location(link + 4)
if (!moduleInfo || moduleInfo.size < MODULE_CRASHPAD_INFO_MIN_SIZE) {
continue
}
// Why: Chromium's crash keys land in annotation_objects on current
// Crashpad, but older modules still populate the two legacy shapes.
readSimpleAnnotations(
view,
view.location(moduleInfo.rva + MODULE_CRASHPAD_INFO_SIMPLE_ANNOTATIONS_OFFSET),
annotations
)
readAnnotationObjects(
view,
view.location(moduleInfo.rva + MODULE_CRASHPAD_INFO_ANNOTATION_OBJECTS_OFFSET),
annotations
)
}
return annotations
}
@@ -0,0 +1,128 @@
// Bounds-checked primitives for walking a minidump.
//
// Split from minidump-crash-signature so the byte-level layout knowledge
// (header, stream directory, MINIDUMP_LOCATION_DESCRIPTOR, the two string
// encodings) stays separate from what Crashpad puts in those structures.
//
// Every accessor returns null past the end of the buffer: a truncated or
// corrupt dump must degrade, not throw, or it takes crash reporting down with it.
export const MINIDUMP_SIGNATURE = 0x504d444d // 'MDMP' little-endian
export const MINIDUMP_HEADER_SIZE = 32
const DIRECTORY_ENTRY_SIZE = 12
// A dump claiming an absurd stream count is corrupt; cap before iterating.
const MAX_STREAMS = 4_096
export const MAX_ANNOTATION_VALUE_BYTES = 8_192
// Shared cap: both the MINIDUMP_MODULE_LIST and Crashpad's per-module info list.
export const MAX_MODULES = 1_024
export type LocationDescriptor = {
readonly size: number
readonly rva: number
}
export class MinidumpView {
constructor(private readonly buf: Buffer) {}
get byteLength(): number {
return this.buf.length
}
u32(offset: number): number | null {
if (offset < 0 || offset + 4 > this.buf.length) {
return null
}
return this.buf.readUInt32LE(offset)
}
u16(offset: number): number | null {
if (offset < 0 || offset + 2 > this.buf.length) {
return null
}
return this.buf.readUInt16LE(offset)
}
u64(offset: number): bigint | null {
if (offset < 0 || offset + 8 > this.buf.length) {
return null
}
return this.buf.readBigUInt64LE(offset)
}
location(offset: number): LocationDescriptor | null {
const size = this.u32(offset)
const rva = this.u32(offset + 4)
if (size === null || rva === null) {
return null
}
// A zero rva means "absent", which is normal for optional sub-structures.
if (rva === 0 || rva >= this.buf.length) {
return null
}
return { size, rva }
}
/** MinidumpUTF8String: u32 byte length, then NUL-terminated UTF-8. */
utf8String(rva: number, maxBytes = MAX_ANNOTATION_VALUE_BYTES): string | null {
return this.byteArray(rva, maxBytes)?.toString('utf8') ?? null
}
/** MINIDUMP_STRING: u32 byte length, then UTF-16LE. Used for module names. */
utf16String(rva: number, maxBytes = MAX_ANNOTATION_VALUE_BYTES): string | null {
const length = this.u32(rva)
if (length === null || length > maxBytes || length % 2 !== 0) {
return null
}
const start = rva + 4
if (start + length > this.buf.length) {
return null
}
return this.buf.toString('utf16le', start, start + length)
}
bytes(location: LocationDescriptor, maxBytes = MAX_ANNOTATION_VALUE_BYTES): Buffer | null {
if (location.size > maxBytes || location.rva + location.size > this.buf.length) {
return null
}
return this.buf.subarray(location.rva, location.rva + location.size)
}
/** MinidumpByteArray: u32 byte length, then the bytes. */
byteArray(rva: number, maxBytes = MAX_ANNOTATION_VALUE_BYTES): Buffer | null {
const length = this.u32(rva)
if (length === null || length > maxBytes) {
return null
}
return this.bytes({ size: length, rva: rva + 4 }, maxBytes)
}
}
export function isMinidump(dump: Buffer): boolean {
return dump.length >= MINIDUMP_HEADER_SIZE && dump.readUInt32LE(0) === MINIDUMP_SIGNATURE
}
/** Locates a stream by type in the header's directory, or null if absent. */
export function findStream(view: MinidumpView, streamType: number): LocationDescriptor | null {
const streamCount = view.u32(8)
const directoryRva = view.u32(12)
if (streamCount === null || directoryRva === null || streamCount > MAX_STREAMS) {
return null
}
for (let index = 0; index < streamCount; index += 1) {
const entry = directoryRva + index * DIRECTORY_ENTRY_SIZE
const type = view.u32(entry)
if (type === null) {
return null
}
if (type !== streamType) {
continue
}
const size = view.u32(entry + 4)
const rva = view.u32(entry + 8)
if (size === null || rva === null || rva === 0 || rva >= view.byteLength) {
return null
}
return { size, rva }
}
return null
}
@@ -18,6 +18,10 @@ import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observa
type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType<typeof vi.fn> }
/** Keeps tests off the real Crashpad directory; minidump pairing has its own suite. */
const noMinidump = async () => null
const attachDetails = async () => null
function capturingSink(): CapturingSink {
const records: unknown[] = []
const flushMock = vi.fn()
@@ -51,6 +55,7 @@ beforeEach(() => {
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
_resetTracerForTests()
clearCrashBreadcrumbsForTest()
@@ -157,6 +162,7 @@ describe('recordProcessGoneCrash', () => {
})
it('reports how many repeats a coalesced suppression stands for', () => {
vi.useFakeTimers()
const dedupe = new ProcessGoneDedupe()
const utilityCrash = event({
source: 'child',
@@ -164,13 +170,11 @@ describe('recordProcessGoneCrash', () => {
reason: 'crashed',
details: { serviceName: 'network.mojom.NetworkService' }
})
const nowSpy = vi.spyOn(Date, 'now')
nowSpy.mockReturnValue(0)
for (let i = 0; i < 700; i++) {
recordProcessGoneCrash({ record: vi.fn() } as never, utilityCrash, dedupe)
}
nowSpy.mockReturnValue(30_000)
vi.advanceTimersByTime(30_000)
recordProcessGoneCrash({ record: vi.fn() } as never, utilityCrash, dedupe)
expect(getCrashBreadcrumbSnapshot()).toEqual([
@@ -243,7 +247,12 @@ describe('recordProcessGoneCrash', () => {
it('persists a report and flushes the process-gone trace before recovery', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
recordProcessGoneCrash(
{ record, attachDetails } as never,
event(),
new ProcessGoneDedupe(),
noMinidump
)
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
expect(record).toHaveBeenCalledWith(
@@ -279,7 +288,12 @@ describe('recordProcessGoneCrash', () => {
})
expect(() =>
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
recordProcessGoneCrash(
{ record, attachDetails } as never,
event(),
new ProcessGoneDedupe(),
noMinidump
)
).not.toThrow()
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
})
@@ -291,7 +305,12 @@ describe('recordProcessGoneCrash', () => {
}
expect(() =>
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
recordProcessGoneCrash(
{ record, attachDetails } as never,
event(),
new ProcessGoneDedupe(),
noMinidump
)
).not.toThrow()
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
})
@@ -304,7 +323,12 @@ describe('recordProcessGoneCrash', () => {
const record = vi.fn().mockRejectedValue(persistError)
vi.spyOn(console, 'error').mockImplementation(() => {})
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
recordProcessGoneCrash(
{ record, attachDetails } as never,
event(),
new ProcessGoneDedupe(),
noMinidump
)
await vi.waitFor(() => {
expect(getCrashBreadcrumbSnapshot()).toEqual(
@@ -331,7 +355,12 @@ describe('recordProcessGoneCrash', () => {
const record = vi.fn().mockRejectedValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
recordProcessGoneCrash(
{ record, attachDetails } as never,
event(),
new ProcessGoneDedupe(),
noMinidump
)
await vi.waitFor(() =>
expect(getCrashBreadcrumbSnapshot()).toEqual(
@@ -353,14 +382,191 @@ describe('recordProcessGoneCrash', () => {
const dedupe = new ProcessGoneDedupe()
vi.spyOn(console, 'error').mockImplementation(() => {})
recordProcessGoneCrash({ record } as never, event(), dedupe)
recordProcessGoneCrash({ record, attachDetails } as never, event(), dedupe, noMinidump)
await vi.waitFor(() =>
expect(getCrashBreadcrumbSnapshot()).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'crash_report_persist_failed' })])
)
)
recordProcessGoneCrash({ record } as never, event(), dedupe)
recordProcessGoneCrash({ record, attachDetails } as never, event(), dedupe, noMinidump)
await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(2))
})
})
describe('minidump signature attachment', () => {
const capturedRendererCheck = {
filePath: '/dumps/reports/abc.dmp',
sizeBytes: 2_400_000,
signature: {
checkMessage: '[0815/143022:FATAL:render_frame_impl.cc(4821)] Check failed: !is_detached_.',
checkFile: 'render_frame_impl.cc',
checkLine: 4821,
processType: 'renderer',
exceptionCode: 0x80000003,
annotations: {}
}
}
it('names the failing CHECK on the report that only had an exit code', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
const attach = vi.fn().mockResolvedValue(null)
const capture = vi.fn().mockResolvedValue(capturedRendererCheck)
recordProcessGoneCrash(
{ record, attachDetails: attach } as never,
event({ exitCode: -2147483645 }),
new ProcessGoneDedupe(),
capture
)
await vi.waitFor(() => expect(attach).toHaveBeenCalledOnce())
expect(capture).toHaveBeenCalledWith(expect.any(Number), 'renderer')
expect(attach).toHaveBeenCalledWith(
'report-1',
expect.objectContaining({
minidumpStatus: 'captured',
minidumpCheckMessage: capturedRendererCheck.signature.checkMessage,
minidumpCheckFile: 'render_frame_impl.cc',
minidumpCheckLine: 4821,
minidumpExceptionCode: '0x80000003',
minidumpBytes: 2_400_000
})
)
})
it('maps Electron child types to Crashpad process identities', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
const attach = vi.fn().mockResolvedValue(null)
const capture = vi.fn().mockResolvedValue(null)
recordProcessGoneCrash(
{ record, attachDetails: attach } as never,
event({
source: 'child',
processType: 'Utility',
// A utility outside the recoverable-service allowlist still reports.
details: { type: 'Utility', serviceName: 'storage.mojom.StorageService' }
}),
new ProcessGoneDedupe(),
capture
)
await vi.waitFor(() => expect(attach).toHaveBeenCalledOnce())
expect(capture).toHaveBeenCalledWith(expect.any(Number), 'utility')
})
it('never looks for a dump for a GPU child, which is suppressed upstream', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
const capture = vi.fn().mockResolvedValue(null)
recordProcessGoneCrash(
{ record, attachDetails: async () => null } as never,
event({ source: 'child', processType: 'GPU', details: { type: 'GPU' } }),
new ProcessGoneDedupe(),
capture
)
// Why: GPU exits are recoverable Chromium churn (process-gone-classification.ts),
// so they never become a report — and must not burn an 8s dump poll either.
await vi.waitFor(() =>
expect(getCrashBreadcrumbSnapshot()).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'process_gone_suppressed' })])
)
)
expect(record).not.toHaveBeenCalled()
expect(capture).not.toHaveBeenCalled()
})
it('exports the signature as a span so it is countable in the bundle', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
recordProcessGoneCrash(
{ record, attachDetails: async () => null } as never,
event(),
new ProcessGoneDedupe(),
async () => capturedRendererCheck
)
await vi.waitFor(() =>
expect(sink.records).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'electron.minidump_signature',
attributes: expect.objectContaining({
'crash.report_id': 'report-1',
minidumpCheckFile: 'render_frame_impl.cc'
})
})
])
)
)
})
it('sanitizes dump annotations before writing the diagnostic span', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
const attach = vi.fn().mockResolvedValue(null)
const captured = {
...capturedRendererCheck,
signature: {
...capturedRendererCheck.signature,
checkMessage:
'[FATAL:node.cc(123)] path=/Users/alice/private-project\nCheck failed: token=abc123'
}
}
recordProcessGoneCrash(
{ record, attachDetails: attach } as never,
event(),
new ProcessGoneDedupe(),
async () => captured
)
await vi.waitFor(() => expect(attach).toHaveBeenCalledOnce())
expect(JSON.stringify(attach.mock.calls[0]?.[1])).not.toContain('alice')
expect(JSON.stringify(attach.mock.calls[0]?.[1])).not.toContain('abc123')
expect(JSON.stringify(sink.records)).not.toContain('alice')
expect(JSON.stringify(sink.records)).not.toContain('abc123')
})
it('marks the report when no dump was produced, so absence is visible', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
const attach = vi.fn().mockResolvedValue(null)
recordProcessGoneCrash(
{ record, attachDetails: attach } as never,
event(),
new ProcessGoneDedupe(),
noMinidump
)
await vi.waitFor(() =>
expect(attach).toHaveBeenCalledWith('report-1', { minidumpStatus: 'absent' })
)
})
it('keeps the persisted report when minidump pairing throws', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
const dedupe = new ProcessGoneDedupe()
const releaseSpy = vi.spyOn(dedupe, 'release')
vi.spyOn(console, 'error').mockImplementation(() => {})
recordProcessGoneCrash({ record, attachDetails } as never, event(), dedupe, async () => {
throw new Error('crashpad directory unreadable')
})
await vi.waitFor(() =>
expect(getCrashBreadcrumbSnapshot()).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'minidump_signature_attach_failed' })
])
)
)
// Why: the report did persist; releasing the claim would let the same crash
// be recorded twice on the next process-gone event in the burst.
expect(releaseSpy).not.toHaveBeenCalled()
expect(getCrashBreadcrumbSnapshot()).not.toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'crash_report_persist_failed' })])
)
})
})
@@ -2,6 +2,7 @@ import os from 'node:os'
import { app } from 'electron'
import {
isCrashReportReason,
sanitizeCrashReportDetails,
sanitizeCrashReportString,
type CrashReportBreadcrumbData
} from '../../shared/crash-reporting'
@@ -26,6 +27,12 @@ import {
type ProcessGoneDedupe
} from './process-gone-dedupe'
import { getMainProcessLifecycleIdentity } from './main-process-lifecycle-identity'
import {
captureMinidumpSignature,
scheduleCrashpadDumpPrune,
type CapturedMinidump
} from './crashpad-capture'
import { minidumpSignatureDetails } from './minidump-crash-signature'
import { flushActiveSink, startSpan } from '../observability/tracer'
export type ProcessGoneCrashEvent = {
@@ -37,7 +44,28 @@ export type ProcessGoneCrashEvent = {
details: Record<string, unknown>
}
type CrashReportRecorderStore = Pick<CrashReportStore, 'record'>
type CrashReportRecorderStore = Pick<CrashReportStore, 'record' | 'attachDetails'>
/** Injectable so tests can drive the pairing without a Crashpad handler. */
export type MinidumpCapture = (
crashedAtMs: number,
expectedProcessType: string
) => Promise<CapturedMinidump | null>
const CHILD_CRASHPAD_PROCESS_TYPES: Readonly<Record<string, string>> = {
gpu: 'gpu-process',
utility: 'utility',
zygote: 'zygote'
}
function expectedCrashpadProcessType(event: ProcessGoneCrashEvent): string | null {
return event.source === 'renderer'
? 'renderer'
: (CHILD_CRASHPAD_PROCESS_TYPES[event.processType.trim().toLowerCase()] ?? null)
}
const captureProcessMinidump: MinidumpCapture = (crashedAtMs, expectedProcessType) =>
captureMinidumpSignature(crashedAtMs, { expectedProcessType })
// Why: the coalesce map prunes every key against the calling window, so a shorter
// one here would weaken the other 30s coalescers. Stay uniform with them.
@@ -75,14 +103,57 @@ function persistFailureData(event: ProcessGoneCrashEvent, error: unknown) {
}
}
/**
* Folds the Crashpad signature into a report that is already on disk.
*
* Why separate from the record write: an exit code of 0x80000003 only says "a
* CHECK fired"; the name, file and line live in the dump, which Crashpad is
* still writing when process-gone fires. Waiting inline would stall recovery.
*/
async function attachMinidumpSignature(
store: CrashReportRecorderStore,
reportId: string,
crashedAtMs: number,
expectedProcessType: string | null,
capture: MinidumpCapture
): Promise<void> {
const captured = expectedProcessType ? await capture(crashedAtMs, expectedProcessType) : null
if (!captured) {
await store.attachDetails(reportId, { minidumpStatus: 'absent' })
return
}
const signatureDetails = sanitizeCrashReportDetails(minidumpSignatureDetails(captured.signature))
await store.attachDetails(reportId, {
...signatureDetails,
minidumpStatus: 'captured',
minidumpPath: captured.filePath,
minidumpBytes: captured.sizeBytes
})
// Why: the crash-report record is capped at 5 entries and is user-facing;
// the span is what makes the signature countable in the diagnostics bundle.
const span = startSpan('electron.minidump_signature', {
attributes: {
'crash.report_id': reportId,
'crash.minidump_bytes': captured.sizeBytes,
...signatureDetails
}
})
span.end()
flushActiveSink()
}
export function recordProcessGoneCrash(
store: CrashReportRecorderStore | null,
event: ProcessGoneCrashEvent,
dedupe: ProcessGoneDedupe = processGoneDedupe
dedupe: ProcessGoneDedupe = processGoneDedupe,
capture: MinidumpCapture = captureProcessMinidump
): void {
if (!isCrashReportReason(event.reason)) {
return
}
// Crashpad captures suppressed service crashes too; keep a crash loop from
// filling the disk even when no user-facing report is created.
scheduleCrashpadDumpPrune()
if (
!shouldRecordProcessGoneCrash({
source: event.source,
@@ -152,6 +223,8 @@ export function recordProcessGoneCrash(
)
flushActiveSink()
const crashedAtMs = Date.now()
const expectedProcessType = expectedCrashpadProcessType(event)
void store
.record({
source: event.source,
@@ -167,6 +240,24 @@ export function recordProcessGoneCrash(
details: crashDetails,
breadcrumbs
})
.then((report) => {
// Why: kept off the returned chain so a minidump failure can never reach
// the persist-failure handler below and release a claim that did persist.
void attachMinidumpSignature(
store,
report.id,
crashedAtMs,
expectedProcessType,
capture
).catch((error) => {
console.error('[crash-reporting] Failed to attach minidump signature:', error)
recordDurableCrashBreadcrumb(
'minidump_signature_attach_failed',
processGoneBreadcrumbData(event),
error instanceof Error ? error.message : String(error)
)
})
})
.catch((error) => {
dedupe.release(claim)
console.error('[crash-reporting] Failed to persist crash report:', error)
@@ -0,0 +1,426 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile'
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
const itWithBash = hasBash ? it : it.skip
const bashMajor = hasBash
? Number(spawnSync('bash', ['-lc', 'printf %s "${BASH_VERSINFO[0]}"']).stdout)
: 0
const bashMinor = hasBash
? Number(spawnSync('bash', ['-lc', 'printf %s "${BASH_VERSINFO[1]}"']).stdout)
: 0
const bashPreservesOddTerminalBackslash = bashMajor > 4 || (bashMajor === 4 && bashMinor >= 4)
function runInteractiveBash(
profile: string,
tempHome: string,
input = 'true\nfalse\nexit 0\n'
): string {
const rcfile = join(tempHome, 'rcfile')
writeFileSync(join(tempHome, '.bash_profile'), profile)
writeFileSync(rcfile, getDaemonBashShellReadyRcfileContent())
const result = spawnSync(
'bash',
['-lc', '"$BASH" --noprofile --rcfile "$1" -i 2>&1', 'bash', rcfile],
{
input,
encoding: 'utf8',
env: { ...process.env, HOME: tempHome, ORCA_SHELL_READY_MARKER: '1', TERM: 'xterm' },
timeout: 5000
}
)
expect(result.error).toBeUndefined()
expect(result.status, result.stdout).toBe(0)
return result.stdout
}
function expectLifecycle(output: string, secondExitCode = 1): void {
const lifecyclePattern = new RegExp(
`${String.fromCharCode(27)}]133;(?:A|C|D;[0-9]+)${String.fromCharCode(7)}`,
'g'
)
expect(output).not.toContain('syntax error')
expect(output.match(lifecyclePattern)).toEqual([
'\x1b]133;A\x07',
'\x1b]133;C\x07',
'\x1b]133;D;0\x07',
'\x1b]133;A\x07',
'\x1b]133;C\x07',
`\x1b]133;D;${secondExitCode}\x07`,
'\x1b]133;A\x07',
'\x1b]133;C\x07'
])
}
describe.skipIf(process.platform === 'win32')('daemon bash PROMPT_COMMAND composition', () => {
let tempHome: string
beforeEach(() => {
tempHome = mkdtempSync(join(tmpdir(), 'bash-prompt-command-'))
})
afterEach(() => {
rmSync(tempHome, { recursive: true, force: true })
})
itWithBash.each([
['semicolon', ';'],
['spaces', '; '],
['tab', ';\t'],
['newlines', ';\n\n'],
['many separators', '; ;\t\n;; ']
])('composes a value ending in %s', (_name, suffix) => {
const output = runInteractiveBash(
`PROMPT_COMMAND='printf "PROMPT_TRAILING\\n"${suffix}'\n`,
tempHome
)
expect(output).toContain('PROMPT_TRAILING')
expectLifecycle(output)
})
itWithBash('normalizes a trailing separator with errexit enabled', () => {
const output = runInteractiveBash(
'set -e\nPROMPT_COMMAND=\'printf "PROMPT_ERREXIT\\n"; \'\n',
tempHome,
'true\ntrue\nexit 0\n'
)
expect(output.match(/PROMPT_ERREXIT\r?\n/g)).toHaveLength(3)
expectLifecycle(output, 0)
})
itWithBash('finishes lifecycle after a top-level return in an inherited hook', () => {
const output = runInteractiveBash(
'PROMPT_COMMAND=\'printf "PROMPT_RETURN\\n"; true && return 0\'\n',
tempHome
)
expect(output.match(/PROMPT_RETURN\r?\n/g)).toHaveLength(3)
expectLifecycle(output)
})
itWithBash('composes an unset PROMPT_COMMAND with nounset enabled', () => {
const output = runInteractiveBash('set -u\nunset PROMPT_COMMAND\n', tempHome)
expect(output).not.toContain('unbound variable')
expectLifecycle(output)
})
itWithBash('composes an inherited hook with nounset enabled', () => {
const output = runInteractiveBash(
'set -u\nPROMPT_COMMAND=\'printf "PROMPT_NOUNSET\\\\n"\'\n',
tempHome
)
expect(output.match(/PROMPT_NOUNSET\r?\n/g)).toHaveLength(3)
expect(output).not.toContain('unbound variable')
expectLifecycle(output)
})
itWithBash('flattens arrays without duplicating later elements', () => {
const profile = `PROMPT_COMMAND=('printf "PROMPT_ARRAY_A\\n";' 'printf "PROMPT_ARRAY_B\\n"; ')\n`
const output = runInteractiveBash(profile, tempHome)
expect(output.split('PROMPT_ARRAY_A')).toHaveLength(4)
expect(output.split('PROMPT_ARRAY_B')).toHaveLength(4)
expectLifecycle(output)
})
itWithBash('passes the foreground status to every array element', () => {
const profile = `__status_a() { printf 'PROMPT_ARRAY_STATUS_A:%s\\n' "$?"; return 7; }
__status_b() { printf 'PROMPT_ARRAY_STATUS_B:%s\\n' "$?"; }
PROMPT_COMMAND=(__status_a __status_b)
`
const output = runInteractiveBash(profile, tempHome)
expect([...output.matchAll(/PROMPT_ARRAY_STATUS_A:(\d+)/g)].map((match) => match[1])).toEqual([
'0',
'0',
'1'
])
expect([...output.matchAll(/PROMPT_ARRAY_STATUS_B:(\d+)/g)].map((match) => match[1])).toEqual([
'0',
'0',
'1'
])
expectLifecycle(output)
})
itWithBash('keeps a scalar ending in an odd backslash isolated from Orca hooks', () => {
const profile = String.raw`PROMPT_COMMAND='printf "PROMPT_BACKSLASH:<%s>\n" safe \'
`
const output = runInteractiveBash(profile, tempHome)
expect(output.split('PROMPT_BACKSLASH:<safe>')).toHaveLength(4)
expect(output.split('PROMPT_BACKSLASH:<\\>')).toHaveLength(
bashPreservesOddTerminalBackslash ? 4 : 1
)
expect(output).not.toContain('PROMPT_BACKSLASH:<__orca_')
expectLifecycle(output)
})
itWithBash('keeps odd-backslash array elements isolated', () => {
const profile = String.raw`PROMPT_COMMAND=('printf "PROMPT_ARRAY_BACKSLASH:<%s>\n" safe \' 'printf "PROMPT_ARRAY_NEXT\n"')
`
const output = runInteractiveBash(profile, tempHome)
expect(output.split('PROMPT_ARRAY_BACKSLASH:<safe>')).toHaveLength(4)
expect(output.split('PROMPT_ARRAY_BACKSLASH:<\\>')).toHaveLength(
bashPreservesOddTerminalBackslash ? 4 : 1
)
expect(output.split('PROMPT_ARRAY_NEXT')).toHaveLength(4)
expect(output).not.toContain('PROMPT_ARRAY_BACKSLASH:<__orca_')
expectLifecycle(output)
})
itWithBash.each([
['space', 'foo\\ ', 'foo '],
['semicolon', 'foo\\;', 'foo;']
])('preserves an escaped trailing %s', (_name, command, expected) => {
const profile = `PROMPT_COMMAND='printf "PROMPT_ESCAPED:<%s>\\n" ${command}'\n`
const output = runInteractiveBash(profile, tempHome)
expect(output.split(`PROMPT_ESCAPED:<${expected}>`)).toHaveLength(4)
expectLifecycle(output)
})
itWithBash.each([
['carriage return', '\\r', '\r'],
['vertical tab', '\\v', '\v'],
['form feed', '\\f', '\f']
])('preserves a trailing %s word byte', (_name, bashEscape, expected) => {
const profile = `PROMPT_COMMAND=$'printf "PROMPT_CONTROL:<%s>\\\\n" foo${bashEscape}'\n`
const output = runInteractiveBash(profile, tempHome)
expect(output.split(`PROMPT_CONTROL:<foo${expected}>`)).toHaveLength(4)
expectLifecycle(output)
})
itWithBash('passes the foreground exit status to the inherited hook', () => {
const profile = `__status_hook() { printf 'PROMPT_STATUS:%s\\n' "$?"; }\nPROMPT_COMMAND=__status_hook\n`
const output = runInteractiveBash(profile, tempHome)
expect([...output.matchAll(/PROMPT_STATUS:(\d+)/g)].map((match) => match[1])).toEqual([
'0',
'0',
'1'
])
expectLifecycle(output)
})
itWithBash('preserves BASH_REMATCH while normalizing a scalar hook', () => {
const profile = `[[ "seed:VALUE" =~ seed:(.*) ]]
PROMPT_COMMAND='printf "PROMPT_REMATCH:<%s>\\n" "\${BASH_REMATCH[1]-unset}"'
`
const output = runInteractiveBash(profile, tempHome)
expect(output.split('PROMPT_REMATCH:<VALUE>')).toHaveLength(4)
expectLifecycle(output)
})
itWithBash('passes BASH_REMATCH between array hooks', () => {
const profile = `PROMPT_COMMAND=('[[ "seed:VALUE" =~ seed:(.*) ]]' 'printf "PROMPT_ARRAY_REMATCH:<%s>\\n" "\${BASH_REMATCH[1]-unset}"')
`
const output = runInteractiveBash(profile, tempHome)
expect(output.split('PROMPT_ARRAY_REMATCH:<VALUE>')).toHaveLength(4)
expectLifecycle(output)
})
itWithBash('normalizes hooks without a DEBUG-trap command substitution', () => {
const profile = [
'set -T',
'trap \'printf "PROMPT_DEBUG:<%s>\\n" "$BASH_COMMAND"\' DEBUG',
'PROMPT_COMMAND=\'printf "PROMPT_HOOK\\n"; \''
].join('\n')
const output = runInteractiveBash(profile, tempHome)
expect(output.match(/PROMPT_HOOK\r?\n/g)).toHaveLength(3)
expect(output).not.toContain('PROMPT_DEBUG:<(( __orca_exit_code == 0 ))>')
expect(output).not.toContain('PROMPT_DEBUG:<__orca_restore_prompt_status')
expectLifecycle(output)
})
itWithBash('preserves DEBUG visibility for inherited prompt commands without functrace', () => {
const profile = [
'trap \'printf "PROMPT_DEBUG:<%s>\\n" "$BASH_COMMAND"\' DEBUG',
'PROMPT_COMMAND=\'printf "PROMPT_HOOK\\n"\''
].join('\n')
const output = runInteractiveBash(profile, tempHome)
expect(output.split('PROMPT_DEBUG:<printf "PROMPT_HOOK\\n">')).toHaveLength(4)
expect(output).not.toContain('PROMPT_DEBUG:<(( __orca_exit_code == 0 ))>')
expect(output).not.toContain('PROMPT_DEBUG:<__orca_restore_prompt_status')
expect(output).not.toContain('PROMPT_DEBUG:<eval "$__orca_prompt_part">')
expectLifecycle(output)
})
itWithBash('does not emit command lifecycle for an empty prompt', () => {
const profile = [
'trap \'printf "PROMPT_DEBUG:<%s>\\n" "$BASH_COMMAND"\' DEBUG',
'PROMPT_COMMAND=\'printf "PROMPT_HOOK\\n"\''
].join('\n')
const output = runInteractiveBash(profile, tempHome, '\ntrue\nfalse\nexit 0\n')
const lifecyclePattern = new RegExp(
`${String.fromCharCode(27)}]133;(?:A|C|D;[0-9]+)${String.fromCharCode(7)}`,
'g'
)
expect(output.match(lifecyclePattern)).toEqual([
'\x1b]133;A\x07',
'\x1b]133;A\x07',
'\x1b]133;C\x07',
'\x1b]133;D;0\x07',
'\x1b]133;A\x07',
'\x1b]133;C\x07',
'\x1b]133;D;1\x07',
'\x1b]133;A\x07',
'\x1b]133;C\x07'
])
expect(output).not.toContain('PROMPT_DEBUG:<__orca_prompt_status=')
expect(output).not.toContain('PROMPT_DEBUG:<__orca_prompt_had_functrace="">')
expect(output).not.toContain('PROMPT_DEBUG:<__orca_outer_debug_trap_spec=')
})
itWithBash('forwards a DEBUG trap replaced after startup', () => {
const profile = [
'trap \'printf "OLD_DEBUG:<%s>\\n" "$BASH_COMMAND"\' DEBUG',
'PROMPT_COMMAND=\'printf "PROMPT_HOOK\\n"\''
].join('\n')
const input = 'trap \'printf "NEW_DEBUG:<%s>\\n" "$BASH_COMMAND"\' DEBUG\ntrue\nexit 0\n'
const output = runInteractiveBash(profile, tempHome, input)
expect(output.split('OLD_DEBUG:<printf "PROMPT_HOOK\\n">')).toHaveLength(2)
expect(output.split('NEW_DEBUG:<printf "PROMPT_HOOK\\n">')).toHaveLength(3)
expectLifecycle(output, 0)
})
itWithBash('replaces the forwarded DEBUG trap with functrace enabled', () => {
const profile = [
'set -T',
'trap \'printf "OLD_FT:<%s>\\n" "$BASH_COMMAND"\' DEBUG',
'PROMPT_COMMAND=\'printf "HOOK_FT\\n"\''
].join('\n')
const input = 'trap \'printf "NEW_FT:<%s>\\n" "$BASH_COMMAND"\' DEBUG\ntrue\nexit 0\n'
const output = runInteractiveBash(profile, tempHome, input)
expect(output.split('OLD_FT:<printf "HOOK_FT\\n">')).toHaveLength(2)
expect(output.split('NEW_FT:<printf "HOOK_FT\\n">')).toHaveLength(3)
expect(output).not.toContain('OLD_FT:<true>')
expect(output).toContain('NEW_FT:<true>')
expectLifecycle(output, 0)
})
itWithBash('does not resurrect a removed DEBUG trap with functrace enabled', () => {
const profile = [
'set -T',
'trap \'printf "OLD_FT:<%s>\\n" "$BASH_COMMAND"\' DEBUG',
'PROMPT_COMMAND=\'printf "HOOK_FT\\n"\''
].join('\n')
const output = runInteractiveBash(profile, tempHome, 'trap - DEBUG\ntrue\nexit 0\n')
expect(output.split('OLD_FT:<printf "HOOK_FT\\n">')).toHaveLength(2)
expect(output).not.toContain('OLD_FT:<true>')
expectLifecycle(output, 0)
})
itWithBash('does not treat a DEBUG trap query as a mutation', () => {
const profile = [
'trap \'trap -p DEBUG >/dev/null; printf "QUERY:<%s>\\n" "$BASH_COMMAND"\' DEBUG',
'PROMPT_COMMAND=\'printf "HOOK_QUERY\\n"\''
].join('\n')
const output = runInteractiveBash(profile, tempHome)
expect(output).toContain('QUERY:<true>')
expect(output).toContain('QUERY:<false>')
expectLifecycle(output)
})
itWithBash('keeps lifecycle when RANDOM is unset under nounset', () => {
const profile = [
'unset RANDOM',
'set -u',
'trap \'printf "NOUNSET_DEBUG:<%s>\\n" "$BASH_COMMAND"\' DEBUG',
'PROMPT_COMMAND=\'printf "HOOK_NOUNSET\\n"\''
].join('\n')
const output = runInteractiveBash(profile, tempHome)
expect(output).not.toContain('unbound variable')
expect(output).toContain('NOUNSET_DEBUG:<true>')
expectLifecycle(output)
})
itWithBash('does not recurse when a user trap installs Orca preexec', () => {
const profile = [
`trap 'trap '\\''__orca_osc133_preexec'\\'' DEBUG 2>/dev/null; printf "PRIVATE_TRAP\\n"' DEBUG`,
'PROMPT_COMMAND=\'printf "HOOK_PRIVATE\\n"\''
].join('\n')
const output = runInteractiveBash(profile, tempHome)
expect(output).not.toContain('Segmentation fault')
expect(output).toContain('HOOK_PRIVATE')
expectLifecycle(output)
})
itWithBash('does not introduce RETURN traps for inherited prompt commands', () => {
const profile = [
'PROMPT_COMMAND=\'AFTER_FIRST_PROMPT=1; printf "PROMPT_HOOK\\n"\'',
'trap \'if [[ -n "${AFTER_FIRST_PROMPT:-}" ]]; then printf "PROMPT_RETURN:<%s>\\n" "${FUNCNAME[0]-}"; fi\' RETURN'
].join('\n')
const output = runInteractiveBash(profile, tempHome)
expect(output).not.toContain('PROMPT_RETURN:<__orca_run_prompt_command_array>')
expectLifecycle(output)
})
itWithBash('hides legacy dispatcher commands from a chained DEBUG trap', () => {
const profile = [
'__bp_preexec() { [[ -n "${__bp_armed:-}" ]] || return; __bp_armed=""; printf \'PROMPT_PREEXEC:<%s>\\n\' "$BASH_COMMAND"; }',
"trap '__bp_preexec' DEBUG",
'__bp_arm() { __bp_armed=1; }',
'PROMPT_COMMAND=__bp_arm'
].join('\n')
const output = runInteractiveBash(
profile,
tempHome,
'echo __orca_osc133_probe\nfalse\nexit 0\n'
)
const commands = [...output.matchAll(/PROMPT_PREEXEC:<([^>]+)>/g)].map((match) => match[1])
expect(commands).toEqual(['echo __orca_osc133_probe', 'false', 'exit 0'])
expectLifecycle(output)
})
itWithBash('does not treat user command text as a bash-preexec helper', () => {
const output = runInteractiveBash('', tempHome, 'echo a:__bp_x\nfalse\nexit 0\n')
expect(output).toContain('a:__bp_x')
expectLifecycle(output)
})
itWithBash.each([
['status capture', '__orca_prompt_status=$?'],
['trap capture', '__orca_outer_debug_trap_spec="$(trap -p DEBUG)"'],
['trap re-arm', 'trap "__orca_osc133_preexec" DEBUG']
])('does not suppress lifecycle for an exact foreground %s', (_name, command) => {
const output = runInteractiveBash('', tempHome, `${command}\nfalse\nexit 0\n`)
expectLifecycle(output)
})
itWithBash('emits lifecycle for a user command named like a bash-preexec helper', () => {
const output = runInteractiveBash(
'__bp_user_command() { printf "BP_USER_COMMAND\\n"; }',
tempHome,
'__bp_user_command\nfalse\nexit 0\n'
)
expect(output).toContain('BP_USER_COMMAND')
expectLifecycle(output)
})
})
+19 -18
View File
@@ -17,6 +17,7 @@ import {
attachStreamEventReader
} from './daemon-client-ndjson-readers'
import { writeNotifyWithSettlement } from './daemon-client-notify-settlement'
import { requestDaemonRpc } from './daemon-client-rpc-request'
const CONNECT_TIMEOUT_MS = 5000
const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4
@@ -197,29 +198,29 @@ export class DaemonClient {
async request<T = unknown>(
type: string,
payload: unknown,
timeoutMs = REQUEST_TIMEOUT_MS
timeoutMs = REQUEST_TIMEOUT_MS,
signal?: AbortSignal
): Promise<T> {
if (!this.connected || !this.controlSocket) {
throw new DaemonProtocolError('Not connected')
}
const generation = this.connectionGeneration
const id = `req-${++this.requestCounter}`
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
const encoded = encodeNdjson(msg)
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.drop(id)
reject(new DaemonProtocolError(`Request ${type} timed out after ${timeoutMs}ms`))
}, timeoutMs)
this.pendingRequests.add(id, {
resolve: resolve as (value: unknown) => void,
reject,
timer
})
this.controlSocket!.write(encoded)
return requestDaemonRpc<T>({
socket: this.controlSocket,
pendingRequests: this.pendingRequests,
id: `req-${++this.requestCounter}`,
type,
payload,
timeoutMs,
...(signal ? { signal } : {}),
onCreateCancellationFailure: () => this.handleDisconnect(generation),
settleCreateCancellation: (sessionId, requestId) =>
this.request<{ canceled: boolean }>(
'cancelCreateOrAttach',
{ sessionId, requestId },
NOTIFY_SETTLEMENT_TIMEOUT_MS
)
})
}
@@ -1,5 +1,6 @@
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight'
import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition'
import { SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates'
import { SHELL_READY_MARKER } from './daemon-shell-ready-marker'
@@ -39,6 +40,7 @@ ${getPosixCodexShellLaunchPreflight()}
# status when the foreground command exits — mirrors the zsh daemon wrapper.
# Without this, bash users (default on most Linux distros) keep a stuck
# 'working' spinner after the CLI exits without a Stop/SessionEnd hook.
__orca_initializing_wrapper=1
__orca_osc133_precmd() {
local exit_code=$?
__orca_in_prompt_command=1
@@ -51,13 +53,42 @@ __orca_osc133_precmd() {
# so a framework that must be last in PROMPT_COMMAND — bash-preexec — is not
# displaced by one of Orca's own hooks.
[[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]] && printf "${SHELL_READY_MARKER}"
}
__orca_run_user_debug_trap() {
if [[ -n "\${__orca_user_debug_trap:-}" ]]; then
eval "$__orca_user_debug_trap" || true
fi
return "$exit_code"
}
__orca_osc133_preexec() {
if [[ -n "\${__orca_prompt_status_capture_command:-}" && "$BASH_COMMAND" == "$__orca_prompt_status_capture_command" ]]; then
unset __orca_initial_prompt
__orca_in_legacy_prompt_wrapper=1
return 0
fi
if [[ -n "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}\${__orca_initial_prompt:-}\${__orca_in_prompt_dispatch:-}\${__orca_in_legacy_prompt_wrapper:-}\${__orca_in_prompt_command:-}" ]]; then
[[ -z "\${__orca_initializing_wrapper:-}\${__orca_in_debug_capture:-}" ]] || return 0
if [[ -n "\${__orca_initial_prompt:-}" && "$BASH_COMMAND" == "__orca_osc133_precmd" ]]; then
unset __orca_initial_prompt; return 0
fi
if [[ -n "\${__orca_in_prompt_dispatch:-}" ]]; then
[[ -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0
if [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" ]]; then
case "$BASH_COMMAND" in
'(( __orca_exit_code == 0 ))'|'__orca_restore_prompt_status "$__orca_exit_code"'|'eval "$__orca_prompt_part"'|'eval "$__orca_final_prompt_command"'|__orca_dispatching_user_prompt_command=*|__orca_osc133_precmd|__orca_osc133_epilogue) return 0 ;;
esac
fi
elif [[ "\${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" || "$BASH_COMMAND" == "__orca_run_prompt_command_array" ]]; then
return 0
fi
[[ -z "\${__orca_in_legacy_prompt_wrapper:-}" || -n "\${__orca_dispatching_user_prompt_command:-}" ]] || return 0
if [[ -n "\${__orca_in_prompt_command:-}" && "$BASH_COMMAND" == "__orca_in_debug_capture=1" ]]; then
return 0
fi
fi
case "\${FUNCNAME[1]:-}" in
__orca_osc133_*|__orca_restore_prompt_status|__bp_*) return 0 ;;
esac
case "$BASH_COMMAND" in
__orca_osc133_precmd|__orca_osc133_epilogue) return 0 ;;
# The prefix is only special while bash-preexec prompt hooks run.
__bp_*) [[ -n "\${__orca_in_prompt_command:-}" ]] && return 0 ;;
esac
__orca_run_user_debug_trap
# Why: a framework (bash-preexec/starship) may replace our DEBUG trap at the
# first prompt; __orca_osc133_epilogue re-takes it each prompt and stores the
@@ -66,62 +97,41 @@ __orca_osc133_preexec() {
if [[ -n "\${__orca_chained_debug_trap:-}" ]]; then
eval "$__orca_chained_debug_trap" || true
fi
[[ -z "\${__orca_in_prompt_command:-}" ]] || return
[[ -z "\${__orca_in_prompt_command:-}" ]] || return 0
# Why: a chained trap can invoke us more than once for a single command, so
# emit C only on the first fire (the __orca_in_command gate), and never for a
# prompt-time hook — ours or bash-preexec's __bp_* helpers.
[[ -z "\${__orca_in_command:-}" ]] || return
case "$BASH_COMMAND" in
*__orca_osc133_*|*__bp_*) return ;;
esac
[[ -z "\${__orca_in_command:-}" ]] || return 0
printf "\\033]133;C\\007"
__orca_in_command=1
}
# Why: runs LAST every prompt — closes the prompt window (so command starts emit
# C) and re-arms our single DEBUG trap. A framework that replaced DEBUG at the
# first prompt is captured and chained rather than discarded, so it keeps working
# while its re-arm can no longer silence Orca's command-start signal.
# Why: adopt the latest user trap before Orca retakes lifecycle ownership.
__orca_osc133_epilogue() {
unset __orca_in_prompt_command
local __orca_spec="$(trap -p DEBUG)"
case "$__orca_spec" in
"" | *__orca_osc133_preexec* ) __orca_chained_debug_trap="" ;;
* )
__orca_spec="\${__orca_spec#trap -- }"
__orca_spec="\${__orca_spec% DEBUG}"
eval "__orca_chained_debug_trap=$__orca_spec"
;;
esac
__orca_adopt_outer_debug_trap
trap '__orca_osc133_preexec' DEBUG
}
# Why: normalize an array PROMPT_COMMAND (bash 5.1+) to a string so prepend/append
# below is uniform, and capture $? in precmd before the user's chain mutates it.
__orca_normalize_prompt_command() {
local __orca_joined="" __orca_prompt_part
if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
for __orca_prompt_part in "\${PROMPT_COMMAND[@]}"; do
[[ -n "$__orca_prompt_part" ]] || continue
if [[ -n "$__orca_joined" ]]; then
__orca_joined="$__orca_joined;$__orca_prompt_part"
else
__orca_joined="$__orca_prompt_part"
fi
done
PROMPT_COMMAND="$__orca_joined"
fi
}
__orca_normalize_prompt_command
PROMPT_COMMAND="__orca_osc133_precmd\${PROMPT_COMMAND:+;\${PROMPT_COMMAND}};__orca_osc133_epilogue"
${BASH_PROMPT_COMMAND_COMPOSITION_BLOCK}
__orca_prepend_prompt_command "__orca_osc133_precmd"
__orca_append_prompt_command '__orca_in_debug_capture=1; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace __orca_in_debug_capture'
__orca_append_prompt_command "__orca_osc133_epilogue"
__orca_had_functrace=""
[[ -o functrace ]] && __orca_had_functrace=1
set +T
__orca_debug_trap_spec="$(trap -p DEBUG)"
if [[ -n "$__orca_debug_trap_spec" ]]; then
[[ -z "$__orca_had_functrace" ]] || set -T
if [[ -n "$__orca_debug_trap_spec" && "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]]; then
__orca_debug_trap_command="\${__orca_debug_trap_spec#trap -- }"
__orca_debug_trap_command="\${__orca_debug_trap_command% DEBUG}"
eval "__orca_user_debug_trap=$__orca_debug_trap_command"
fi
unset __orca_debug_trap_spec __orca_debug_trap_command
unset -f __orca_normalize_prompt_command
unset __orca_debug_trap_spec __orca_debug_trap_command __orca_had_functrace
unset -f __orca_normalize_prompt_command_part __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command
unset __orca_prompt_command_normalized
# Why: arm DEBUG after wrapper setup; otherwise bash treats our own rcfile
# commands as a foreground command and emits a fake C/D before the first prompt.
__orca_initial_prompt=1
trap '__orca_osc133_preexec' DEBUG
unset __orca_initializing_wrapper
`
}
@@ -0,0 +1,107 @@
import type { Socket } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DaemonPendingRequests } from './daemon-client-pending-requests'
import { requestDaemonRpc } from './daemon-client-rpc-request'
describe('requestDaemonRpc', () => {
afterEach(() => {
vi.useRealTimers()
})
it('keeps a completed spawn result when cancellation arrives too late', async () => {
const pendingRequests = new DaemonPendingRequests()
const abort = new AbortController()
let finishCancellation: (result: { canceled: boolean }) => void = () => {}
const cancellation = new Promise<{ canceled: boolean }>((resolve) => {
finishCancellation = resolve
})
const request = requestDaemonRpc<{ isNew: boolean }>({
socket: { write: vi.fn() } as unknown as Socket,
pendingRequests,
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'completed-spawn' },
timeoutMs: 30_000,
signal: abort.signal,
onCreateCancellationFailure: vi.fn(),
settleCreateCancellation: () => cancellation
})
abort.abort()
finishCancellation({ canceled: false })
pendingRequests.settle({ id: 'req-1', ok: true, payload: { isNew: true } })
await expect(request).resolves.toEqual({ isNew: true })
})
it('keeps a completed spawn result when its deadline cancellation arrives too late', async () => {
vi.useFakeTimers()
const pendingRequests = new DaemonPendingRequests()
const settleCreateCancellation = vi.fn(async () => ({ canceled: false }))
const request = requestDaemonRpc<{ isNew: boolean }>({
socket: { write: vi.fn() } as unknown as Socket,
pendingRequests,
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'completed-spawn' },
timeoutMs: 10,
onCreateCancellationFailure: vi.fn(),
settleCreateCancellation
})
await vi.advanceTimersByTimeAsync(10)
expect(settleCreateCancellation).toHaveBeenCalledWith('completed-spawn', 'req-1')
pendingRequests.settle({ id: 'req-1', ok: true, payload: { isNew: true } })
await expect(request).resolves.toEqual({ isNew: true })
})
it('rejects a timed-out spawn after the daemon confirms cancellation', async () => {
vi.useFakeTimers()
const pendingRequests = new DaemonPendingRequests()
const request = requestDaemonRpc({
socket: { write: vi.fn() } as unknown as Socket,
pendingRequests,
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'pending-spawn' },
timeoutMs: 10,
onCreateCancellationFailure: vi.fn(),
settleCreateCancellation: vi.fn(async () => ({ canceled: true }))
})
const rejected = expect(request).rejects.toThrow('Request createOrAttach timed out after 10ms')
await vi.advanceTimersByTimeAsync(10)
await rejected
expect(pendingRequests.size).toBe(0)
})
it('disconnects when spawn cancellation cannot be confirmed', async () => {
const pendingRequests = new DaemonPendingRequests()
const abort = new AbortController()
const onCreateCancellationFailure = vi.fn(() => {
pendingRequests.rejectAll('Connection lost')
})
const request = requestDaemonRpc({
socket: { write: vi.fn() } as unknown as Socket,
pendingRequests,
id: 'req-1',
type: 'createOrAttach',
payload: { sessionId: 'unconfirmed-spawn' },
timeoutMs: 30_000,
signal: abort.signal,
onCreateCancellationFailure,
settleCreateCancellation: vi.fn(async () => {
throw new Error('settlement failed')
})
})
const rejected = expect(request).rejects.toThrow('Connection lost')
abort.abort()
await rejected
expect(onCreateCancellationFailure).toHaveBeenCalledOnce()
expect(pendingRequests.size).toBe(0)
})
})
@@ -0,0 +1,107 @@
import type { Socket } from 'node:net'
import { encodeNdjson } from './ndjson'
import { DaemonProtocolError } from './types'
import type { DaemonPendingRequests } from './daemon-client-pending-requests'
type DaemonRpcRequestOptions = {
socket: Socket
pendingRequests: DaemonPendingRequests
id: string
type: string
payload: unknown
timeoutMs: number
signal?: AbortSignal
onCreateCancellationFailure: () => void
settleCreateCancellation: (sessionId: string, requestId: string) => Promise<{ canceled: boolean }>
}
export function requestDaemonRpc<T>(opts: DaemonRpcRequestOptions): Promise<T> {
const { payload, type } = opts
const createSessionId =
type === 'createOrAttach' && payload !== null && typeof payload === 'object'
? Reflect.get(payload, 'sessionId')
: null
const requestPayload =
type === 'createOrAttach' && payload !== null && typeof payload === 'object'
? { ...payload, cancelAfterMs: Math.max(1, opts.timeoutMs - 100) }
: payload
const encoded = encodeNdjson({
id: opts.id,
type,
...(requestPayload !== undefined ? { payload: requestPayload } : {})
})
return new Promise<T>((resolve, reject) => {
let sent = false
let cancellationStarted = false
let settled = false
const removeAbortListener = (): void => opts.signal?.removeEventListener('abort', onAbort)
const rejectAndDrop = (error: Error): void => {
if (settled) {
return
}
settled = true
opts.pendingRequests.drop(opts.id)
removeAbortListener()
clearTimeout(timer)
reject(error)
}
const cancelCreate = (error: Error): void => {
if (cancellationStarted) {
return
}
cancellationStarted = true
clearTimeout(timer)
if (!sent || typeof createSessionId !== 'string') {
rejectAndDrop(error)
return
}
void opts
.settleCreateCancellation(createSessionId, opts.id)
.then((result) => {
if (result.canceled) {
rejectAndDrop(error)
}
})
.catch(() => {
if (!settled) {
opts.onCreateCancellationFailure()
}
})
}
const timer = setTimeout(() => {
const error = new DaemonProtocolError(`Request ${type} timed out after ${opts.timeoutMs}ms`)
if (typeof createSessionId === 'string') {
cancelCreate(error)
} else {
rejectAndDrop(error)
}
}, opts.timeoutMs)
const onAbort = (): void => {
removeAbortListener()
cancelCreate(new Error('client_disconnected'))
}
opts.pendingRequests.add(opts.id, {
resolve: (value) => {
settled = true
removeAbortListener()
resolve(value as T)
},
reject: (error) => {
settled = true
removeAbortListener()
reject(error)
},
timer
})
opts.signal?.addEventListener('abort', onAbort, { once: true })
if (opts.signal?.aborted) {
onAbort()
return
}
sent = true
opts.socket.write(encoded)
})
}
@@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(34)
expect(PROTOCOL_VERSION).toBe(35)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
@@ -17,5 +17,6 @@ describe('foreground-confirmation daemon protocol', () => {
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(31)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(32)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(33)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(34)
})
})
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION,
CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
@@ -17,7 +18,8 @@ import {
describe('daemon protocol version', () => {
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(34)
expect(PROTOCOL_VERSION).toBe(35)
expect(ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION).toBe(35)
expect(CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION).toBe(34)
expect(WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION).toBe(33)
expect(SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION).toBe(32)
@@ -29,7 +31,7 @@ describe('daemon protocol version', () => {
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 33 }, (_, index) => index + 1)
Array.from({ length: 34 }, (_, index) => index + 1)
)
})
+4 -3
View File
@@ -1,6 +1,7 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v34 prepares Codex hook trust at the shell launch boundary; older owners remain attachable.
export const PROTOCOL_VERSION = 34
// v35 makes cwd validation async with request-scoped spawn cancellation; older owners stay attachable.
export const PROTOCOL_VERSION = 35
export const ASYNC_CWD_VALIDATION_DAEMON_PROTOCOL_VERSION = 35
export const CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION = 34
export const WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION = 33
export const SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION = 32
@@ -28,7 +29,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28, 29, 30, 31, 32, 33
28, 29, 30, 31, 32, 33, 34
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
+56 -4
View File
@@ -66,6 +66,9 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import { resolveUnixShellPath } from '../providers/local-pty-utils'
import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history'
import { addWslEnvKeys } from '../wsl-env'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache'
import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue'
@@ -425,7 +428,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId)
const spawnOpts = this.withHistoryIsolation(opts)
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
const operation = {
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
ignoredExitIncarnationIds: new Set<string>(),
@@ -444,7 +448,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
try {
return await this.withHistorySpawnLock(sessionId, () =>
this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, operation, historyRecovery))
this.withDaemonRetry(() =>
this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery)
)
)
} finally {
if (historyRecovery.freeze) {
@@ -458,6 +464,44 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
}
private withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions {
const wslContext = resolveWslSessionContext({
cwd: opts.cwd,
sessionId: opts.sessionId,
shellOverride: opts.shellOverride,
terminalWindowsWslDistro: opts.terminalWindowsWslDistro
})
if (
opts.attachOnly === true ||
(opts.sessionId !== undefined && opts.isNewSession !== true) ||
!opts.worktreeId ||
opts.historyIsolationEnabled !== true ||
(process.platform === 'win32' && !wslContext)
) {
return opts
}
const env = { ...opts.env }
const preferredShell = wslContext
? 'bash'
: opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh'
const shellPath = resolveUnixShellPath(preferredShell)
const historyArgs = [
env,
opts.worktreeId,
shellPath,
opts.cwd ?? resolveSafePtyDefaultCwd()
] as const
const result = wslContext
? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro })
: injectHistoryEnv(...historyArgs)
if (wslContext) {
injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro)
addWslEnvKeys(env, ['HISTFILE', 'fish_history'])
}
logHistoryInjection(opts.worktreeId, result)
return { ...opts, env }
}
private async doSpawn(
opts: PtySpawnOptions,
operation: PendingDaemonSpawnOperation,
@@ -588,7 +632,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (opts.signal?.aborted) {
throw new Error('client_disconnected')
}
return this.client.request<CreateOrAttachResult>('createOrAttach', {
const payload = {
sessionId,
cols: effectiveCols,
rows: effectiveRows,
@@ -615,7 +659,15 @@ export class DaemonPtyAdapter implements IPtyProvider {
...(!attachOnly && opts.agentSessionEnsure
? { agentSessionEnsure: opts.agentSessionEnsure }
: {})
})
}
return opts.signal
? this.client.request<CreateOrAttachResult>(
'createOrAttach',
payload,
undefined,
opts.signal
)
: this.client.request<CreateOrAttachResult>('createOrAttach', payload)
}
const createOrAttach = async (
+7 -2
View File
@@ -409,7 +409,7 @@ describe('DaemonPtyRouter', () => {
expect(current.confirmForegroundProcess).toHaveBeenCalledWith('current-session')
})
it('preserves older session owners and routes new sessions to v34', async () => {
it('preserves older session owners and routes new sessions to v35', async () => {
const current = createAdapter('current', [], undefined, PROTOCOL_VERSION)
const legacyV30 = createAdapter(
'v30',
@@ -425,9 +425,10 @@ describe('DaemonPtyRouter', () => {
)
const legacyV32 = createAdapter('v32', ['v32-session'], undefined, 32)
const legacyV33 = createAdapter('v33', ['v33-session'], undefined, 33)
const legacyV34 = createAdapter('v34', ['v34-session'], undefined, 34)
const router = new DaemonPtyRouter({
current,
legacy: [legacyV30, legacyV31, legacyV32, legacyV33]
legacy: [legacyV30, legacyV31, legacyV32, legacyV33, legacyV34]
})
await router.discoverLegacySessions()
@@ -436,22 +437,26 @@ describe('DaemonPtyRouter', () => {
await router.spawn({ sessionId: 'v31-session', cols: 80, rows: 24 })
await router.spawn({ sessionId: 'v32-session', cols: 80, rows: 24 })
await router.spawn({ sessionId: 'v33-session', cols: 80, rows: 24 })
await router.spawn({ sessionId: 'v34-session', cols: 80, rows: 24 })
const fresh = await router.spawn({ cols: 80, rows: 24 })
router.write('v30-session', 'old-v30\n')
router.write('v31-session', 'old-v31\n')
router.write('v32-session', 'old-v32\n')
router.write('v33-session', 'old-v33\n')
router.write('v34-session', 'old-v34\n')
router.write(fresh.id, 'new\n')
expect(legacyV30.spawn).toHaveBeenCalledWith({ sessionId: 'v30-session', cols: 80, rows: 24 })
expect(legacyV31.spawn).toHaveBeenCalledWith({ sessionId: 'v31-session', cols: 80, rows: 24 })
expect(legacyV32.spawn).toHaveBeenCalledWith({ sessionId: 'v32-session', cols: 80, rows: 24 })
expect(legacyV33.spawn).toHaveBeenCalledWith({ sessionId: 'v33-session', cols: 80, rows: 24 })
expect(legacyV34.spawn).toHaveBeenCalledWith({ sessionId: 'v34-session', cols: 80, rows: 24 })
expect(current.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 })
expect(legacyV30.write).toHaveBeenCalledWith('v30-session', 'old-v30\n')
expect(legacyV31.write).toHaveBeenCalledWith('v31-session', 'old-v31\n')
expect(legacyV32.write).toHaveBeenCalledWith('v32-session', 'old-v32\n')
expect(legacyV33.write).toHaveBeenCalledWith('v33-session', 'old-v33\n')
expect(legacyV34.write).toHaveBeenCalledWith('v34-session', 'old-v34\n')
expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n')
})
@@ -0,0 +1,183 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DaemonClient } from './client'
import { createMockSubprocess } from './daemon-pty-adapter-test-harness'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
describe('daemon async spawn cancellation', () => {
let directory: string
let server: DaemonServer
let client: DaemonClient
let releaseSpawn: () => void
let spawnStarted: Promise<void>
let subprocess: ReturnType<typeof createMockSubprocess>
beforeEach(async () => {
directory = mkdtempSync(join(tmpdir(), 'daemon-async-spawn-cancel-'))
let markSpawnStarted: () => void = () => {}
spawnStarted = new Promise<void>((resolve) => {
markSpawnStarted = resolve
})
const spawnGate = new Promise<void>((resolve) => {
releaseSpawn = resolve
})
server = new DaemonServer({
socketPath: getDaemonSocketPath(directory),
tokenPath: join(directory, 'daemon.token'),
spawnSubprocess: async () => {
subprocess = createMockSubprocess()
markSpawnStarted()
await spawnGate
return subprocess
}
})
await server.start()
client = new DaemonClient({
socketPath: getDaemonSocketPath(directory),
tokenPath: join(directory, 'daemon.token')
})
await client.ensureConnected()
})
afterEach(async () => {
releaseSpawn?.()
client?.disconnect()
await server?.shutdown()
rmSync(directory, { recursive: true, force: true })
})
it.each(['cancelCreateOrAttach', 'kill'] as const)(
'reaps a subprocess canceled by %s during async spawn',
async (requestType) => {
const create = client.request('createOrAttach', {
sessionId: 'canceled-spawn',
cols: 80,
rows: 24
})
const canceled = expect(create).rejects.toThrow('Attach canceled for session canceled-spawn')
await spawnStarted
await (requestType === 'kill'
? client.request('kill', { sessionId: 'canceled-spawn', immediate: true })
: client.request('cancelCreateOrAttach', { sessionId: 'canceled-spawn' }))
releaseSpawn()
await canceled
expect(subprocess.forceKill).toHaveBeenCalledOnce()
await expect(client.request('listSessions', undefined)).resolves.toEqual({ sessions: [] })
}
)
it('reaps a subprocess after its requesting client disconnects', async () => {
const create = client
.request('createOrAttach', {
sessionId: 'disconnected-spawn',
cols: 80,
rows: 24
})
.catch(() => undefined)
await spawnStarted
client.disconnect()
const daemon = server as unknown as {
pendingPtySpawnPreparations: Map<string, Set<{ canceled: boolean }>>
host: { listSessions: () => unknown[] }
}
await vi.waitFor(() =>
expect(
[...(daemon.pendingPtySpawnPreparations.get('disconnected-spawn') ?? [])][0]?.canceled
).toBe(true)
)
releaseSpawn()
await create
await vi.waitFor(() => expect(subprocess.forceKill).toHaveBeenCalledOnce())
expect(daemon.host.listSessions()).toEqual([])
})
it('reaps a subprocess after the client request times out', async () => {
const create = client.request(
'createOrAttach',
{ sessionId: 'timed-out-spawn', cols: 80, rows: 24 },
10
)
await spawnStarted
await expect(create).rejects.toThrow('timed out')
releaseSpawn()
await vi.waitFor(() => expect(subprocess.forceKill).toHaveBeenCalledOnce())
await expect(client.request('listSessions', undefined)).resolves.toEqual({ sessions: [] })
})
it('reaps a subprocess after the client aborts its request', async () => {
const abort = new AbortController()
const create = client.request(
'createOrAttach',
{ sessionId: 'aborted-spawn', cols: 80, rows: 24 },
30_000,
abort.signal
)
await spawnStarted
abort.abort()
await expect(create).rejects.toThrow('client_disconnected')
releaseSpawn()
await vi.waitFor(() => expect(subprocess.forceKill).toHaveBeenCalledOnce())
await expect(client.request('listSessions', undefined)).resolves.toEqual({ sessions: [] })
})
it('does not cancel a sibling request for the same session id', async () => {
const first = client.request('createOrAttach', {
sessionId: 'shared-session',
cols: 80,
rows: 24
})
const abort = new AbortController()
const second = client.request(
'createOrAttach',
{ sessionId: 'shared-session', cols: 80, rows: 24 },
30_000,
abort.signal
)
await spawnStarted
const daemon = server as unknown as {
pendingPtySpawnPreparations: Map<string, Set<unknown>>
}
await vi.waitFor(() =>
expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2)
)
abort.abort()
await expect(second).rejects.toThrow('client_disconnected')
releaseSpawn()
await expect(first).resolves.toMatchObject({ isNew: true })
expect(subprocess.forceKill).not.toHaveBeenCalled()
await expect(client.request('listSessions', undefined)).resolves.toMatchObject({
sessions: [expect.objectContaining({ sessionId: 'shared-session' })]
})
})
it('reaps an async spawn before daemon shutdown completes', async () => {
const create = client
.request('createOrAttach', {
sessionId: 'shutdown-spawn',
cols: 80,
rows: 24
})
.catch(() => undefined)
await spawnStarted
const shutdown = server.shutdown()
releaseSpawn()
await create
await shutdown
expect(subprocess.forceKill).toHaveBeenCalledOnce()
expect(subprocess.dispose).toHaveBeenCalledOnce()
})
})
+3 -1
View File
@@ -263,7 +263,9 @@ describe('DaemonServer', () => {
requestType === 'kill'
? c.request('kill', { sessionId: 'canceled-preparation', immediate: true })
: c.request('cancelCreateOrAttach', { sessionId: 'canceled-preparation' })
await expect(cancelRequest).resolves.toEqual({})
await expect(cancelRequest).resolves.toEqual(
requestType === 'kill' ? {} : { canceled: true }
)
finishPreparation()
await canceledCreates
expect(spawnSubprocess).not.toHaveBeenCalled()
+73 -13
View File
@@ -86,7 +86,9 @@ export type DaemonServerOptions = {
env?: Record<string, string>
command?: string
shellOverride?: string
}) => SubprocessHandle
isCanceled?: () => boolean
// Async production spawns and sync test stubs share this boundary.
}) => SubprocessHandle | Promise<SubprocessHandle>
}
type ConnectedClient = {
@@ -98,9 +100,11 @@ type ConnectedClient = {
type PendingPtySpawnPreparation = {
canceled: boolean
cancelTimer?: ReturnType<typeof setTimeout>
// Why: preparations are keyed by sessionId, but a control-socket close must
// cancel only the disconnecting client's preps, not another client's (F4).
clientId: string
requestId: string
}
type PendingShutdownReply = {
@@ -900,10 +904,25 @@ export class DaemonServer {
this.pendingShutdownReplies.set(key, { start })
}
private async preparePtySpawnUnlessCanceled(sessionId: string, clientId: string): Promise<void> {
private async preparePtySpawnUnlessCanceled(
sessionId: string,
clientId: string,
requestId: string,
cancelAfterMs: unknown
): Promise<PendingPtySpawnPreparation> {
const preparation: PendingPtySpawnPreparation = {
canceled: false,
clientId
clientId,
requestId
}
if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) {
preparation.cancelTimer = setTimeout(
() => {
preparation.canceled = true
},
Math.min(Number(cancelAfterMs), 300_000)
)
preparation.cancelTimer.unref()
}
const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set()
pending.add(preparation)
@@ -914,23 +933,48 @@ export class DaemonServer {
if (preparation.canceled) {
throw new TerminalAttachCanceledError(sessionId)
}
} finally {
pending.delete(preparation)
if (pending.size === 0) {
this.pendingPtySpawnPreparations.delete(sessionId)
}
return preparation
} catch (error) {
this.finishPtySpawnPreparation(sessionId, preparation)
throw error
}
}
private cancelPendingPtySpawnPreparations(sessionId: string): boolean {
private finishPtySpawnPreparation(
sessionId: string,
preparation: PendingPtySpawnPreparation
): void {
if (preparation.cancelTimer) {
clearTimeout(preparation.cancelTimer)
}
const pending = this.pendingPtySpawnPreparations.get(sessionId)
pending?.delete(preparation)
if (pending?.size === 0) {
this.pendingPtySpawnPreparations.delete(sessionId)
}
}
private cancelPendingPtySpawnPreparations(
sessionId: string,
request?: { clientId: string; requestId?: string }
): boolean {
const pending = this.pendingPtySpawnPreparations.get(sessionId)
if (!pending) {
return false
}
let canceled = false
for (const preparation of pending) {
if (
request &&
(preparation.clientId !== request.clientId ||
(request.requestId !== undefined && preparation.requestId !== request.requestId))
) {
continue
}
preparation.canceled = true
canceled = true
}
return true
return canceled
}
private cancelAllPendingPtySpawnPreparations(): void {
@@ -1033,6 +1077,7 @@ export class DaemonServer {
this.createOrAttachInFlight++
let routedSessionId = p.sessionId
let result: Awaited<ReturnType<TerminalHost['createOrAttach']>>
let spawnPreparation: PendingPtySpawnPreparation | null = null
try {
if (
p.agentSessionEnsure !== undefined &&
@@ -1042,7 +1087,12 @@ export class DaemonServer {
throw new Error('agent_session_identity_required')
}
if (!attachOnly) {
await this.preparePtySpawnUnlessCanceled(p.sessionId, clientId)
spawnPreparation = await this.preparePtySpawnUnlessCanceled(
p.sessionId,
clientId,
request.id,
p.cancelAfterMs
)
}
if (p.historySeed !== undefined && p.historySeedTransferId !== undefined) {
throw new Error('Multiple terminal history seed sources')
@@ -1075,6 +1125,7 @@ export class DaemonServer {
? { shellReadyTimeoutMs: p.shellReadyTimeoutMs }
: {}),
...(p.agentSessionEnsure ? { agentSessionEnsure: p.agentSessionEnsure } : {}),
...(spawnPreparation ? { isCanceled: () => spawnPreparation?.canceled === true } : {}),
onSessionResolved: (sessionId) => {
routedSessionId = sessionId
},
@@ -1121,6 +1172,9 @@ export class DaemonServer {
}
})
} finally {
if (spawnPreparation) {
this.finishPtySpawnPreparation(p.sessionId, spawnPreparation)
}
this.createOrAttachInFlight--
this.reevaluateIdleShutdown()
}
@@ -1163,8 +1217,14 @@ export class DaemonServer {
}
case 'cancelCreateOrAttach':
this.cancelPendingPtySpawnPreparations(request.payload.sessionId)
return {}
return {
canceled: this.cancelPendingPtySpawnPreparations(request.payload.sessionId, {
clientId,
...(typeof request.payload.requestId === 'string'
? { requestId: request.payload.requestId }
: {})
})
}
case 'closeStartupQueryAuthority':
return {
@@ -1,6 +1,8 @@
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight'
import { getZshFinalZdotdirRestoreBlock, getZshStartupFileSourceBlock } from '../shell-templates'
import { getZshFinalZdotdirRestoreBlock, getZshStartupFileSourceBlock,
ZSH_HISTFILE_RESTORE_BLOCK
} from '../shell-templates'
export function getDaemonZshShellReadyRcfileContent(): string {
return `# Orca daemon zsh shell-ready wrapper
@@ -23,6 +25,7 @@ if [[ ! -o login ]]; then
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${ZSH_HISTFILE_RESTORE_BLOCK}
${getPosixCodexShellLaunchPreflight()}
fi
__orca_osc133_precmd() {

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