mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
Merge remote-tracking branch 'origin/main' into nwparker/sta-4319-ssh-host-key-verification
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,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
@@ -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'],
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user