Merge origin/main (v1.4.130) into orca-performance

36 commits from main incl. the v1.4.130 release. Two conflicts, both the
branch-guide pattern (our restructured shape wins; main's new semantics graft in):

- pty-dispatcher.ts (eager-buffer exitHandler): kept our releaseDeliveryInterest()
  (hidden-delivery-gate ref-count invariant) AND grafted main's identity guard
  (#7894 detach/attach remount race) around the handler deletions.
- orca-runtime.ts: (1) kept our isCursorNativeAgentTitle import; (2) OSC-title
  path — kept our 4-arg trackHeadlessTerminalData(...forwardQueryReplies) and the
  shared per-PTY title tracker, dropped main's superseded inline OSC extraction.

Grafting main's #7863 Grok/#7880 Pi title-normalization required a real fix, not
just keeping our block: the runtime's normalizeTerminalTitle re-export chain
(agent-detection → agent-title-status.ts) lacked the Grok-collapse branch main
added to the sibling terminal-title-display.ts impl. Added isGrokRotatingWorkingTitle
collapsing to agent-title-status.ts and made applyTrackedPtyTitle store the
NORMALIZED title (matching main's inline block) — restores 4 Grok/Pi runtime tests.

Gates: typecheck 0 errors; lint 0 errors; terminal-pane+ipc/pty+daemon+pane-manager
4574 passed; runtime+renderer-runtime+shared 4063 passed / 0 failed.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-09 14:13:07 -04:00
co-authored by Orca
285 changed files with 23698 additions and 2437 deletions
+330 -1
View File
@@ -737,6 +737,10 @@ jobs:
~/.cache/electron-builder
runs-on: ${{ matrix.os }}
# Why: hosted runners hard-cap jobs at 6h; the Windows SignPath waits
# (1h inner + 4h installer) are budgeted to fit under this with the
# build itself, so a slow approval can't kill the job mid-flow.
timeout-minutes: 360
permissions:
actions: read
@@ -979,6 +983,244 @@ jobs:
}
}
# ── Windows inner-binary signing (issue #7785) ─────────────────────
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE
# files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip
# request, then the installer is rebuilt from the signed tree before the
# existing installer signing request below. Every step in this chain is
# fail-open (continue-on-error + outcome gating): any failure ships the
# original installer with unsigned inner binaries, exactly like releases
# did before this chain existed. Rehearsed end to end in run 28988432001
# (.github/workflows/windows-signing-rehearsal.yml).
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
- name: Stage unsigned inner PE files for signing
id: stage-inner
if: matrix.platform == 'win'
continue-on-error: true
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
$list = New-Object System.Collections.Generic.List[string]
$skipped = New-Object System.Collections.Generic.List[string]
Get-ChildItem -Path $root -Recurse -File |
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
if ($signature.Status -eq 'Valid') {
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
return
}
$destination = Join-Path $stage.FullName $relative
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
Copy-Item -Path $_.FullName -Destination $destination -Force
$list.Add($relative)
}
if (-not ($list -contains 'Orca.exe')) {
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
}
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
}
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
$list | ForEach-Object { Write-Host " $_" }
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success'
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }}
path: signing-stage/**
if-no-files-found: error
- name: Submit inner binaries signing request
id: submit-inner-signing
if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success'
continue-on-error: true
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: release-signing
artifact-configuration-slug: windows-inner-binaries-zip
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
wait-for-completion: false
- name: Notify Slack that inner-binary signing is waiting for approval
id: notify-inner-signing
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }}
TAG: ${{ needs.cut.outputs.tag }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
}
$requestUrl = $env:SIGNPATH_REQUEST_URL
if ([string]::IsNullOrWhiteSpace($requestUrl)) {
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
}
$message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
$payload = @{
text = $message
blocks = @(
@{
type = 'section'
text = @{
type = 'mrkdwn'
text = $message
}
}
)
} | ConvertTo-Json -Depth 5
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
# Why gate on the notify outcome too: if nobody was told to approve,
# don't hold the release for the approval window — fall through and
# ship like today instead. The 1h wait (vs the installer's 4h) keeps
# both waits plus the build inside the 360-minute job cap; missing it
# falls through to today's unsigned-inner flow rather than blocking.
- name: Download signed inner binaries from SignPath
id: download-signed-inner
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
-ApiToken $env:SIGNPATH_API_TOKEN `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-inner.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 3600
New-Item -ItemType Directory -Path signed-inner -Force
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
# Why: copy back strictly by the staged list so a layout mismatch in the
# returned artifact fails loudly (into fail-open) instead of silently
# shipping a mix of signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
id: restore-signed-inner
if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$failures = New-Object System.Collections.Generic.List[string]
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
Select-Object -First 1
if ($null -eq $signed) {
$failures.Add("missing from signed artifact: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
$failures.Add("returned without a signature: $relative")
continue
}
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
}
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
}
# Why this step exists: electron-builder's CopyElevateHelper re-copies a
# pristine elevate.exe from its download cache over resources\elevate.exe
# on EVERY nsis pack — including the --prepackaged rebuild below — which
# clobbered the SignPath signature in v1.4.129-rc.4. There is no supported
# way to disable just the copy, so we overwrite the cache's copy with our
# signed one (identical bytes plus signature) so the clobber becomes a
# no-op. Known quirk: the cache persists across releases via actions/cache,
# so later runs may see elevate.exe as already signed and skip staging it —
# that is fine (the signature is timestamped) and the evidence gate checks
# elevate.exe in the shipped installer unconditionally. If this ever causes
# trouble, delete this step; the only effect is elevate.exe shipping
# unsigned again, which the evidence gate will flag.
- name: Replace cached elevate.exe with the signed copy
id: sign-elevate-cache
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
$signed = 'dist/win-unpacked/resources/elevate.exe'
if (-not (Test-Path $signed)) {
Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.'
exit 0
}
$signature = Get-AuthenticodeSignature -FilePath $signed
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap."
exit 0
}
$cached = @(Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter elevate.exe -ErrorAction SilentlyContinue)
if ($cached.Count -eq 0) {
Write-Host '::warning::No cached elevate.exe found (electron-builder cache layout changed?); the rebuild will pack the unsigned copy and the evidence gate will flag it.'
exit 0
}
foreach ($file in $cached) {
Copy-Item -Path $signed -Destination $file.FullName -Force
Write-Host "Replaced $($file.FullName) with the SignPath-signed copy."
}
- name: Rebuild NSIS installer from signed unpacked app
id: rebuild-nsis-signed
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
# Why: keep the pre-rebuild artifacts so a failed rebuild can fall
# back to shipping them unchanged (fail-open).
New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null
Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force
Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
}
- name: Roll back to original installer after failed rebuild
if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure'
shell: pwsh
run: |
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force
Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
}
# ── End Windows inner-binary signing ───────────────────────────────
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
id: upload-unsigned-windows-installer
@@ -1013,6 +1255,7 @@ jobs:
SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }}
TAG: ${{ needs.cut.outputs.tag }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }}
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
@@ -1023,7 +1266,9 @@ jobs:
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
}
$message = "Orca Windows release $env:TAG is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
# Why: releases where inner signing fell through have only this one request.
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
$message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
$payload = @{
text = $message
blocks = @(
@@ -1106,6 +1351,90 @@ jobs:
}
$signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint
# Why: evidence gate for inner-binary signing (issue #7785, supersedes
# PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node
# by extracting the shipped installer). Warn-only until the flow has been
# proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED
# to 'true' so unsigned inner binaries block the release.
- name: Verify Windows inner binary signatures
if: matrix.platform == 'win'
shell: pwsh
env:
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
run: |
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
if ($required) { throw $message }
Write-Host "::warning::$message"
exit 0
}
# Why try/catch: while the gate is warn-only, even an unexpected
# script error (extraction hiccup, missing file) must not block
# the release — only the flip to required makes failures fatal.
try {
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
# Why: verify the files a user actually gets on disk, not the build
# tree — 7z parses the NSIS exe directly as its embedded payload.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
$root = Resolve-Path 'inner-evidence-extract'
# Why elevate.exe is always appended: staging skips already-signed
# files, and the persisted electron-builder cache can carry a
# previously signed elevate.exe — so it may be absent from the list
# in some runs, yet it is the file most at risk of losing its
# signature in the NSIS rebuild. Verify it in every release.
$targets = @(Get-Content 'inner-signing-list.txt')
if ($targets -notcontains 'resources\elevate.exe') {
$targets += 'resources\elevate.exe'
}
foreach ($relative in $targets) {
$path = Join-Path $root $relative
if (-not (Test-Path $path)) {
$failures.Add("missing from installer payload: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $path
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $relative, $subject
$report.Add($line)
Write-Host $line
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
$failures.Add("not signed by SignPath Foundation: $relative ($($signature.Status), $subject)")
}
}
Set-Content -Path 'inner-signing-evidence.txt' -Value ($report -join "`n")
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::warning::$_" }
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
if ($required) { throw $message }
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
} else {
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
}
} catch {
if ($required) { throw }
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
}
- name: Upload Windows inner signing evidence
if: always() && matrix.platform == 'win'
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
path: |
inner-signing-evidence.txt
inner-signing-list.txt
if-no-files-found: ignore
retention-days: 30
- name: Publish signed Windows release artifacts
if: matrix.platform == 'win'
uses: nick-fields/retry@v4
@@ -0,0 +1,360 @@
# Windows inner-binary signing rehearsal.
#
# Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed
# inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires
# a two-request flow: sign the unpacked PE files first, then build the NSIS
# installer from the signed tree, then sign the installer. This workflow
# rehearses that entire flow from a branch, end to end, without publishing
# anything — so the release pipeline on main is never at risk while we verify.
#
# Runs only via manual dispatch. Use the test-signing policy for iteration
# (auto-approved test certificate) and release-signing to rehearse the
# production flow (requires manual SignPath approvals). Rehearsed successfully
# with test-signing (run 28987534795) and release-signing (run 28988432001).
name: Windows signing rehearsal
on:
workflow_dispatch:
inputs:
signing-policy-slug:
description: SignPath signing policy
type: choice
default: test-signing
options:
- test-signing
- release-signing
inner-artifact-configuration-slug:
description: SignPath artifact configuration for the inner-binaries zip
type: string
default: windows-inner-binaries-zip
jobs:
rehearse:
# Why: SignPath origin verification requires GitHub-hosted runners, and
# windows-2022 matches the real release job's build environment.
runs-on: windows-2022
timeout-minutes: 360
permissions:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: nothing here pushes; keep the token out of .git/config.
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-win-
- name: Install dependencies
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: rehearsal builds are never published, so the official-build
# secrets (telemetry key, diagnostics URL) are intentionally omitted.
- name: Build app
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
- name: Package unpacked Windows app
shell: pwsh
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --dir --publish never
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/win-unpacked/Orca.exe')) {
throw 'electron-builder --dir did not produce dist/win-unpacked/Orca.exe'
}
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe, signed vendor DLLs) must
# keep their original signer, so they are excluded from the request.
- name: Stage unsigned PE files for inner signing
id: stage-inner
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
$list = New-Object System.Collections.Generic.List[string]
$skipped = New-Object System.Collections.Generic.List[string]
Get-ChildItem -Path $root -Recurse -File |
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
if ($signature.Status -eq 'Valid') {
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
return
}
$destination = Join-Path $stage.FullName $relative
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
Copy-Item -Path $_.FullName -Destination $destination -Force
$list.Add($relative)
}
if (-not ($list -contains 'Orca.exe')) {
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
}
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
}
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
$list | ForEach-Object { Write-Host " $_" }
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-unsigned-${{ github.run_id }}
path: signing-stage/**
if-no-files-found: error
- name: Install SignPath PowerShell module
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
if ($useResourceGet) {
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSResourceRepository -PSGallery -Trusted
} else {
Set-PSResourceRepository -Name PSGallery -Trusted
}
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
} else {
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
}
Import-Module SignPath -ErrorAction Stop
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
- name: Submit inner binaries signing request
id: submit-inner-signing
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
artifact-configuration-slug: ${{ inputs.inner-artifact-configuration-slug || 'windows-inner-binaries-zip' }}
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
wait-for-completion: false
- name: Download signed inner binaries from SignPath
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-ApiToken $env:SIGNPATH_API_TOKEN `
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-inner.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 3600
New-Item -ItemType Directory -Path signed-inner -Force
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
# Why: copy back strictly by the staged list so a layout mismatch in the
# returned artifact fails loudly instead of silently shipping a mix of
# signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$failures = New-Object System.Collections.Generic.List[string]
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
Select-Object -First 1
if ($null -eq $signed) {
$failures.Add("missing from signed artifact: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
$failures.Add("returned without a signature: $relative")
continue
}
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
}
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
}
- name: Build NSIS installer from signed unpacked app
shell: pwsh
run: |
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
}
- name: Upload unsigned Windows installer for SignPath
id: upload-unsigned-installer
uses: actions/upload-artifact@v7
with:
name: orca-windows-installer-unsigned-${{ github.run_id }}
path: dist/orca-windows-setup.exe
if-no-files-found: error
- name: Submit Windows installer signing request
id: submit-installer-signing
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
artifact-configuration-slug: github-actions-windows-installer
github-artifact-id: ${{ steps.upload-unsigned-installer.outputs.artifact-id }}
wait-for-completion: false
- name: Download signed Windows installer from SignPath
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-installer-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-ApiToken $env:SIGNPATH_API_TOKEN `
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-windows.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 14400
New-Item -ItemType Directory -Path signed-windows -Force
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
# Why: signing changes installer bytes, so the updater metadata must be
# regenerated exactly like the release job's staging step does.
- name: Stage signed Windows installer and regenerate updater metadata
shell: pwsh
run: |
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
if ($null -eq $signedInstaller) {
throw 'Signed Windows installer was not returned by SignPath.'
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$installer = Get-Item 'dist/orca-windows-setup.exe'
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
$stream = [System.IO.File]::OpenRead($installer.FullName)
try {
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hash = [Convert]::ToBase64String($sha512.ComputeHash($stream))
} finally {
if ($null -ne $sha512) { $sha512.Dispose() }
$stream.Dispose()
}
$latestYml = Get-Content -Path 'dist/latest.yml' -Raw
$latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', {
param($match)
"$($match.Groups[1].Value)sha512: $hash"
})
$latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)"
$latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)"
Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline
# Why: this is the pass/fail heart of the rehearsal — the same evidence
# check that will later gate releases (see PR #7170). Test certificates
# do not chain to a trusted root, so Status=Valid is only required for
# release-signing runs; test-signing runs require a present signature.
- name: Verify signatures end to end
shell: pwsh
env:
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }}
run: |
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
$requireValid = $env:SIGNING_POLICY -eq 'release-signing'
function Test-Signature([string]$label, [string]$path) {
$signature = Get-AuthenticodeSignature -FilePath $path
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $label, $subject
$script:report.Add($line)
Write-Host $line
if ($null -eq $signature.SignerCertificate -or $signature.Status -eq 'NotSigned') {
$script:failures.Add("unsigned: $label")
} elseif ($script:requireValid -and $signature.Status -ne 'Valid') {
$script:failures.Add("not Valid under release-signing: $label ($($signature.Status))")
} elseif ($script:requireValid -and $subject -notlike '*CN=SignPath Foundation*') {
$script:failures.Add("unexpected signer: $label ($subject)")
}
}
Test-Signature 'installer: orca-windows-setup.exe' 'dist/orca-windows-setup.exe'
# Extract the signed installer and verify the files a user actually
# gets on disk — including the exact file from issue #7785.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
$root = Resolve-Path 'extracted-app'
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$path = Join-Path $root $relative
if (-not (Test-Path $path)) {
$failures.Add("missing from installer payload: $relative")
continue
}
Test-Signature "installed: $relative" $path
}
Set-Content -Path 'signing-evidence.txt' -Value ($report -join "`n")
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signing rehearsal failed with $($failures.Count) problems."
}
Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries plus the installer are signed."
- name: Upload rehearsal evidence and installer
if: always()
uses: actions/upload-artifact@v7
with:
name: windows-signing-rehearsal-${{ inputs.signing-policy-slug || 'test-signing' }}-${{ github.run_id }}
path: |
signing-evidence.txt
inner-signing-list.txt
dist/orca-windows-setup.exe
dist/orca-windows-setup.exe.blockmap
dist/latest.yml
retention-days: 7
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk)
- **Android:** [Download APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
+44
View File
@@ -158,6 +158,10 @@ module.exports = {
}
if (context.electronPlatformName === 'darwin') {
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
await signMacNotificationStatusHelper(
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
context.packager
)
}
},
win: {
@@ -249,6 +253,15 @@ module.exports = {
},
featureWallResources
],
// Why: the notification-status helper must execute from Contents/MacOS —
// on macOS 26 UNUserNotificationCenter aborts (bundleProxyForCurrentProcess
// is nil) for executables launched out of Contents/Resources (#7929).
extraFiles: [
{
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
}
],
target: [
{
target: 'dmg',
@@ -418,6 +431,37 @@ async function signMacComputerUseHelper(helperAppPath, packager) {
})
}
async function signMacNotificationStatusHelper(helperPath, packager) {
if (!existsSync(helperPath)) {
if (isMacRelease) {
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
}
return
}
const codeSigningInfo =
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
? await packager.codeSigningInfo.value
: null
const identity =
process.env.CSC_NAME ??
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
(isMacRelease ? null : '-')
if (!identity) {
throw new Error('Missing signing identity for orca-notification-status helper')
}
// Why: macOS keys notification records to the code-signing identifier; the
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
// (and any later) `codesign --force` derives the correct identifier. Sign
// before the outer Orca.app is sealed, like the computer-use helper.
const args = ['--force', '--sign', identity]
if (isMacRelease) {
args.push('--options', 'runtime', '--timestamp')
}
args.push(helperPath)
execFileSync('codesign', args, { stdio: 'inherit' })
execFileSync('codesign', ['--verify', '--strict', helperPath], { stdio: 'inherit' })
}
function codesignArgs(identity, targetPath) {
const args = ['--force', '--deep', '--sign', identity]
if (isMacRelease) {
+5 -1
View File
@@ -8,6 +8,8 @@ if (process.platform !== 'darwin') {
}
runPnpmScript('build:computer-macos')
runPnpmScript('build:notification-status-macos')
process.exit(0)
function runPnpmScript(scriptName) {
const npmExecPath = process.env.npm_execpath
@@ -22,5 +24,7 @@ function runPnpmScript(scriptName) {
if (result.signal) {
process.kill(process.pid, result.signal)
}
process.exit(result.status ?? (result.error ? 1 : 0))
if (result.status !== 0 || result.error) {
process.exit(result.status ?? 1)
}
}
@@ -0,0 +1,97 @@
#!/usr/bin/env node
// Builds the orca-notification-status helper binary.
//
// The helper reads UNUserNotificationCenter settings for the app it ships
// inside (see native/notification-status-macos/main.swift). The target
// CFBundleIdentifier is embedded as a __TEXT,__info_plist section so every
// later `codesign --force` pass (electron-builder's signing, the dev runner's
// ad-hoc deep sign) derives the correct code identifier automatically —
// macOS keys notification records to that identifier.
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const repoRoot = path.resolve(import.meta.dirname, '../..')
const sourcePath = path.join(repoRoot, 'native', 'notification-status-macos', 'main.swift')
const defaultOutputPath = path.join(
repoRoot,
'native',
'notification-status-macos',
'.build',
'release',
'orca-notification-status'
)
if (process.platform !== 'darwin') {
process.exit(0)
}
const args = process.argv.slice(2)
const bundleId = readArg('--bundle-id') ?? 'com.stablyai.orca'
const outputPath = readArg('--output') ?? defaultOutputPath
// Why: dev launches only need the host architecture; release builds ship a
// universal binary matching the app's x64 + arm64 targets.
const singleArch = args.includes('--single-arch')
const workDir = path.join(tmpdir(), `orca-notification-status-${process.pid}`)
mkdirSync(workDir, { recursive: true })
try {
const plistPath = path.join(workDir, 'Info.plist')
writeFileSync(plistPath, embeddedInfoPlist(bundleId), 'utf8')
const triples = singleArch
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
const builtBinaries = triples.map((triple) => {
const output = path.join(workDir, `orca-notification-status-${triple}`)
execFileSync(
'swiftc',
[
'-O',
sourcePath,
'-target',
triple.replace('-apple-macosx', '-apple-macosx11.0'),
'-o',
output,
'-Xlinker',
'-sectcreate',
'-Xlinker',
'__TEXT',
'-Xlinker',
'__info_plist',
'-Xlinker',
plistPath
],
{ stdio: 'inherit' }
)
return output
})
mkdirSync(path.dirname(outputPath), { recursive: true })
if (builtBinaries.length === 1) {
execFileSync('cp', [builtBinaries[0], outputPath])
} else {
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
}
execFileSync('chmod', ['755', outputPath])
} finally {
rmSync(workDir, { recursive: true, force: true })
}
function readArg(name) {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}
function embeddedInfoPlist(identifier) {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>${identifier}</string>
<key>CFBundleName</key>
<string>orca-notification-status</string>
</dict>
</plist>
`
}
@@ -63,6 +63,22 @@ describe('electron-builder config', () => {
)
})
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
expect(electronBuilderConfig.mac.extraFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
})
])
)
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
expect.arrayContaining([expect.objectContaining({ to: 'orca-notification-status' })])
)
})
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
@@ -221,7 +221,7 @@ describe('Electron runtime package contract', () => {
expect(installRun).not.toMatch(/throw\s+\$_/)
})
it('temporarily allows publishing Windows after verifying the signed installer only', () => {
it('verifies Windows inner binary signatures fail-open before publishing', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
@@ -230,12 +230,38 @@ describe('Electron runtime package contract', () => {
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable')
const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures')
const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence')
const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
expect(outerVerifyIndex).toBeGreaterThan(-1)
expect(innerVerifyIndex).toBe(-1)
expect(publishIndex).toBe(outerVerifyIndex + 1)
expect(innerVerifyIndex).toBe(outerVerifyIndex + 1)
expect(evidenceIndex).toBe(innerVerifyIndex + 1)
expect(publishIndex).toBe(evidenceIndex + 1)
// Why fail-open: unsigned inner binaries must warn, not block, until the
// flow is proven on a real release (issue #7785). Flip this to 'true'
// together with the workflow env to make the gate required.
expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false')
// Why: every step in the inner-signing chain must be unable to fail the
// release — a SignPath outage or timeout falls through to today's
// unsigned-inner flow instead of blocking the cut.
const innerChainStepNames = [
'Stage unsigned inner PE files for signing',
'Upload unsigned inner binaries for SignPath',
'Submit inner binaries signing request',
'Notify Slack that inner-binary signing is waiting for approval',
'Download signed inner binaries from SignPath',
'Restore signed inner binaries into unpacked app',
'Replace cached elevate.exe with the signed copy',
'Rebuild NSIS installer from signed unpacked app'
]
for (const stepName of innerChainStepNames) {
const step = steps[stepNames.indexOf(stepName)]
expect(step, stepName).toBeDefined()
expect(step['continue-on-error'], stepName).toBe(true)
}
})
it('publishes both Linux release matrix entries', () => {
+54 -15
View File
@@ -97,16 +97,6 @@ function setPlistValue(plistPath, key, value) {
execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, plistPath])
}
function sanitizeBundleIdPart(value) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9.-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'dev'
)
}
function sanitizeMacAppBundleName(value) {
return (
Array.from(value, (char) => {
@@ -138,7 +128,10 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v4'
// v6: bundle the notification-status helper (real permission readout) and
// ad-hoc re-sign after plist edits so Notification Center accepts the
// bundle; bumping forces stale cached copies to be recreated.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@@ -151,7 +144,17 @@ function prepareMacDevElectronApp() {
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
const appPath = path.join(distDir, appBundleName)
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
const bundleId = `com.stablyai.orca.dev.${sanitizeBundleIdPart(hash)}`
// Why: one stable id for every dev instance. Per-instance ids registered a
// new macOS Notification Settings entry for each branch × Electron version,
// piling up "Orca: <branch>" rows forever and breaking the notification
// settings deep-link (System Settings can't resolve an id it has no entry
// for and falls back to the root list). macOS keys notification permission
// by bundle id, so a single id also means granting notifications to one dev
// instance covers all of them. Trade-off: when two dev instances run at
// once, macOS may route a notification click to the other instance —
// Electron drops clicks for notification ids it didn't create, so the
// click is lost, not misdirected.
const bundleId = 'com.stablyai.orca.dev'
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
const expectedMarker = JSON.stringify(
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
@@ -206,9 +209,45 @@ function prepareMacDevElectronApp() {
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
// Why no re-sign: dev launches execute the copied Electron binary directly,
// and Electron's framework bundle is ambiguous to codesign when deep-signing
// an already-built distribution. Avoid blocking `pn dev` on local signing.
// Why: the notification-status helper reads the app's real macOS
// notification authorization (UNUserNotificationCenter has no Electron
// API). It must live inside the bundle and carry the dev bundle id as its
// embedded/code-sign identifier — macOS keys notification records to the
// signing identifier. Non-fatal: without swiftc the permission card falls
// back to delivery-probe heuristics.
try {
execFileSync(
process.execPath,
[
path.join(repoRoot, 'config', 'scripts', 'build-notification-status-macos.mjs'),
'--bundle-id',
bundleId,
'--single-arch',
'--output',
path.join(appPath, 'Contents', 'MacOS', 'orca-notification-status')
],
{ stdio: 'inherit' }
)
} catch (error) {
console.warn(
`[orca-dev] notification-status helper build failed (permission card falls back to probes): ${error?.message ?? error}`
)
}
// Why: the plist edits above (and the copy itself) break the bundle's
// ad-hoc seal, and macOS refuses Notification Center registration for
// invalidly-signed apps — every dev notification fails with UNErrorDomain
// error 1 and the app never appears in System Settings > Notifications.
// An ad-hoc re-sign restores delivery, the permission prompt, and the
// notification-settings deep link for dev builds. Non-fatal: a signing
// failure should not block `pnpm dev`.
try {
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath])
} catch (error) {
console.warn(
`[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}`
)
}
writeFileSync(markerPath, expectedMarker, 'utf8')
process.env.ELECTRON_EXEC_PATH = executablePath
}
+2
View File
@@ -13,9 +13,11 @@
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
"../src/main/codex/hook-trust-promotion.ts",
"../src/main/codex-accounts/fs-utils.ts",
"../src/main/command-code/command-code-managed-script.ts",
"../src/main/command-code/hook-service.ts",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.0m">
<title>downloads: 4.0m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.1m">
<title>downloads: 4.1m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.0m</text>
<text x="93.5" y="14">4.0m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.1m</text>
<text x="93.5" y="14">4.1m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk)
- **Android:** [Baixar APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
@@ -0,0 +1,368 @@
# Source Control Push Failure AI Recovery
## Problem
Push, force-push, publish, and sync push-stage failures flow through the shared
remote operation formatter in `src/renderer/src/lib/source-control-remote-error.ts:94`
and the editor-store toast paths in `src/renderer/src/store/slices/editor.ts:3716`
and `src/renderer/src/store/slices/editor.ts:3821`. A local pre-push hook or
lint hook is not an auth, protected-branch, or transport problem, so generic
remote guidance sends the user in the wrong direction.
PR #7787 proves the behavior is useful, but the prototype needs tightening before
it is maintainable:
- `src/renderer/src/components/right-sidebar/SourceControl.tsx:1938` and
`src/renderer/src/components/right-sidebar/SourceControl.tsx:5799` repeat
push-failure predicates and feed live branch/file state into a prompt that says
"at failure time".
- `src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts:63`
imports push detection again instead of consuming one derived recovery model.
- `src/renderer/src/components/right-sidebar/source-control-ai-commit-failure-launch.ts:24`
and `src/renderer/src/components/right-sidebar/source-control-ai-push-failure-launch.ts:24`
are copied launch flows with action-specific copy.
- `src/shared/source-control-commit-failure-agent-command.ts:6` and
`src/shared/source-control-push-failure-agent-command.ts:6` are identical
command-template builders.
- `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx:21`
already contains a reusable split button, while `SourceControl.tsx:6415`
carries a second inline version.
- `src/renderer/src/lib/source-control-remote-error.ts:148` currently treats
any `isSync` error as push-like for hook detection. `syncBranch` calls the
formatter for fetch, upstream-status, pull, and push failures, so the renderer
cannot prove a sync error came from the push stage without an explicit marker.
- `src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts:2`
imports prompt helpers through the large React module instead of shared prompt
modules.
## Goal
Add first-class recovery for pre-push hook failures without growing a parallel
push-only subsystem:
1. Toasts for push, force-push, publish, and sync push-stage failures say
"blocked" for detected pre-push or lint-hook output.
2. Source Control shows a concise Push blocked panel with Details and AI Fix
only for a failure snapshot that is both push-like and hook-like.
3. AI Fix launches the configured `fixPushFailure` Source Control action with a
safe, provider-neutral prompt.
4. Commit-failure recovery and push-failure recovery share launch,
command-template, split-button, and dense error-panel primitives.
## Non-goals
- Do not fix server-side pre-receive hooks, hosted CI failures, or provider-side
protected-branch failures from this entry point.
- Do not bypass hooks, add `--no-verify`, push from the launched agent, create a
PR, or assume GitHub-specific terminology.
- Do not add app-wide persistence for this transient failure panel.
- Do not redesign the Source Control panel or the Source Control AI settings
model.
- Do not fold unrelated launch surfaces such as checks recovery into this change
unless a tiny shared helper is already needed for commit/push recovery.
## Design
1. Keep push-hook detection and prompt building in shared code.
- `src/shared/source-control-push-failure.ts` owns normalization, bounded
scanning, summary text, Details eligibility, prompt truncation, prompt
rules, and changed-file list bounding.
- Detection must stay conservative: explicit `pre-push`/`prepush`,
`hook declined to push`, hook-runner output in push context, or lint output
in push context. Auth, non-fast-forward, protected branch, pre-receive,
submodule, and generic transport failures stay on the existing remote-error
path.
- Keep the output scan bounded at 64 KiB and the prompt failure-output
section bounded. Also cap prompt file lines to a small constant with an
omitted-count line; do not rely on the git-status cap, which is far too high
for prompt context.
2. Make remote-error formatting sync-stage aware.
- Extend `RemoteOperationErrorOptions` with an explicit push-stage flag for
sync, for example `isSyncPushStage`.
- Gate push-hook classification on `isPush`, `isForcePush`, `publish`, or
`isSyncPushStage`; do not use bare `isSync` as evidence of a push failure.
- In `syncBranch`, pass the push-stage flag only inside the two inner
`pushRuntimeGit` catch blocks. The outer fetch, upstream-status, and pull
catches still pass `isSync` for Sync-shaped generic copy but must not render
blocked hook copy.
- Carry the same push-stage fact back to the renderer, either by tagging the
thrown `Error` with a narrow exported marker or by wrapping it in a typed
error that preserves the original message/cause. `SourceControl` must set
`syncPushStage` only from that marker, not from `kind === 'sync'` plus
hook-like stderr.
- Keep submodule push messages before hook detection so submodule guidance
remains more specific than AI recovery.
3. Replace one-off command builders with one shared recovery command builder.
- Add a shared builder that accepts `{ actionId, promptOverride,
commandInputTemplate, basePrompt }` and renders the existing Source Control
AI command template for launch actions.
- Keep compatibility exports for commit and push helpers if nearby tests or
call sites still use those names, but make them wrappers over the generic
builder.
- Move prompt-builder tests to import from shared modules, not
`SourceControl.tsx`.
4. Capture and derive push recovery state once.
- Extend `SourceControlActionError` with the raw error, a push-stage marker
for sync failures, the branch name at failure time, a bounded status-entry
snapshot at failure time, and a per-worktree sequence token.
- `runRemoteAction` clears the worktree's previous error and increments its
sequence before starting. A catch may write an error only if its sequence
still owns that worktree, so a slow failure cannot overwrite a newer retry
or success.
- Add a focused renderer helper that accepts the captured
`SourceControlActionError` and the current branch name, then returns either
`null` or a model with raw/sanitized detail text, summary, details flag,
kind label, and AI prompt.
- The helper returns `null` unless the operation is `push`, `force_push`,
`publish`, or `sync` with the sync push-stage marker and
`isPushHookFailure(rawError)` passes.
- If the current branch is known and differs from the captured branch, hide or
clear the model instead of launching an agent with wrong branch context.
- Pass that single model to `useSourceControlAi` and `CommitArea`; neither
prop construction nor `CommitArea` should repeat the push-hook predicate.
5. Use one generic recovery launcher for commit and push failures.
- Replace the separate commit and push launch files with
`source-control-ai-recovery-launch.ts`, or keep the old files as one-line
wrappers if import stability is cheaper.
- The generic launcher owns connection resolution, SSH/local agent discovery,
saved-agent validation, agent-args validation, agent selection, terminal
launch, focus, and success/failure toasts.
- Resolve connection without collapsing local and unresolved states: read
`const worktreeConnectionId = getConnectionId(worktreeId)`, use it when it
is a string or `null`, and only fall back to `sourceRepoConnectionId` when
the worktree lookup returns `undefined`. If the fallback is also
`undefined`, show the workspace-connection error instead of launching
locally. `null` means proven local; a string means SSH/remote.
- Validate saved CLI args before agent detection or terminal creation. On
Windows, keep using PowerShell-safe planning through
`planAgentCliArgsSuffix(..., 'powershell')`.
- Action-specific data is limited to action id, base prompt, empty-prompt
copy, unavailable-agent copy, and success copy.
6. Keep the recovery hook thin.
- `use-source-control-recovery-ai.ts` builds the commit prompt, accepts the
already-derived push recovery prompt/model, keeps independent loading
flags, and calls the generic launcher.
- It should not import push-hook detection, resolve agents, or duplicate
launch plumbing.
7. Extract and reuse the recovery UI.
- Reuse or replace the existing
`src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx`
rather than adding another split-button component.
- Move the dense recovery notice and Details dialog into focused renderer
modules named for source-control recovery. Avoid `helpers`, `utils`, and new
max-lines suppressions.
- Reuse the same notice/dialog component for commit and push recovery with
action-specific labels, summary, details, prompt, saved recipe, and launch
callback.
- Keep normal remote errors filtered out when a push recovery model is
rendered, so the user does not see both Push blocked and generic remote
error copy for the same failure.
8. Retain `fixPushFailure` in the existing Source Control AI action model.
- The prototype already adds `fixPushFailure` to
`src/shared/source-control-ai-actions.ts`; keep it as a launch action like
`fixCommitFailure`.
- Verify default settings, normalizers, global settings rows, repository
override rows, labels, descriptions, and variable chips all include Push
failure fixes.
- Do not create a push-specific settings store or migration. Existing
`normalizeSourceControlAiSettings` hydrates missing action defaults.
## Data Flow
- User clicks Push, Force Push, Publish Branch, or Sync.
- `runRemoteAction` clears that worktree's previous remote error, records a new
sequence token, and starts the editor-store remote action.
- Git push fails and throws.
- `pushBranch` or sync's inner push-stage catch calls
`resolveRemoteOperationErrorMessage` with push-like options. Sync fetch, pull,
and upstream-status failures do not pass the push-stage flag.
- Sync's inner push-stage catch marks the rethrown error as push-stage so
`runRemoteAction` can preserve `syncPushStage` in the captured snapshot.
- The formatter classifies hook output before auth/transport fallbacks only for
push-like options and shows blocked toast copy.
- `SourceControl` catches the same error, and if the sequence still owns the
worktree, stores `{ kind, rawError, message, syncPushStage, branchName,
entriesSnapshot }`.
- The push recovery helper derives one `pushRecovery` model from that snapshot.
- `CommitArea` renders normal remote errors or the Push blocked recovery notice.
- AI Fix builds the `fixPushFailure` command input and launches the selected
agent in the owning local or SSH runtime.
## Edge Cases
- Auth, missing repo, protected-branch, pre-receive, non-fast-forward, and
transport failures must not show the Push blocked panel.
- Submodule push errors keep their existing specialized messages before
push-hook detection.
- Sync failures from fetch, upstream-status, or pull stages must not show Push
blocked, even if stderr contains words like "lint" or "hook".
- Create PR intent remote failures use the same `runRemoteAction` plumbing; if
CommitArea is not visible, blocked toast copy is still required but no hidden
panel work is needed.
- A newer remote operation for the same worktree must clear stale recovery state
immediately and prevent an older in-flight failure from writing after it.
- Switching worktrees or branches must not show another worktree or branch's
push failure.
- External edits or another Orca window may make the failure stale. Do not add
cross-window persistence for this feature; rely on local retry/remount/branch
mismatch clearing and list this as residual risk.
- Source Control AI hidden for a repo still shows the blocked notice and Details;
it hides AI Fix.
- SSH worktrees must detect and launch agents on the owning connection, not a
local fallback.
- Windows launch planning must continue to use PowerShell-safe agent args.
- Very large hook output and very large changed-file sets must be bounded before
prompt generation.
- ANSI/control output must not leak into summaries or Details comparison logic.
Details may show sanitized text while preserving enough line breaks for
debugging.
- Empty custom command templates must stay empty so the launcher rejects them
with a clear settings error.
- Prompt text must treat file paths, branch names, and hook output as data, not
instructions.
## Test Plan
- Shared unit tests:
- `src/shared/source-control-push-failure.test.ts` covers detection positives,
auth/protected/pre-receive/non-fast-forward negatives, ANSI/control
stripping, bounded scanning, details comparison, prompt output truncation,
prompt file-list capping, and provider-neutral prompt rules.
- Shared command-template tests cover the generic recovery builder plus
commit/push wrappers, including empty templates and prompt overrides.
- `src/shared/source-control-ai-actions.test.ts` covers `fixPushFailure`
label, default template, variables, normalization, and action-list inclusion.
- Remote formatter and store tests:
- `src/renderer/src/lib/source-control-remote-error.test.ts` covers push,
force-push, publish, and sync push-stage blocked copy plus sync non-push
stage negatives, auth/non-fast-forward/protected/pre-receive/submodule
regressions.
- `src/renderer/src/store/slices/editor.test.ts` covers the `pushBranch` toast
path and both sync push-stage and sync non-push-stage toasts.
- Renderer model and UI tests:
- A focused push recovery derivation test covers operation kind, sync
push-stage marker, branch mismatch, stale sequence ownership, snapshot file
entries, summary, details flag, prompt contents, and ordinary remote errors.
- `CommitArea` tests cover the Push blocked notice, Details dialog, AI Fix
visible/hidden, ordinary remote errors, no duplicate remote error when a push
model exists, and unchanged commit-failure recovery rendering.
- Settings tests cover global and repository Source Control AI action rows
showing Push failure fixes.
- Launcher tests:
- Generic recovery launcher tests cover commit and push action ids, invalid CLI
args before detection, empty template rejection, unavailable saved agent,
local versus SSH agent detection, successful launch/focus, and success copy.
- Checks:
- Run targeted vitest files above, `pnpm typecheck`, `pnpm lint`, and
`pnpm check:max-lines-ratchet`.
## UI Quality Bar
The Source Control recovery UI must match `docs/STYLEGUIDE.md`: monochrome,
quiet, dense, token-based, and consistent with adjacent commit-failure recovery.
Use existing shadcn `Button`, `DropdownMenu`, and `Dialog` primitives, lucide
icons, `card`/`border`/`destructive`/`muted` tokens, and the existing action
recipe row patterns. The Push blocked notice should keep the summary readable at
narrow right-sidebar widths, avoid nested cards, avoid new color values, show
stable split-button geometry while launching, and keep Details as progressive
disclosure for long hook output. Dialogs and dropdowns must remain usable in
light/dark mode and under SSH latency.
## Review Screenshots
1. Source Control after a simulated pre-push hook failure: Push blocked notice
with summary, AI Fix, and Details.
2. Push failure Details dialog showing hook output and Fix with AI.
3. AI Fix customize-launch dialog for `fixPushFailure`.
4. Ordinary push auth/protected/remote failure state: regular remote error, no
Push blocked notice.
5. Sync fetch/pull-stage failure state: Sync-shaped remote error, no Push
blocked notice.
6. Source Control AI hidden for the repo: Push blocked notice and Details, no AI
Fix.
7. Source Control AI settings showing the Push failure fixes launch recipe.
8. Adjacent commit-failure recovery notice still rendering correctly after the
shared UI extraction.
## Rollout
1. Tighten shared push-failure classifier/prompt bounds and add the generic
recovery command builder.
2. Make remote formatter/store sync-stage aware and update toast tests.
3. Add the `SourceControlActionError` snapshot/sequence data and one push
recovery derivation helper.
4. Replace duplicated commit/push launchers with the generic recovery launcher
and thin hook wiring.
5. Extract/reuse source-control recovery UI components, including the existing
split-button module.
6. Verify `fixPushFailure` settings/default/normalizer coverage and i18n labels.
7. Update targeted tests and run typecheck, lint, and max-lines ratchet.
8. Validate rendered Electron states with screenshots before opening the PR.
## Lightweight Eng Review
- Scope: Reduced from PR #7787's push-specific parallel path to a shared
recovery layer. Keep the feature limited to local pre-push/lint-hook detection,
blocked copy, an inline recovery notice, Details, and AI launch. No persistence
or provider-specific recovery.
- Architecture/data flow: Shared code owns classification, prompt text, and
command-template rendering; the editor store owns stage-aware toast formatting;
`SourceControl` owns transient worktree-scoped failure snapshots and sequence
invalidation; the generic launcher owns local/SSH agent discovery and terminal
launch.
- Failure modes covered:
- Sync stage ambiguity: only the inner push stage may classify as hook-blocked.
- Auth/protected/pre-receive/non-fast-forward/submodule/transport errors stay
out of push recovery.
- Branch or worktree switches suppress stale recovery models.
- Per-worktree sequence tokens prevent older in-flight failures from
overwriting newer retries or successes.
- Empty custom templates, invalid CLI args, unavailable saved agents, missing
worktree context, SSH host detection, and Windows shell planning fail before
terminal launch.
- Large or hostile hook output and file lists are bounded and treated as data
in prompts.
- Test coverage required:
- Shared classifier/prompt/command/action-model tests in `src/shared`.
- Remote formatter and editor-store tests for push, force-push, publish, sync
push-stage, and sync non-push-stage paths.
- Renderer derivation and `CommitArea` tests for Push blocked, Details, AI Fix,
hidden AI actions, stale branch/sequence, ordinary remote errors, and
existing commit failure recovery.
- Generic launcher tests for local/SSH, invalid args, saved-agent unavailable,
empty template, and success launch.
- Settings tests for global and repo Push failure fixes rows.
- Electron validation for all required screenshots; no separate E2E is
required if renderer tests cover the model and Electron validates the real UI.
- Performance/blast radius: No polling, watchers, migrations, or startup work.
Runtime cost is one bounded classifier pass over at most 64 KiB, bounded prompt
output, and a bounded file snapshot only when a remote action fails. Largest
blast radius is the shared recovery UI and launcher used by existing commit
failures, so commit recovery needs tests and a screenshot.
- UI quality bar: Match `docs/STYLEGUIDE.md` and adjacent Source Control density:
token-based error state, stable split-button geometry, progressive Details
disclosure, no nested cards, concise copy, light/dark compatibility, and
visible disabled/loading state under SSH latency.
- Required review screenshots:
1. Push blocked notice after pre-push hook failure.
2. Push failure Details dialog with Fix with AI.
3. `fixPushFailure` customize-launch dialog.
4. Ordinary push auth/protected/remote failure with no Push blocked panel.
5. Sync non-push-stage failure with no Push blocked panel.
6. Source Control AI hidden: Push blocked without AI Fix.
7. Source Control AI settings showing Push failure fixes.
8. Existing commit-failure recovery notice after shared UI extraction.
- Residual risks: Classifier false positives remain possible for unusual local
hook-runner output that mentions push context; keep tests biased toward
preserving auth, protected-branch, and non-fast-forward behavior. Failure
panels are renderer-local, so another Orca window or an external terminal can
fix the problem while the old panel remains visible until local retry, remount,
or branch/worktree change. Electron validation may need a mocked or temporary
repo scenario to trigger a real pre-push hook without mutating user data.
+1 -1
View File
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 5
"versionCode": 6
},
"plugins": [
"expo-router",
+43 -8
View File
@@ -3,14 +3,15 @@ import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from '
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react-native'
import { useHostClient } from '../../../../src/transport/client-context'
import { useForceReconnect, useHostClient } from '../../../../src/transport/client-context'
import type { RpcSuccess } from '../../../../src/transport/types'
import { colors, spacing, typography } from '../../../../src/theme/mobile-theme'
import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
import {
fetchMobileGitHistory,
mapMobileCommitRows,
type MobileCommitRow
} from '../../../../src/source-control/mobile-git-history'
import { resolveMobileHistoryScreenView } from '../../../../src/source-control/mobile-history-screen-state'
import type { GitBranchChangeEntry } from '../../../../../src/shared/types'
function firstParam(value: string | string[] | undefined): string {
@@ -27,9 +28,11 @@ export default function HistoryScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const [rows, setRows] = useState<MobileCommitRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [reloadNonce, setReloadNonce] = useState(0)
const [expanded, setExpanded] = useState<string | null>(null)
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
@@ -57,7 +60,20 @@ export default function HistoryScreen() {
return () => {
active = false
}
}, [client, connState, worktreeId])
}, [client, connState, reloadNonce, worktreeId])
const retry = useCallback(() => {
setError(null)
// Why: retrying the fetch is useless while the transport's reconnect loop
// is parked at its backoff cap — revive the connection instead (mirrors
// MobileSourceControlPanel / issue #5049). The load effect re-runs via
// connState once the fresh client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
}
setReloadNonce((n) => n + 1)
}, [connState, forceReconnect, hostId])
const toggleCommit = useCallback(
(row: MobileCommitRow) => {
@@ -130,6 +146,12 @@ export default function HistoryScreen() {
[expanded, filesById, toggleCommit]
)
const view = resolveMobileHistoryScreenView({
connected: client !== null && connState === 'connected',
rows,
error
})
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
@@ -138,21 +160,26 @@ export default function HistoryScreen() {
</Pressable>
<Text style={styles.title}>Commit History</Text>
</View>
{error ? (
{view.kind === 'error' || view.kind === 'waiting' ? (
<View style={styles.state}>
<Text style={styles.stateText}>{error}</Text>
<Text style={styles.stateText}>
{view.kind === 'waiting' ? 'Waiting for desktop...' : view.message}
</Text>
<Pressable style={styles.retryButton} onPress={retry} accessibilityLabel="Retry">
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
) : rows === null ? (
) : view.kind === 'loading' ? (
<View style={styles.state}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : rows.length === 0 ? (
) : view.kind === 'empty' ? (
<View style={styles.state}>
<Text style={styles.stateText}>No commits.</Text>
</View>
) : (
<FlatList
data={rows}
data={view.rows}
renderItem={renderCommit}
keyExtractor={(row) => row.id}
contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }}
@@ -175,6 +202,14 @@ const styles = StyleSheet.create({
title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
stateText: { color: colors.textMuted, fontSize: typography.bodySize },
retryButton: {
marginTop: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgRaised
},
retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle },
commitHeader: {
flexDirection: 'row',
@@ -5061,9 +5061,10 @@ export default function SessionScreen() {
}
style={styles.textInput}
value={input}
onChangeText={(text) =>
setInput((previousText) => normalizeTerminalTextInput(text, previousText))
}
// Why: iOS kills an active dictation/IME session when JS
// writes a value that differs from the native field text;
// store the raw field text and normalize at send time.
onChangeText={setInput}
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
@@ -2,13 +2,14 @@ import { useEffect } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, X } from 'lucide-react-native'
import { ChevronLeft, ExternalLink, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import type { ConnectionState } from '../../transport/types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
import { useMobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
import { MobilePRSidebar } from '../MobilePRSidebar'
import { openMobilePrUrl } from '../MobilePrComposeSheet'
type Props = {
client: RpcClient | null
@@ -73,6 +74,27 @@ export function MobilePrViewPanel({
message: 'Current branch unavailable.'
} as const)
: controller.prSidebarState
// Why: open-on-host lives in the chrome so the PR URL is always flush-right of
// the screen title, even when the body is scrolled past the PR header.
const prUrl =
sidebarState.kind === 'ready' && sidebarState.data.pr.url ? sidebarState.data.pr.url : null
const prNumber = sidebarState.kind === 'ready' ? sidebarState.data.pr.number : null
const openPr = prUrl ? () => openMobilePrUrl(prUrl) : undefined
const openPrControl = openPr ? (
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={openPr}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={
prNumber != null
? `Open pull request #${prNumber} on the web`
: 'Open pull request on the web'
}
>
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : null
const sidebar = (
<MobilePRSidebar
state={sidebarState}
@@ -104,6 +126,7 @@ export function MobilePrViewPanel({
<Text style={styles.title} numberOfLines={1}>
Pull Request
</Text>
{openPrControl}
</View>
</View>
{sidebar}
@@ -126,6 +149,7 @@ export function MobilePrViewPanel({
<Text style={styles.title} numberOfLines={1}>
Pull Request
</Text>
{openPrControl}
</View>
</View>
{sidebar}
@@ -29,8 +29,8 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
const baseRef = item?.baseRefName ?? null
const headRef = item?.branchName ?? null
const editable = canEditPRTitle(pr.state)
// Tapping the state badge or the #number opens the PR on its host (GitHub/etc.)
// in the phone browser — pr.url is the canonical web URL.
// Badge, #number, and the flush-right external-link control all open pr.url
// (canonical host web URL) in the phone browser.
const openPr = pr.url ? () => openMobilePrUrl(pr.url) : undefined
return (
@@ -16,6 +16,7 @@ type Props = {
export function MobileSourceControlContent({ state }: Props) {
const {
insets,
connState,
busyAction,
commitMessage,
setCommitMessage,
@@ -56,6 +57,15 @@ export function MobileSourceControlContent({ state }: Props) {
return (
<>
{connState !== 'connected' ? (
// Why: once data has loaded the screen looks alive even when the
// desktop link is down, so taps appear to do nothing (STA-1511).
// Surface the reconnect state where the user is looking.
<View style={styles.reconnectBanner}>
<ActivityIndicator size="small" color={colors.statusAmber} />
<Text style={styles.reconnectBannerText}>Reconnecting to desktop...</Text>
</View>
) : null}
<View style={styles.summaryCard}>
<View style={styles.summaryHeader}>
<View style={styles.branchLine}>
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { resolveMobileHistoryScreenView } from './mobile-history-screen-state'
import type { MobileCommitRow } from './mobile-git-history'
const row: MobileCommitRow = {
id: 'abc123',
shortId: 'abc123',
subject: 'commit',
author: 'author',
parentId: null,
relativeTime: '1m'
}
describe('resolveMobileHistoryScreenView', () => {
it('shows waiting (not an endless spinner) when disconnected with nothing loaded', () => {
// Regression (STA-1511): the load effect no-ops while disconnected, so
// rows stay null forever — the screen must offer a retry, not a spinner.
expect(resolveMobileHistoryScreenView({ connected: false, rows: null, error: null })).toEqual({
kind: 'waiting'
})
})
it('spins only while connected and loading', () => {
expect(resolveMobileHistoryScreenView({ connected: true, rows: null, error: null })).toEqual({
kind: 'loading'
})
})
it('keeps already-loaded rows visible across a connection drop', () => {
expect(resolveMobileHistoryScreenView({ connected: false, rows: [row], error: null })).toEqual({
kind: 'rows',
rows: [row]
})
})
it('prefers the error state over waiting so failures stay actionable', () => {
expect(resolveMobileHistoryScreenView({ connected: false, rows: null, error: 'boom' })).toEqual(
{ kind: 'error', message: 'boom' }
)
})
it('distinguishes an empty history from a pending load', () => {
expect(resolveMobileHistoryScreenView({ connected: true, rows: [], error: null })).toEqual({
kind: 'empty'
})
})
})
@@ -0,0 +1,30 @@
import type { MobileCommitRow } from './mobile-git-history'
export type MobileHistoryScreenView =
| { kind: 'error'; message: string }
| { kind: 'waiting' }
| { kind: 'loading' }
| { kind: 'empty' }
| { kind: 'rows'; rows: MobileCommitRow[] }
// Why: the history load effect no-ops while the client isn't connected, so
// without an explicit 'waiting' view the screen showed an infinite spinner
// with no retry when opened during a reconnect window (STA-1511). Rows loaded
// before a drop stay visible — stale history beats a blank screen.
export function resolveMobileHistoryScreenView(input: {
connected: boolean
rows: MobileCommitRow[] | null
error: string | null
}): MobileHistoryScreenView {
const { connected, rows, error } = input
if (error) {
return { kind: 'error', message: error }
}
if (rows !== null) {
return rows.length === 0 ? { kind: 'empty' } : { kind: 'rows', rows }
}
if (!connected) {
return { kind: 'waiting' }
}
return { kind: 'loading' }
}
@@ -125,6 +125,24 @@ const baseStyles = StyleSheet.create({
fontWeight: '600',
textTransform: 'capitalize'
},
reconnectBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginHorizontal: spacing.lg,
marginTop: spacing.lg,
marginBottom: -spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.statusAmber
},
reconnectBannerText: {
color: colors.textPrimary,
fontSize: typography.metaSize
},
actionError: {
marginTop: spacing.sm,
paddingHorizontal: spacing.md,
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
// Why: iOS terminates an active keyboard-dictation (and IME) session whenever
// JS writes a value into the focused field that differs from the native text
// (RN applies it via setTextAndSelection / _setAttributedString). Terminal
// inputs therefore must echo the raw field text in their controlled value and
// apply dash normalization only on the send/mirror path. See stablyai/orca#7925.
describe('terminal iOS dictation write-back', () => {
it('does not write normalized text back into the buffered command input value', () => {
expect(sessionRouteSource).toContain('onChangeText={setInput}')
expect(sessionRouteSource).not.toContain(
'setInput((previousText) => normalizeTerminalTextInput'
)
})
it('still normalizes the buffered command text at send time', () => {
expect(sessionRouteSource).toContain('normalizeTerminalTextInput(input)')
})
})
@@ -202,6 +202,32 @@ describe('terminal live input commit hook', () => {
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
})
it('Given iOS smart-dash text When the change arrives Then the capture echoes the raw field text and the PTY gets normalized bytes', async () => {
// Given
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When: iOS smart punctuation rewrote "--" into an en dash inside the field
handlers.handleLiveInputChange('a–')
// Then: writing "a--" back into the controlled value would kill an active
// iOS dictation/IME session, so the capture must keep what iOS produced
expect(captures).toEqual(['a–'])
await vi.waitFor(() => expect(sent).toEqual(['a--']))
})
it('Given dictation-style hypothesis revisions When changes arrive Then the field is never rewritten and the PTY converges', async () => {
// Given
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When: iOS dictation replaces its hypothesis as recognition refines
handlers.handleLiveInputChange('high')
handlers.handleLiveInputChange('hi there')
// Then: captures only echo the field; the mirror repairs the PTY with DELs
expect(captures).toEqual(['high', 'hi there'])
await vi.waitFor(() => expect(sent).toEqual(['high', '\x7f\x7f there']))
})
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
@@ -109,9 +109,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit()
return
}
const normalizedText = normalizeTerminalTextInput(text)
setLiveInputCapture(normalizedText)
applyLiveInputMirror(activeHandle, normalizedText)
// Why: iOS kills an active dictation/IME session when JS writes a value
// that differs from the native field text, so the controlled capture must
// echo the field verbatim; only the PTY mirror sees normalized text.
setLiveInputCapture(text)
applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text))
},
[
activeHandle,
+159
View File
@@ -0,0 +1,159 @@
import { createElement } from 'react'
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'
const connectMock = vi.fn()
const loadHostsMock = vi.fn()
vi.mock('./rpc-client', () => ({
connect: (...args: unknown[]) => connectMock(...args)
}))
vi.mock('./host-store', () => ({
loadHosts: () => loadHostsMock()
}))
vi.mock('./connection-revival-triggers', () => ({
subscribeConnectionRevivalTriggers: () => () => {}
}))
import { RpcClientProvider, useCloseHost, useHostClient } from './client-context'
type FakeClient = RpcClient & {
emitState: (state: ConnectionState) => void
closeMock: ReturnType<typeof vi.fn>
}
function makeFakeClient(initialState: ConnectionState): FakeClient {
let state = initialState
const listeners = new Set<(state: ConnectionState) => void>()
const closeMock = vi.fn()
return {
sendRequest: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => state,
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
onStateChange: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
notifyForeground: vi.fn(),
close: closeMock,
closeMock,
emitState: (next) => {
state = next
for (const listener of listeners) {
listener(next)
}
}
} as FakeClient
}
const HOST = {
id: 'host-1',
name: 'Host 1',
endpoint: 'ws://127.0.0.1:1',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 0
}
type Harness = {
readonly hook: ReturnType<typeof useHostClient>
readonly closeHost: (hostId: string) => void
readonly unmount: () => void
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => spy.mockRestore()
}
async function renderHarness(hostId: string): Promise<Harness> {
let hook: ReturnType<typeof useHostClient> | null = null
let closeHost: ((hostId: string) => void) | null = null
let renderer: ReactTestRenderer | null = null
function Probe(): null {
hook = useHostClient(hostId)
closeHost = useCloseHost()
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
})
} finally {
restore()
}
if (!hook || !closeHost || !renderer) {
throw new Error('harness did not render')
}
const mounted = renderer as ReactTestRenderer
return {
get hook() {
if (!hook) {
throw new Error('hook not rendered')
}
return hook
},
closeHost: (id) => {
if (!closeHost) {
throw new Error('closeHost not rendered')
}
closeHost(id)
},
unmount: () => mounted.unmount()
}
}
beforeEach(() => {
connectMock.mockReset()
loadHostsMock.mockReset()
})
describe('useHostClient', () => {
it('drops the closed client when the host entry is removed', async () => {
const fake = makeFakeClient('connected')
connectMock.mockReturnValue(fake)
loadHostsMock.mockResolvedValue([HOST])
const harness = await renderHarness(HOST.id)
expect(harness.hook.client).toBe(fake)
expect(harness.hook.state).toBe('connected')
// Regression (STA-1511): closeHost deletes the entry; before the fix the
// hook kept handing out the closed client, so mounted screens kept
// driving requests that could never resolve.
await act(async () => {
harness.closeHost(HOST.id)
})
expect(fake.closeMock).toHaveBeenCalled()
expect(harness.hook.client).toBeNull()
expect(harness.hook.state).toBe('disconnected')
harness.unmount()
})
it('reports disconnected instead of hanging when the host id is unknown', async () => {
loadHostsMock.mockResolvedValue([])
const harness = await renderHarness('missing-host')
expect(connectMock).not.toHaveBeenCalled()
expect(harness.hook.client).toBeNull()
expect(harness.hook.state).toBe('disconnected')
harness.unmount()
})
})
+11
View File
@@ -133,6 +133,11 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
return null
}
if (!host) {
// Why: returning silently leaves mounted screens on a permanent
// spinner (STA-1511) — surface 'disconnected' so they can render
// their waiting/retry affordance instead.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
}
@@ -406,6 +411,12 @@ export function useHostClient(hostId: string | undefined): {
if (found && found.client !== clientRef.current) {
clientRef.current = found.client
force((n) => n + 1)
} else if (!found && clientRef.current) {
// Why: closeHost deletes the entry without a replacement; holding the
// closed client would let screens keep issuing requests that can never
// resolve (STA-1511). Null it so they render disconnected states.
clientRef.current = null
force((n) => n + 1)
}
})
const initial = ctx.acquire(hostId)
@@ -0,0 +1,35 @@
// Prints the app's macOS notification settings as JSON and exits.
//
// Why this exists: Electron exposes no API for UNUserNotificationCenter
// authorization, and scheduling silently succeeds even while macOS suppresses
// display, so the renderer cannot know whether the user actually receives
// notifications. This binary must run from inside the app bundle (NSBundle
// resolves the bundle by walking up from the executable path) and must be
// code-signed with the app's identifier — macOS keys notification records to
// the signing identifier, which is why the build embeds an Info.plist section
// with the target CFBundleIdentifier.
import Foundation
import UserNotifications
let semaphore = DispatchSemaphore(value: 0)
var authorization = "unknown"
var alert = "unknown"
UNUserNotificationCenter.current().getNotificationSettings { settings in
switch settings.authorizationStatus {
case .authorized: authorization = "authorized"
case .provisional: authorization = "provisional"
case .ephemeral: authorization = "ephemeral"
case .denied: authorization = "denied"
case .notDetermined: authorization = "not-determined"
@unknown default: authorization = "unknown"
}
switch settings.alertSetting {
case .enabled: alert = "enabled"
case .disabled: alert = "disabled"
case .notSupported: alert = "not-supported"
@unknown default: alert = "unknown"
}
semaphore.signal()
}
_ = semaphore.wait(timeout: .now() + 3)
print("{\"authorization\":\"\(authorization)\",\"alert\":\"\(alert)\"}")
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.129-rc.3",
"version": "1.4.130",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -42,6 +42,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: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",
"verify:computer-native": "node config/scripts/verify-computer-native.mjs",
-523
View File
@@ -1,523 +0,0 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mobile Driver Overlay — Orca Preview</title>
<style>
/* ── Orca dark tokens (from main.css .dark) ── */
:root {
--background: #0a0a0a;
--foreground: #fafafa;
--card: #171717;
--card-foreground: #fafafa;
--primary: #e5e5e5;
--primary-foreground: #171717;
--muted: #262626;
--muted-foreground: #a1a1a1;
--accent: #404040;
--accent-foreground: #fafafa;
--border: rgb(255 255 255 / 0.07);
--input: rgb(255 255 255 / 0.15);
--ring: #737373;
--radius: 0.625rem;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
--orca-pane-title-height: 24px;
--orca-pane-title-fg: rgb(255 255 255 / 0.52);
--orca-pane-title-separator: rgb(255 255 255 / 0.06);
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--xterm-bg: #000000;
--xterm-fg: #cccccc;
--xterm-green: #23d18b;
--xterm-blue: #3b8eea;
--xterm-dim: #6a6a6a;
}
@font-face {
font-family: 'Geist';
src: url('../src/renderer/src/assets/fonts/Geist-Variable.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: 'Geist', ui-sans-serif, system-ui, sans-serif;
background: var(--background);
color: var(--foreground);
letter-spacing: 0.01em;
}
/* ── Preview chrome (switcher only — not part of Orca UI) ── */
.preview-chrome {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 92%, transparent);
backdrop-filter: blur(8px);
}
.preview-chrome h1 {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--muted-foreground);
}
.state-tabs {
display: flex;
gap: 0.375rem;
}
.state-tab {
height: 1.75rem;
padding: 0 0.75rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--muted-foreground);
font: inherit;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
}
.state-tab[aria-selected='true'] {
border-color: var(--input);
background: color-mix(in srgb, var(--input) 30%, transparent);
color: var(--foreground);
}
/* ── Orca workspace frame ── */
.workspace {
height: 100%;
padding-top: 3rem;
display: flex;
flex-direction: column;
}
.workspace-main {
flex: 1;
min-height: 0;
padding: 0.5rem;
}
/* Terminal pane — matches .pane + .terminal-container */
.pane {
position: relative;
height: 100%;
min-height: 28rem;
overflow: hidden;
background: var(--xterm-bg);
}
.pane[data-has-title]::after {
content: '';
position: absolute;
top: var(--orca-pane-title-height);
left: -5px;
right: -5px;
height: 1px;
background: var(--orca-pane-title-separator);
z-index: 6;
pointer-events: none;
}
.pane-title-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: var(--orca-pane-title-height);
z-index: 5;
display: flex;
align-items: center;
padding: 0 8px;
font-size: 13px;
font-family: var(--font-mono);
color: var(--orca-pane-title-fg);
background: transparent;
user-select: none;
}
.xterm-container {
position: relative;
width: calc(100% - 4px);
height: calc(100% - var(--orca-pane-title-height));
margin-top: var(--orca-pane-title-height);
margin-left: 4px;
}
.xterm-screen {
position: absolute;
inset: 0;
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.35;
color: var(--xterm-fg);
white-space: pre;
overflow: hidden;
}
.xterm-screen .green {
color: var(--xterm-green);
}
.xterm-screen .blue {
color: var(--xterm-blue);
}
.xterm-screen .dim {
color: var(--xterm-dim);
}
/* ── MobileDriverOverlay (from MobileDriverOverlay.tsx) ── */
.mobile-driver-banner {
pointer-events: none;
position: absolute;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.mobile-driver-banner.is-chip {
align-items: flex-start;
justify-content: flex-end;
padding: 0;
}
.overlay-card {
pointer-events: auto;
display: flex;
width: 100%;
max-width: 30rem;
flex-direction: column;
gap: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
color: var(--card-foreground);
padding: 1.5rem 1.5rem 1.25rem;
box-shadow: var(--shadow-xs);
}
.overlay-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.phone-icon-badge {
display: flex;
width: 2.5rem;
height: 2.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 9999px;
}
.phone-icon-badge.tone-driving {
background: var(--muted);
}
.phone-icon-badge.tone-held {
background: color-mix(in srgb, var(--muted) 60%, transparent);
}
.phone-icon-badge svg {
width: 1.25rem;
height: 1.25rem;
color: var(--foreground);
}
.overlay-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.25rem;
}
.overlay-eyebrow {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.025em;
text-transform: uppercase;
}
.overlay-eyebrow.tone-driving {
color: var(--foreground);
}
.overlay-eyebrow.tone-held {
color: var(--muted-foreground);
}
.live-dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 9999px;
background: var(--foreground);
}
.overlay-title {
font-size: 1rem;
font-weight: 600;
line-height: 1.25;
}
.overlay-body {
font-size: 0.875rem;
line-height: 1.625;
color: var(--muted-foreground);
}
.overlay-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.25rem;
}
/* Button primitive (button.tsx, dark theme) */
[data-slot='button'] {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
gap: 0.375rem;
border-radius: calc(var(--radius) - 2px);
border: 1px solid transparent;
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
cursor: default;
outline: none;
transition: all 150ms ease;
}
[data-slot='button'][data-size='sm'] {
height: 2rem;
padding: 0 0.75rem;
}
[data-slot='button'][data-size='xs'] {
height: 1.5rem;
gap: 0.25rem;
padding: 0 0.5rem;
font-size: 0.75rem;
}
[data-slot='button'][data-variant='default'] {
background: var(--primary);
color: var(--primary-foreground);
}
[data-slot='button'][data-variant='default'].is-focused {
border-color: var(--ring);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
[data-slot='button'][data-variant='outline'] {
border-color: var(--input);
background: color-mix(in srgb, var(--input) 30%, transparent);
color: var(--foreground);
box-shadow: var(--shadow-xs);
}
[data-slot='button'][data-variant='ghost'] {
background: transparent;
color: var(--foreground);
padding-left: 0.25rem;
padding-right: 0.25rem;
}
/* LockChip */
.mobile-driver-banner .lock-chip {
pointer-events: auto;
position: absolute;
right: 0.5rem;
top: 0.5rem;
z-index: 50;
display: flex;
align-items: center;
gap: 0.375rem;
border: 1px solid var(--border);
border-radius: 9999px;
background: var(--card);
color: var(--card-foreground);
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
box-shadow: var(--shadow-xs);
}
.lock-chip svg {
width: 0.75rem;
height: 0.75rem;
color: var(--foreground);
}
.state-panel[hidden] {
display: none !important;
}
</style>
</head>
<body>
<div class="preview-chrome">
<h1>Mobile Driver Overlay — production layout preview</h1>
<div class="state-tabs" role="tablist" aria-label="Overlay state">
<button class="state-tab" type="button" role="tab" data-state="driving" aria-selected="false">
Phone in control
</button>
<button class="state-tab" type="button" role="tab" data-state="held" aria-selected="true">
Phone session ended
</button>
<button class="state-tab" type="button" role="tab" data-state="chip" aria-selected="false">
Collapsed chip
</button>
</div>
</div>
<div class="workspace">
<div class="workspace-main">
<div class="pane" data-has-title>
<div class="pane-title-bar">
<span class="pane-title-text">bash — fix-phone-resizing</span>
</div>
<div class="xterm-container">
<div class="xterm-screen" aria-hidden="true">
<span class="green">➜</span> fix-phone-resizing <span class="dim">git:(</span><span class="blue">fix-phone-resizing</span><span class="dim">) ✗</span>
<span class="green">➜</span> fix-phone-resizing <span class="dim">git status</span>
On branch fix-phone-resizing
Changes not staged for commit:
<span class="dim">modified:</span> src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx
<span class="dim">modified:</span> src/renderer/src/i18n/locales/en.json
<span class="green">➜</span> fix-phone-resizing <span class="dim">_</span></div>
<!-- Driving -->
<div class="state-panel mobile-driver-banner" id="panel-driving" role="dialog" hidden>
<div class="overlay-card">
<div class="overlay-header">
<div class="phone-icon-badge tone-driving">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>
</div>
<div class="overlay-copy">
<div class="overlay-eyebrow tone-driving">
<span class="live-dot" aria-hidden="true"></span>
<span>From your phone</span>
</div>
<div class="overlay-title">Your phone is in control</div>
</div>
</div>
<div class="overlay-body">
Desktop keyboard is paused. Take back this terminal to type here, take back all terminals your phone controls, or collapse to keep watching.
</div>
<div class="overlay-actions">
<button data-slot="button" data-variant="outline" data-size="sm" type="button">Collapse</button>
<button data-slot="button" data-variant="outline" data-size="sm" type="button">Take back all terminals</button>
<button data-slot="button" data-variant="default" data-size="sm" type="button" class="is-focused">Take back this terminal</button>
</div>
</div>
</div>
<!-- Held -->
<div class="state-panel mobile-driver-banner" id="panel-held" role="dialog">
<div class="overlay-card">
<div class="overlay-header">
<div class="phone-icon-badge tone-held">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>
</div>
<div class="overlay-copy">
<div class="overlay-eyebrow tone-held">
<span>From your phone</span>
</div>
<div class="overlay-title">Your phone left this at phone size</div>
</div>
</div>
<div class="overlay-body">
Your phone session ended. Restore to desktop size for this terminal, or for all terminals your phone left at phone size.
</div>
<div class="overlay-actions">
<button data-slot="button" data-variant="outline" data-size="sm" type="button">Restore all terminals</button>
<button data-slot="button" data-variant="default" data-size="sm" type="button" class="is-focused">Restore this terminal</button>
</div>
</div>
</div>
<!-- Chip -->
<div class="state-panel mobile-driver-banner is-chip" id="panel-chip" hidden>
<div class="lock-chip mobile-driver-banner">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>
<button data-slot="button" data-variant="ghost" data-size="xs" type="button">Phone driving</button>
<button data-slot="button" data-variant="default" data-size="xs" type="button">Take back</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const tabs = document.querySelectorAll('.state-tab')
const panels = {
driving: document.getElementById('panel-driving'),
held: document.getElementById('panel-held'),
chip: document.getElementById('panel-chip')
}
function showState(state) {
for (const [key, panel] of Object.entries(panels)) {
panel.hidden = key !== state
}
tabs.forEach((tab) => {
tab.setAttribute('aria-selected', tab.dataset.state === state ? 'true' : 'false')
})
}
tabs.forEach((tab) => {
tab.addEventListener('click', () => showState(tab.dataset.state))
})
</script>
</body>
</html>
@@ -5,10 +5,8 @@ import { parseCodexSessionContent } from './session-scanner-codex-parser'
import { parseDevinSessionContent } from './session-scanner-devin-parser'
import { parseDroidSessionContent } from './session-scanner-droid-parser'
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
import {
parseClaudeSessionContent,
parseGeminiSessionContent
} from './session-scanner-primary-parsers'
import { parseClaudeSessionContent } from './session-scanner-primary-parsers'
import { parseGeminiSessionContent } from './session-scanner-gemini-parsers'
import {
parseCopilotSessionContent,
parseCursorSessionContent,
@@ -36,13 +34,19 @@ export function remoteSessionSources(
): RemoteSessionSource[] {
return [
...remoteCodexSources(remoteHome, hostPlatform),
jsonlSource(
'claude',
remoteHome,
hostPlatform,
['.claude', 'projects'],
parseClaudeSessionContent
),
{
...jsonlSource(
'claude',
remoteHome,
hostPlatform,
['.claude', 'projects'],
parseClaudeSessionContent
),
// The remote host owns the transcript disk, so the local readdir in the
// Claude parser is skipped; the walked listing supplies the sibling
// subagent counts instead.
collectSubagentSiblingCounts: true
},
source(
'gemini',
remoteHome,
@@ -21,6 +21,9 @@ export type RemoteSessionSource = {
rootDir: string
extensions: readonly string[]
filePredicate?: (path: string) => boolean
// Claude layout: count `<session>/subagents/*.jsonl` siblings from the walked
// listing and drop them from candidates instead of indexing them as sessions.
collectSubagentSiblingCounts?: boolean
parse: (
file: FileWithMtime,
content: string,
@@ -31,4 +34,5 @@ export type RemoteSessionSource = {
export type RemoteSessionCandidate = {
source: RemoteSessionSource
file: FileWithMtime
subagentTranscriptCount?: number
}
@@ -201,6 +201,79 @@ describe('scanRemoteAiVaultSessions', () => {
})
})
it('counts remote sibling subagent transcripts for zero-turn Claude sessions', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.claude/projects/repo/lost-session.jsonl',
jsonLines([{ type: 'mode', mode: 'default', sessionId: 'lost-session' }]),
50
)
provider.addFile(
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-a.jsonl',
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask A' } }]),
51
)
provider.addFile(
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-b.jsonl',
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask B' } }]),
52
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
// Subagent transcripts must not surface as standalone sessions; they only
// contribute recoverable signal to their zero-turn parent.
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
agent: 'claude',
sessionId: 'lost-session',
messageCount: 0,
subagentTranscriptCount: 2,
filePath: '/home/ada/.claude/projects/repo/lost-session.jsonl'
})
})
it('ignores remote subagent siblings for Claude sessions with real turns', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
'/home/ada/.claude/projects/repo/live-session.jsonl',
jsonLines([
{
sessionId: 'live-session',
type: 'user',
message: { content: [{ type: 'text', text: 'Do the thing' }] }
}
]),
60
)
provider.addFile(
'/home/ada/.claude/projects/repo/live-session/subagents/agent-a.jsonl',
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask' } }]),
61
)
const result = await scanRemoteAiVaultSessions({
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(result.issues).toEqual([])
expect(result.sessions).toHaveLength(1)
expect(result.sessions[0]).toMatchObject({
sessionId: 'live-session',
messageCount: 1,
subagentTranscriptCount: 0
})
})
it('builds resume commands with the remote host platform', async () => {
const provider = new MemoryRemoteProvider()
provider.addFile(
+19 -3
View File
@@ -11,6 +11,7 @@ import type { FileStat, IFilesystemProvider } from '../providers/types'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { sessionSortTime } from './session-scanner-accumulator'
import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import { remoteSessionSources } from './remote-session-scanner-sources'
@@ -77,11 +78,15 @@ async function discoverRemoteSourceCandidates(args: {
context: RemoteScannerContext
issues: AiVaultScanIssue[]
}): Promise<RemoteSessionCandidate[]> {
const paths = await walkRemoteSessionFiles(
const walked = await walkRemoteSessionFiles(
args.source,
args.context.provider,
args.context.hostPlatform
)
const partition = args.source.collectSubagentSiblingCounts
? partitionSubagentTranscriptPaths(walked)
: null
const paths = partition ? partition.sessionFilePaths : walked
const files = await mapRemoteScanConcurrently(paths, (path) =>
statRemoteFile(
args.context.provider,
@@ -93,7 +98,11 @@ async function discoverRemoteSourceCandidates(args: {
)
return files
.filter((file): file is FileWithMtime => Boolean(file))
.map((file) => ({ source: args.source, file }))
.map((file) => ({
source: args.source,
file,
subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0
}))
}
async function walkRemoteSessionFiles(
@@ -199,7 +208,14 @@ async function parseRemoteSessionCandidate(
if (read.isBinary) {
return null
}
return candidate.source.parse(candidate.file, read.content, context)
const session = await candidate.source.parse(candidate.file, read.content, context)
// Mirror the local zero-turn-only rule: sibling subagent transcripts are
// recoverable signal only when the parent conversation persisted no turns.
const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0
if (session && session.messageCount === 0 && subagentTranscriptCount > 0) {
return { ...session, subagentTranscriptCount }
}
return session
} catch (err) {
issues.push({
executionHostId: context.executionHostId,
@@ -139,6 +139,8 @@ function session(
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume ${sessionId}`
}
}
@@ -70,6 +70,10 @@ const aiVaultListResultSchema = z.object({
messageCount: z.number(),
totalTokens: z.number(),
previewMessages: z.array(aiVaultSessionPreviewMessageSchema),
// Default keeps remote hosts running an older build (no recoverable-signal
// fields) parseable; they simply report no recoverable-empty sessions.
queuedMessageCount: z.number().default(0),
subagentTranscriptCount: z.number().default(0),
resumeCommand: z.string()
})
),
@@ -41,6 +41,8 @@ export function createAccumulator(args: {
messageCount: 0,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
latestTimestampMs: 0
}
}
@@ -110,6 +112,8 @@ export function finalizeSession(
messageCount: accumulator.messageCount,
totalTokens: accumulator.totalTokens,
previewMessages: accumulator.previewMessages,
queuedMessageCount: accumulator.queuedMessageCount,
subagentTranscriptCount: accumulator.subagentTranscriptCount,
resumeCommand: buildAiVaultResumeCommand({
agent: accumulator.agent,
sessionId,
@@ -6,7 +6,8 @@ import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-sc
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { parseClaudeSessionFile, parseGeminiSessionFile } from './session-scanner-primary-parsers'
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
import { parseGeminiSessionFile } from './session-scanner-gemini-parsers'
import { parseCodexSessionFile } from './session-scanner-codex-parser'
import {
parseCopilotSessionFile,
@@ -0,0 +1,156 @@
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type {
FileWithMtime,
ResumableParseFinalizeOptions,
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import {
accumulatorFoldResumeState,
addPreviewContent,
createAccumulator,
finalizeSession,
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import {
arrayValue,
asRecord,
extractContentText,
extractString,
parseJsonObject,
tokenTotal
} from './session-scanner-values'
export async function parseGeminiSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
if (file.path.endsWith('.jsonl')) {
return parseGeminiJsonlSessionFile(file, platform)
}
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
}
export async function parseGeminiSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ResumableParseFinalizeOptions = {}
): Promise<AiVaultSession | null> {
if (file.path.endsWith('.jsonl')) {
return parseGeminiJsonlSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
return parseGeminiJsonSessionContent(file, content, platform, options)
}
function parseGeminiJsonSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: ResumableParseFinalizeOptions = {}
): AiVaultSession | null {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
return null
}
const accumulator = createAccumulator({
agent: 'gemini',
file,
sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path)
})
updateTimeline(accumulator, extractString(record.startTime))
updateTimeline(accumulator, extractString(record.lastUpdated))
for (const message of arrayValue(record.messages)) {
consumeGeminiMessage(accumulator, asRecord(message))
}
return finalizeSession(accumulator, platform, options)
}
export async function parseGeminiJsonlSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseGeminiJsonlSessionLines({ file, lines, platform })
}
function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: string): void {
const record = parseJsonObject(line)
if (!record) {
return
}
const setRecord = asRecord(record.$set)
if (setRecord) {
updateTimeline(accumulator, extractString(setRecord.lastUpdated))
return
}
const sessionId = extractString(record.sessionId)
if (sessionId) {
accumulator.sessionId = sessionId
}
updateTimeline(accumulator, extractString(record.startTime))
updateTimeline(accumulator, extractString(record.lastUpdated))
consumeGeminiMessage(accumulator, record)
}
// Resumable only for the JSONL log format; Gemini's legacy single-JSON
// session documents are rewritten in place and must be re-read whole.
export function createGeminiJsonlSessionResumeState(
file: FileWithMtime
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }),
consumeGeminiJsonlRecordLine
)
}
async function parseGeminiJsonlSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ResumableParseFinalizeOptions
}): Promise<AiVaultSession | null> {
const state = createGeminiJsonlSessionResumeState(args.file)
for await (const line of args.lines) {
state.consumeLine(line)
}
return state.finalize(args.platform, args.options)
}
export function consumeGeminiMessage(
accumulator: SessionAccumulator,
record: Record<string, unknown> | null
): void {
if (!record) {
return
}
updateTimeline(accumulator, extractString(record.timestamp))
if (record.type === 'user') {
accumulator.messageCount++
accumulator.title ??= extractContentText(record.content)
addPreviewContent(accumulator, 'user', record.content, record.timestamp)
return
}
if (record.type === 'gemini') {
accumulator.messageCount++
addPreviewContent(accumulator, 'assistant', record.content, record.timestamp)
const model = extractString(record.model)
if (model) {
accumulator.model = model
}
accumulator.totalTokens += tokenTotal(record.tokens)
}
}
@@ -5,14 +5,13 @@ import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { createCodexSessionResumeState } from './session-scanner-codex-parser'
import { createDroidSessionResumeState } from './session-scanner-droid-parser'
import { createMessageGraphSessionResumeState } from './session-scanner-graph-parsers'
import {
createClaudeSessionResumeState,
createGeminiJsonlSessionResumeState
} from './session-scanner-primary-parsers'
import { createClaudeSessionResumeState } from './session-scanner-primary-parsers'
import { createGeminiJsonlSessionResumeState } from './session-scanner-gemini-parsers'
import {
createCopilotSessionResumeState,
createCursorSessionResumeState
} from './session-scanner-secondary-parsers'
import { countSubagentTranscripts } from './session-scanner-subagent-transcripts'
import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types'
// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a
@@ -132,6 +131,16 @@ export async function parseAgentSessionFileCached(
if (stats) {
stats.reused++
}
// A zero-turn transcript usually never changes again, but its sibling
// subagents/ dir can gain files after the parent's last write (a
// still-running subagent finishing). The mtime+size key can't see that,
// so refresh the cheap directory count on reuse.
if (entry.session && candidate.agent === 'claude' && entry.session.messageCount === 0) {
const subagentTranscriptCount = await countSubagentTranscripts(file.path)
if (subagentTranscriptCount !== entry.session.subagentTranscriptCount) {
entry.session = { ...entry.session, subagentTranscriptCount }
}
}
storeEntry(file.path, entry)
return entry.session
}
@@ -1,15 +1,13 @@
import { createReadStream } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createInterface } from 'node:readline'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import type {
FileWithMtime,
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import {
accumulatorFoldResumeState,
addPreviewContent,
createAccumulator,
finalizeSession,
@@ -17,16 +15,14 @@ import {
updateLatestLocation,
updateTimeline
} from './session-scanner-accumulator'
import { countSubagentTranscripts } from './session-scanner-subagent-transcripts'
import {
arrayValue,
asRecord,
claudeUsageTotal,
extractContentText,
extractMessageText,
extractString,
normalizeTitleText,
parseJsonObject,
tokenTotal
parseJsonObject
} from './session-scanner-values'
type ParserSessionOptions = {
@@ -102,6 +98,22 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s
return
}
if (record.type === 'queue-operation') {
// Enqueued prompts hold real content (e.g. queued subagent messages) that
// survives even when the conversation was never persisted — recoverable
// signal for an otherwise-empty session, but not a conversation turn.
// Count net of remove/dequeue: consumed or user-removed prompts are written
// as later queue-operation records and are no longer queued. Accepted gap:
// a dequeue/remove of an uncounted empty-content enqueue can undercount,
// which only hides the recoverable badge — it never fabricates one.
if (record.operation === 'enqueue' && (extractString(record.content)?.trim().length ?? 0) > 0) {
accumulator.queuedMessageCount++
} else if (record.operation === 'remove' || record.operation === 'dequeue') {
accumulator.queuedMessageCount = Math.max(0, accumulator.queuedMessageCount - 1)
}
return
}
if (record.type === 'user') {
accumulator.messageCount++
const title = extractMessageText(record.message)
@@ -129,11 +141,11 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s
}
}
export function finalizeClaudeSessionParseState(
export async function finalizeClaudeSessionParseState(
state: ClaudeSessionParseState,
platform: NodeJS.Platform,
options: ParserSessionOptions = {}
): AiVaultSession | null {
): Promise<AiVaultSession | null> {
// Finalize a snapshot: the live state (and its preview array) may keep
// accumulating appended lines after this session object is handed out.
const snapshot = cloneClaudeSessionParseState(state)
@@ -141,6 +153,17 @@ export function finalizeClaudeSessionParseState(
// session name (ai-title) should outrank the raw first prompt when present.
snapshot.accumulator.fallbackTitle =
snapshot.generatedTitle ?? snapshot.firstUserTitle ?? snapshot.metaTitle
// Only a zero-turn transcript needs its sibling subagent transcripts counted;
// for normal sessions the extra directory read is skipped. The sibling dir
// lives on the host that owns the transcript, so content fetched from a
// remote (SSH) host must not readdir this machine's disk.
const ownsTranscriptDisk =
!options.executionHostId || options.executionHostId === LOCAL_EXECUTION_HOST_ID
if (snapshot.accumulator.messageCount === 0 && ownsTranscriptDisk) {
snapshot.accumulator.subagentTranscriptCount = await countSubagentTranscripts(
snapshot.accumulator.filePath
)
}
return finalizeSession(snapshot.accumulator, platform, options)
}
@@ -198,133 +221,3 @@ async function parseClaudeSessionLines(args: {
}
return finalizeClaudeSessionParseState(state, args.platform, args.options)
}
export async function parseGeminiSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
if (file.path.endsWith('.jsonl')) {
return parseGeminiJsonlSessionFile(file, platform)
}
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
}
export async function parseGeminiSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform = process.platform,
options: ParserSessionOptions = {}
): Promise<AiVaultSession | null> {
if (file.path.endsWith('.jsonl')) {
return parseGeminiJsonlSessionLines({
file,
lines: content.split(/\r?\n/),
platform,
options
})
}
return parseGeminiJsonSessionContent(file, content, platform, options)
}
function parseGeminiJsonSessionContent(
file: FileWithMtime,
content: string,
platform: NodeJS.Platform,
options: ParserSessionOptions = {}
): AiVaultSession | null {
const record = asRecord(JSON.parse(content) as unknown)
if (!record) {
return null
}
const accumulator = createAccumulator({
agent: 'gemini',
file,
sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path)
})
updateTimeline(accumulator, extractString(record.startTime))
updateTimeline(accumulator, extractString(record.lastUpdated))
for (const message of arrayValue(record.messages)) {
consumeGeminiMessage(accumulator, asRecord(message))
}
return finalizeSession(accumulator, platform, options)
}
export async function parseGeminiJsonlSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform
): Promise<AiVaultSession | null> {
const lines = createInterface({
input: createReadStream(file.path, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
return parseGeminiJsonlSessionLines({ file, lines, platform })
}
function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: string): void {
const record = parseJsonObject(line)
if (!record) {
return
}
const setRecord = asRecord(record.$set)
if (setRecord) {
updateTimeline(accumulator, extractString(setRecord.lastUpdated))
return
}
const sessionId = extractString(record.sessionId)
if (sessionId) {
accumulator.sessionId = sessionId
}
updateTimeline(accumulator, extractString(record.startTime))
updateTimeline(accumulator, extractString(record.lastUpdated))
consumeGeminiMessage(accumulator, record)
}
// Resumable only for the JSONL log format; Gemini's legacy single-JSON
// session documents are rewritten in place and must be re-read whole.
export function createGeminiJsonlSessionResumeState(
file: FileWithMtime
): ResumableSessionParseState {
return accumulatorFoldResumeState(
createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }),
consumeGeminiJsonlRecordLine
)
}
async function parseGeminiJsonlSessionLines(args: {
file: FileWithMtime
lines: AsyncIterable<string> | Iterable<string>
platform: NodeJS.Platform
options?: ParserSessionOptions
}): Promise<AiVaultSession | null> {
const state = createGeminiJsonlSessionResumeState(args.file)
for await (const line of args.lines) {
state.consumeLine(line)
}
return state.finalize(args.platform, args.options)
}
export function consumeGeminiMessage(
accumulator: SessionAccumulator,
record: Record<string, unknown> | null
): void {
if (!record) {
return
}
updateTimeline(accumulator, extractString(record.timestamp))
if (record.type === 'user') {
accumulator.messageCount++
accumulator.title ??= extractContentText(record.content)
addPreviewContent(accumulator, 'user', record.content, record.timestamp)
return
}
if (record.type === 'gemini') {
accumulator.messageCount++
addPreviewContent(accumulator, 'assistant', record.content, record.timestamp)
const model = extractString(record.model)
if (model) {
accumulator.model = model
}
accumulator.totalTokens += tokenTotal(record.tokens)
}
}
@@ -0,0 +1,245 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { isAiVaultSessionRecoverableEmpty } from '../../shared/ai-vault-types'
import { scanAiVaultSessions } from './session-scanner'
import { parseClaudeSessionContent } from './session-scanner-primary-parsers'
import { countSubagentTranscripts } from './session-scanner-subagent-transcripts'
import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
// Shape of the real ~/.claude/.../9176163a-*.jsonl artifact: CLI metadata plus
// four queued subagent messages, and zero user/assistant conversation turns.
function metadataOnlyTranscript(sessionId: string): unknown[] {
return [
{
type: 'last-prompt',
lastPrompt: 'Run the review',
leafUuid: 'leaf-1',
sessionId
},
{ type: 'ai-title', aiTitle: 'Push failure recovery review', sessionId },
{ type: 'mode', mode: 'default', sessionId },
{ type: 'permission-mode', permissionMode: 'acceptEdits', sessionId },
...['contracts', 'environment', 'correctness', 'perf'].map((lens, index) => ({
type: 'queue-operation',
operation: 'enqueue',
timestamp: `2026-07-08T16:3${index}:00.000Z`,
sessionId,
content: `<agent-message from="rev-${lens}">Review complete</agent-message>`
}))
]
}
describe('recoverable-but-empty Claude sessions', () => {
it('surfaces a zero-turn session with queued messages and subagent transcripts', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-recoverable-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const sessionId = '9176163a-2f89-431f-b202-32f04d61f124'
await writeJsonlFile(
join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`),
metadataOnlyTranscript(sessionId)
)
// Sibling subagent transcripts survive even though the parent conversation
// was never persisted; a .meta.json sidecar is not a transcript.
const subagentsDir = join(roots.claudeProjectsDir, 'project', sessionId, 'subagents')
await writeJsonlFile(join(subagentsDir, 'agent-arev-contracts-1.jsonl'), [
{
type: 'user',
sessionId,
message: { role: 'user', content: 'Contracts review' }
}
])
await writeJsonlFile(join(subagentsDir, 'agent-arev-perf-2.jsonl'), [
{
type: 'user',
sessionId,
message: { role: 'user', content: 'Perf review' }
}
])
await writeFile(join(subagentsDir, 'agent-arev-contracts-1.meta.json'), '{}')
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
const session = result.sessions.find((entry) => entry.sessionId === sessionId)
expect(session).toBeDefined()
expect(session?.messageCount).toBe(0)
expect(session?.queuedMessageCount).toBe(4)
expect(session?.subagentTranscriptCount).toBe(2)
expect(isAiVaultSessionRecoverableEmpty(session!)).toBe(true)
})
it('reports no recoverable signal for a plain metadata-only session', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-plain-empty-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const sessionId = 'plain-empty-session'
await writeJsonlFile(join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`), [
{ type: 'mode', mode: 'default', sessionId },
{ type: 'permission-mode', permissionMode: 'default', sessionId }
])
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
const session = result.sessions.find((entry) => entry.sessionId === sessionId)
expect(session).toBeDefined()
expect(session?.messageCount).toBe(0)
expect(session?.queuedMessageCount).toBe(0)
expect(session?.subagentTranscriptCount).toBe(0)
expect(isAiVaultSessionRecoverableEmpty(session!)).toBe(false)
})
it('does not count subagent transcripts for a session that has real turns', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nonempty-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const sessionId = 'conversation-session'
await writeJsonlFile(join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`), [
{
type: 'user',
sessionId,
message: { role: 'user', content: 'Do the thing' }
}
])
await writeJsonlFile(
join(roots.claudeProjectsDir, 'project', sessionId, 'subagents', 'agent-x.jsonl'),
[
{
type: 'user',
sessionId,
message: { role: 'user', content: 'Subtask' }
}
]
)
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
const session = result.sessions.find((entry) => entry.sessionId === sessionId)
expect(session?.messageCount).toBe(1)
// Skipped for sessions with content: the directory read only runs at zero turns.
expect(session?.subagentTranscriptCount).toBe(0)
})
it('counts queued messages net of remove and dequeue operations', async () => {
const sessionId = 'net-queue-session'
const content = [
{ type: 'mode', mode: 'default', sessionId },
...[1, 2, 3].map((n) => ({
type: 'queue-operation',
operation: 'enqueue',
sessionId,
content: `queued prompt ${n}`
})),
// One prompt consumed, one removed by the user: only one is still queued.
{ type: 'queue-operation', operation: 'dequeue', sessionId },
{
type: 'queue-operation',
operation: 'remove',
sessionId,
content: null
}
]
.map((line) => JSON.stringify(line))
.join('\n')
const file = {
path: `/tmp/${sessionId}.jsonl`,
mtimeMs: 0,
modifiedAt: '2026-07-08T16:30:00.000Z'
}
const session = await parseClaudeSessionContent(file, content, 'darwin', {
executionHostId: 'ssh:host'
})
expect(session?.queuedMessageCount).toBe(1)
})
it('picks up subagent transcripts written after the parent transcript last changed', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-late-sub-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const sessionId = 'late-subagent-session'
await writeJsonlFile(join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`), [
{ type: 'mode', mode: 'default', sessionId }
])
const first = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
expect(
first.sessions.find((entry) => entry.sessionId === sessionId)?.subagentTranscriptCount
).toBe(0)
// The parent file never changes again, but a still-running subagent lands
// its transcript afterwards; the cached parse must still surface it.
await writeJsonlFile(
join(roots.claudeProjectsDir, 'project', sessionId, 'subagents', 'agent-late.jsonl'),
[
{
type: 'user',
sessionId,
message: { role: 'user', content: 'Late subtask' }
}
]
)
const second = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
expect(
second.sessions.find((entry) => entry.sessionId === sessionId)?.subagentTranscriptCount
).toBe(1)
})
it('never counts local subagent transcripts for a remote-host transcript', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-remote-'))
tempRoots.push(root)
const sessionId = 'remote-session'
const transcriptPath = join(root, `${sessionId}.jsonl`)
// A local sibling dir exists, but the transcript content came from an SSH
// host — its real subagents live on that host, not on this disk.
await writeJsonlFile(join(root, sessionId, 'subagents', 'agent-x.jsonl'), [{ type: 'user' }])
const content = metadataOnlyTranscript(sessionId)
.map((line) => JSON.stringify(line))
.join('\n')
const file = {
path: transcriptPath,
mtimeMs: 0,
modifiedAt: '2026-07-08T16:30:00.000Z'
}
const remote = await parseClaudeSessionContent(file, content, 'linux', {
executionHostId: 'ssh:host'
})
expect(remote?.subagentTranscriptCount).toBe(0)
expect(remote?.queuedMessageCount).toBe(4)
const local = await parseClaudeSessionContent(file, content, 'darwin')
expect(local?.subagentTranscriptCount).toBe(1)
})
})
describe('countSubagentTranscripts', () => {
it('returns 0 when the sibling subagents directory is absent', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nosub-'))
tempRoots.push(root)
expect(await countSubagentTranscripts(join(root, 'session.jsonl'))).toBe(0)
})
it('counts only .jsonl transcripts, excluding meta sidecars', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-sub-count-'))
tempRoots.push(root)
const transcriptPath = join(root, 'session.jsonl')
const subagentsDir = join(root, 'session', 'subagents')
await writeJsonlFile(join(subagentsDir, 'agent-a.jsonl'), [{ type: 'user' }])
await writeJsonlFile(join(subagentsDir, 'agent-b.jsonl'), [{ type: 'user' }])
await writeFile(join(subagentsDir, 'agent-a.meta.json'), '{}')
expect(await countSubagentTranscripts(transcriptPath)).toBe(2)
})
})
@@ -0,0 +1,65 @@
import { readdir } from 'node:fs/promises'
import { basename, dirname, extname, join } from 'node:path'
// Claude writes subagent transcripts to a sibling directory named after the
// parent transcript file (…/<enc>/<uuid>.jsonl → …/<enc>/<uuid>/subagents/).
// These survive intact even when the parent conversation persisted zero turns,
// so they are the recoverable signal that keeps such a session from being hidden.
export function subagentTranscriptsDirFor(transcriptFilePath: string): string {
const stem = basename(transcriptFilePath, extname(transcriptFilePath))
return join(dirname(transcriptFilePath), stem, 'subagents')
}
/**
* Count sibling subagent transcript files for a session's transcript. Returns 0
* when the directory is absent (the common case), so callers can treat any
* positive count as recoverable content. Meta sidecars (`*.meta.json`) are not
* transcripts and are excluded.
*/
export async function countSubagentTranscripts(transcriptFilePath: string): Promise<number> {
let entries: string[]
try {
entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath))
} catch {
return 0
}
return entries.filter((name) => name.endsWith('.jsonl')).length
}
// Direct child of a subagents dir: `<parent>/<uuid>/subagents/<file>.jsonl`.
// Greedy prefix means nested subagent trees attribute to their nearest parent,
// matching the local direct-children-only readdir semantics.
const SUBAGENT_DIRECT_CHILD_PATTERN = /^(.*)[\\/]subagents[\\/][^\\/]+\.jsonl$/i
const SUBAGENT_SUBTREE_PATTERN = /[\\/]subagents[\\/]/i
/**
* Partition a recursively walked transcript listing into session candidates and
* per-parent sibling subagent transcript counts. Remote (SSH) scans cannot
* readdir the transcript's sibling directory, but their walk already enumerates
* subagent paths — counting from the listing costs no extra round-trips.
* Subagent transcripts share the parent sessionId and are not independently
* resumable, so they are excluded from candidates (mirrors the local discovery
* pruning in session-scanner-source-discovery.ts).
*/
export function partitionSubagentTranscriptPaths(paths: readonly string[]): {
sessionFilePaths: string[]
subagentTranscriptCounts: Map<string, number>
} {
const sessionFilePaths: string[] = []
const subagentTranscriptCounts = new Map<string, number>()
for (const path of paths) {
if (!SUBAGENT_SUBTREE_PATTERN.test(path)) {
sessionFilePaths.push(path)
continue
}
const directChild = SUBAGENT_DIRECT_CHILD_PATTERN.exec(path)
if (directChild) {
const parentTranscriptPath = `${directChild[1]}.jsonl`
subagentTranscriptCounts.set(
parentTranscriptPath,
(subagentTranscriptCounts.get(parentTranscriptPath) ?? 0) + 1
)
}
}
return { sessionFilePaths, subagentTranscriptCounts }
}
@@ -100,6 +100,9 @@ export type SessionAccumulator = {
messageCount: number
totalTokens: number
previewMessages: AiVaultSessionPreviewMessage[]
// Recoverable signal for a zero-turn transcript (see AiVaultSession).
queuedMessageCount: number
subagentTranscriptCount: number
latestTimestampMs: number
}
@@ -157,6 +157,29 @@ describe('BrowserSessionRegistry persistence', () => {
expect(fsState.present.has('/user-data/Partitions/orca-browser/Cookies')).toBe(true)
})
it('persists new browser session profiles under the active Orca profile directory', async () => {
const fsState = createFsState()
const profileMetaPath = '/user-data/profiles/local-work/browser-session-meta.json'
installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
browserSessionRegistry.configureForOrcaProfile({
orcaProfileId: 'local-work',
profileDirectory: '/user-data/profiles/local-work'
})
const profile = browserSessionRegistry.createProfile('isolated', 'Work Browser')
expect(profile).not.toBeNull()
expect(fsState.files.has(profileMetaPath)).toBe(true)
expect(fsState.files.has(META_PATH)).toBe(false)
expect(JSON.parse(fsState.files.get(profileMetaPath) ?? '{}').profiles[0]).toMatchObject({
id: profile!.id,
partition: profile!.partition,
label: 'Work Browser'
})
})
it('merges partition-keyed pending entries without clobbering unrelated entries', async () => {
const fsState = createFsState()
seedMeta(fsState, {
@@ -28,6 +28,11 @@ vi.mock('./browser-manager', () => ({
import { browserSessionRegistry } from './browser-session-registry'
import { setupClientHintsOverride } from './browser-session-ua'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
import {
DEFAULT_LOCAL_ORCA_PROFILE_ID,
getOrcaProfileBrowserDefaultPartition,
getOrcaProfileBrowserSessionPartition
} from '../../shared/orca-profiles'
describe('BrowserSessionRegistry', () => {
beforeEach(() => {
@@ -280,6 +285,30 @@ describe('BrowserSessionRegistry', () => {
expect(webAuthnCallback).toHaveBeenCalledWith('credential-1')
})
it('uses profile-owned partitions for non-default Orca profiles', () => {
const orcaProfileId = 'local-work'
browserSessionRegistry.configureForOrcaProfile({
orcaProfileId,
profileDirectory: '/profiles/local-work'
})
expect(browserSessionRegistry.getDefaultProfile().partition).toBe(
getOrcaProfileBrowserDefaultPartition(orcaProfileId)
)
expect(browserSessionRegistry.isAllowedPartition(ORCA_BROWSER_PARTITION)).toBe(false)
const profile = browserSessionRegistry.createProfile('isolated', 'Work Browser')
expect(profile).not.toBeNull()
expect(profile!.partition).toBe(
getOrcaProfileBrowserSessionPartition(orcaProfileId, profile!.id)
)
browserSessionRegistry.configureForOrcaProfile({
orcaProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
profileDirectory: '/profiles/local-default'
})
})
describe('setupClientHintsOverride', () => {
it('overrides sec-ch-ua headers for Edge UA', () => {
const onBeforeSendHeaders = vi.fn()
+82 -31
View File
@@ -14,8 +14,14 @@ import {
unlinkSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { ORCA_BROWSER_PARTITION } from '../../shared/constants'
import {
DEFAULT_LOCAL_ORCA_PROFILE_ID,
getOrcaProfileBrowserDefaultPartition,
getOrcaProfileBrowserPartitionSegment,
getOrcaProfileBrowserSessionPartition
} from '../../shared/orca-profiles'
import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../shared/types'
import { browserManager } from './browser-manager'
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
@@ -36,6 +42,15 @@ type BrowserSessionMeta = {
profiles: BrowserSessionProfile[]
}
export type BrowserSessionRegistryProfileOptions = {
orcaProfileId: string
profileDirectory: string
}
const BROWSER_SESSION_META_FILE_NAME = 'browser-session-meta.json'
const LEGACY_BROWSER_SESSION_PARTITION_RE =
/^persist:orca-browser-session-[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/
// Why: the registry is the single source of truth for which Electron partitions
// are valid. will-attach-webview consults it to decide whether a guest's
// requested partition is allowed. This prevents a compromised renderer from
@@ -43,13 +58,28 @@ type BrowserSessionMeta = {
class BrowserSessionRegistry {
private readonly profiles = new Map<string, BrowserSessionProfile>()
private activeOrcaProfileId = DEFAULT_LOCAL_ORCA_PROFILE_ID
private metadataPathOverride: string | null = null
private defaultPartition = ORCA_BROWSER_PARTITION
constructor() {
this.resetDefaultProfile()
}
configureForOrcaProfile(options: BrowserSessionRegistryProfileOptions): void {
this.activeOrcaProfileId = options.orcaProfileId
this.metadataPathOverride = join(options.profileDirectory, BROWSER_SESSION_META_FILE_NAME)
this.defaultPartition = getOrcaProfileBrowserDefaultPartition(options.orcaProfileId)
this.profiles.clear()
this.resetDefaultProfile()
}
private resetDefaultProfile(): void {
const persisted = this.loadPersistedSource()
this.profiles.set('default', {
id: 'default',
scope: 'default',
partition: ORCA_BROWSER_PARTITION,
partition: this.defaultPartition,
label: 'Default',
source: persisted
})
@@ -60,7 +90,9 @@ class BrowserSessionRegistry {
// status. Cookies themselves persist in the Electron partition's SQLite DB,
// but the registry is in-memory only.
private get metadataPath(): string {
return join(app.getPath('userData'), 'browser-session-meta.json')
return (
this.metadataPathOverride ?? join(app.getPath('userData'), BROWSER_SESSION_META_FILE_NAME)
)
}
private loadPersistedSource(): BrowserSessionProfile['source'] {
@@ -78,6 +110,7 @@ class BrowserSessionRegistry {
try {
const existing = this.loadPersistedMeta()
const tmpPath = `${this.metadataPath}.tmp`
mkdirSync(dirname(this.metadataPath), { recursive: true })
writeFileSync(tmpPath, JSON.stringify({ ...existing, ...updates }))
renameSync(tmpPath, this.metadataPath)
} catch {
@@ -108,8 +141,8 @@ class BrowserSessionRegistry {
data && typeof data.userAgentByPartition === 'object' && data.userAgentByPartition
? { ...data.userAgentByPartition }
: {}
if (legacyUserAgent && !userAgentByPartition[ORCA_BROWSER_PARTITION]) {
userAgentByPartition[ORCA_BROWSER_PARTITION] = legacyUserAgent
if (legacyUserAgent && !userAgentByPartition[this.defaultPartition]) {
userAgentByPartition[this.defaultPartition] = legacyUserAgent
}
const legacyPendingCookieDbPath =
@@ -118,8 +151,8 @@ class BrowserSessionRegistry {
data && typeof data.pendingCookieImports === 'object' && data.pendingCookieImports
? { ...data.pendingCookieImports }
: {}
if (legacyPendingCookieDbPath && !pendingCookieImports[ORCA_BROWSER_PARTITION]) {
pendingCookieImports[ORCA_BROWSER_PARTITION] = legacyPendingCookieDbPath
if (legacyPendingCookieDbPath && !pendingCookieImports[this.defaultPartition]) {
pendingCookieImports[this.defaultPartition] = legacyPendingCookieDbPath
}
return {
defaultSource: data?.defaultSource ?? null,
@@ -168,10 +201,10 @@ class BrowserSessionRegistry {
// hydrateFromPersisted skips the default partition and createProfile never
// targets it. Without this, clipboard permissions and other guest policies
// are denied by default in the default browser partition.
this.setupSessionPolicies(ORCA_BROWSER_PARTITION)
this.setupSessionPolicies(this.defaultPartition)
const partitions = new Set([
ORCA_BROWSER_PARTITION,
this.defaultPartition,
...this.listProfiles().map((p) => p.partition)
])
for (const partition of partitions) {
@@ -208,9 +241,9 @@ class BrowserSessionRegistry {
}
// Why: replay writes to partition-derived file paths, so corrupted
// metadata must pass the same validation as the webview allowlist.
const knownPartitions = new Set([ORCA_BROWSER_PARTITION])
const knownPartitions = new Set([this.defaultPartition])
for (const profile of meta.profiles) {
if (BrowserSessionRegistry.isValidPersistedProfile(profile)) {
if (this.isValidPersistedProfile(profile)) {
knownPartitions.add(profile.partition)
}
}
@@ -270,7 +303,7 @@ class BrowserSessionRegistry {
}
this.persistMeta({
pendingCookieImports: remainingEntries,
pendingCookieDbPath: remainingEntries[ORCA_BROWSER_PARTITION] ?? null
pendingCookieDbPath: remainingEntries[this.defaultPartition] ?? null
})
} catch {
// best-effort — if this fails, CookieMonster loads the old DB
@@ -282,7 +315,7 @@ class BrowserSessionRegistry {
const pendingCookieImports = { ...meta.pendingCookieImports, [partition]: stagingDbPath }
this.persistMeta({
pendingCookieImports,
pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null
pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null
})
}
@@ -296,7 +329,7 @@ class BrowserSessionRegistry {
}
this.persistMeta({
userAgentByPartition,
userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null
userAgent: userAgentByPartition[this.defaultPartition] ?? null
})
}
@@ -313,7 +346,7 @@ class BrowserSessionRegistry {
}
isAllowedPartition(partition: string): boolean {
if (partition === ORCA_BROWSER_PARTITION) {
if (partition === this.defaultPartition) {
return true
}
return [...this.profiles.values()].some((p) => p.partition === partition)
@@ -321,14 +354,17 @@ class BrowserSessionRegistry {
resolvePartition(profileId: string | null | undefined): string {
if (!profileId) {
return ORCA_BROWSER_PARTITION
return this.defaultPartition
}
return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION
return this.profiles.get(profileId)?.partition ?? this.defaultPartition
}
resolveKnownPartition(profileId: string | null | undefined): string | null {
if (!profileId) {
return ORCA_BROWSER_PARTITION
// Why: must track the active Orca profile's default partition, not the
// legacy constant, or non-default profiles would resolve local-default's
// cookie jar.
return this.defaultPartition
}
return this.profiles.get(profileId)?.partition ?? null
}
@@ -336,7 +372,7 @@ class BrowserSessionRegistry {
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null {
// Why: only the constructor may create the default profile. Allowing the
// renderer to pass scope:'default' would create a second profile sharing
// ORCA_BROWSER_PARTITION, causing confusion on delete (clearing storage
// the active default partition, causing confusion on delete (clearing storage
// for the shared partition).
if (scope === 'default') {
return null
@@ -345,7 +381,7 @@ class BrowserSessionRegistry {
// Why: partition names are deterministic from the profile id so main can
// reconstruct the allowlist on restart from persisted profile metadata
// without needing a separate partition→profile mapping.
const partition = `persist:orca-browser-session-${id}`
const partition = getOrcaProfileBrowserSessionPartition(this.activeOrcaProfileId, id)
const profile: BrowserSessionProfile = {
id,
scope,
@@ -391,9 +427,9 @@ class BrowserSessionRegistry {
delete userAgentByPartition[profile.partition]
this.persistMeta({
pendingCookieImports,
pendingCookieDbPath: pendingCookieImports[ORCA_BROWSER_PARTITION] ?? null,
pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null,
userAgentByPartition,
userAgent: userAgentByPartition[ORCA_BROWSER_PARTITION] ?? null
userAgent: userAgentByPartition[this.defaultPartition] ?? null
})
// Why: clearing the partition's storage prevents orphaned cookies/cache from
@@ -423,9 +459,9 @@ class BrowserSessionRegistry {
}
const meta = this.loadPersistedMeta()
const pendingCookieImports = { ...meta.pendingCookieImports }
delete pendingCookieImports[ORCA_BROWSER_PARTITION]
delete pendingCookieImports[this.defaultPartition]
const userAgentByPartition = { ...meta.userAgentByPartition }
delete userAgentByPartition[ORCA_BROWSER_PARTITION]
delete userAgentByPartition[this.defaultPartition]
this.persistMeta({
defaultSource: null,
userAgent: null,
@@ -434,7 +470,7 @@ class BrowserSessionRegistry {
pendingCookieImports
})
const sess = session.fromPartition(ORCA_BROWSER_PARTITION)
const sess = session.fromPartition(this.defaultPartition)
await sess.clearStorageData({ storages: ['cookies'] })
return true
} catch {
@@ -449,9 +485,7 @@ class BrowserSessionRegistry {
// tampered file could inject an arbitrary partition into the allowlist that
// will-attach-webview trusts, so we validate the expected shape before
// registering anything.
private static readonly PARTITION_RE = /^persist:orca-browser-session-[\da-f-]{36}$/
private static isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile {
private isValidPersistedProfile(profile: unknown): profile is BrowserSessionProfile {
if (!profile || typeof profile !== 'object') {
return false
}
@@ -462,17 +496,34 @@ class BrowserSessionRegistry {
typeof candidate.id === 'string' &&
typeof candidate.partition === 'string' &&
typeof candidate.label === 'string' &&
BrowserSessionRegistry.PARTITION_RE.test(candidate.partition)
this.isProfileOwnedSessionPartition(candidate.partition)
)
}
private isProfileOwnedSessionPartition(partition: string): boolean {
if (
this.activeOrcaProfileId === DEFAULT_LOCAL_ORCA_PROFILE_ID &&
LEGACY_BROWSER_SESSION_PARTITION_RE.test(partition)
) {
return true
}
const segment = getOrcaProfileBrowserPartitionSegment(this.activeOrcaProfileId)
const prefix = `persist:orca-profile-${segment}-browser-session-`
if (!partition.startsWith(prefix)) {
return false
}
const profileId = partition.slice(prefix.length)
return /^[\da-f-]{8}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{4}-[\da-f-]{12}$/.test(profileId)
}
hydrateFromPersisted(profiles: BrowserSessionProfile[]): void {
for (const profile of profiles) {
if (!BrowserSessionRegistry.isValidPersistedProfile(profile)) {
if (!this.isValidPersistedProfile(profile)) {
continue
}
this.profiles.set(profile.id, profile)
if (profile.partition !== ORCA_BROWSER_PARTITION) {
if (profile.partition !== this.defaultPartition) {
this.setupSessionPolicies(profile.partition)
}
}
@@ -1,20 +1,24 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
function installRegistryMock(): {
configureForOrcaProfileMock: ReturnType<typeof vi.fn>
applyPendingCookieImportMock: ReturnType<typeof vi.fn>
initializeBrowserSessionsFromPersistedStateMock: ReturnType<typeof vi.fn>
} {
const configureForOrcaProfileMock = vi.fn()
const applyPendingCookieImportMock = vi.fn()
const initializeBrowserSessionsFromPersistedStateMock = vi.fn()
vi.doMock('./browser-session-registry', () => ({
browserSessionRegistry: {
configureForOrcaProfile: configureForOrcaProfileMock,
applyPendingCookieImport: applyPendingCookieImportMock,
initializeBrowserSessionsFromPersistedState: initializeBrowserSessionsFromPersistedStateMock
}
}))
return {
configureForOrcaProfileMock,
applyPendingCookieImportMock,
initializeBrowserSessionsFromPersistedStateMock
}
@@ -40,6 +44,31 @@ describe('initializeBrowserSessionsForApp', () => {
)
})
it('configures the active Orca profile before replaying browser sessions', async () => {
const {
configureForOrcaProfileMock,
applyPendingCookieImportMock,
initializeBrowserSessionsFromPersistedStateMock
} = installRegistryMock()
const { initializeBrowserSessionsForApp } = await import('./browser-session-startup')
initializeBrowserSessionsForApp({
orcaProfileId: 'local-work',
profileDirectory: '/profiles/local-work'
})
expect(configureForOrcaProfileMock).toHaveBeenCalledWith({
orcaProfileId: 'local-work',
profileDirectory: '/profiles/local-work'
})
expect(configureForOrcaProfileMock.mock.invocationCallOrder[0]).toBeLessThan(
applyPendingCookieImportMock.mock.invocationCallOrder[0]
)
expect(applyPendingCookieImportMock.mock.invocationCallOrder[0]).toBeLessThan(
initializeBrowserSessionsFromPersistedStateMock.mock.invocationCallOrder[0]
)
})
it('initializes browser sessions once per app process', async () => {
const { applyPendingCookieImportMock, initializeBrowserSessionsFromPersistedStateMock } =
installRegistryMock()
+8 -1
View File
@@ -1,12 +1,19 @@
import { browserSessionRegistry } from './browser-session-registry'
import type { BrowserSessionRegistryProfileOptions } from './browser-session-registry'
let initialized = false
export function initializeBrowserSessionsForApp(): void {
export function initializeBrowserSessionsForApp(
activeProfile?: BrowserSessionRegistryProfileOptions
): void {
if (initialized) {
return
}
if (activeProfile) {
browserSessionRegistry.configureForOrcaProfile(activeProfile)
}
// Why: cookie replay must happen before the first session.fromPartition()
// call, otherwise Chromium opens the stale live cookie DB before import.
browserSessionRegistry.applyPendingCookieImport()
+72
View File
@@ -0,0 +1,72 @@
import type { HookCommandConfig, HookDefinition } from '../agent-hooks/installer-utils'
import type { CodexEventLabel, CodexTrustEntry } from './config-toml-trust'
// Why: Codex's trust hash key uses the snake_case event label (see
// codex-rs/hooks/src/lib.rs::hook_event_key_label) while hooks.json uses the
// PascalCase serde-rename. Keep the mapping in one shared module so the
// install, status, and write-back promotion paths cannot drift.
export const CODEX_HOOK_EVENT_LABEL: Record<string, CodexEventLabel> = {
SessionStart: 'session_start',
UserPromptSubmit: 'user_prompt_submit',
PreToolUse: 'pre_tool_use',
PermissionRequest: 'permission_request',
PostToolUse: 'post_tool_use',
Stop: 'stop',
PreCompact: 'pre_compact',
PostCompact: 'post_compact'
}
export const CODEX_EVENT_NAME_BY_LABEL: Record<CodexEventLabel, string> = {
session_start: 'SessionStart',
user_prompt_submit: 'UserPromptSubmit',
pre_tool_use: 'PreToolUse',
permission_request: 'PermissionRequest',
post_tool_use: 'PostToolUse',
stop: 'Stop',
pre_compact: 'PreCompact',
post_compact: 'PostCompact'
}
export function getCodexManagedScriptFileName(): string {
return process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh'
}
export function createCodexHookTrustEntry(
sourcePath: string,
eventName: string,
groupIndex: number,
handlerIndex: number,
definition: HookDefinition,
hook: HookCommandConfig
): CodexTrustEntry | null {
const eventLabel = CODEX_HOOK_EVENT_LABEL[eventName]
if (!eventLabel || !hook.command) {
return null
}
return {
sourcePath,
eventLabel,
groupIndex,
handlerIndex,
command: hook.command,
...(typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}),
...(typeof hook.async === 'boolean' ? { async: hook.async } : {}),
...(typeof definition.matcher === 'string' ? { matcher: definition.matcher } : {}),
...(typeof hook.statusMessage === 'string' ? { statusMessage: hook.statusMessage } : {})
}
}
// Why: identity of a hook's content independent of file position, used to
// match the same hook across the system and runtime hooks.json layouts
// (dedupe and the prepended managed hook shift positions between them).
export function getCodexHookTrustSignature(entry: CodexTrustEntry): string {
return JSON.stringify({
eventLabel: entry.eventLabel,
command: entry.command,
timeoutSec: Math.max(1, entry.timeoutSec ?? 600),
async: entry.async ?? false,
matcher: entry.matcher ?? null,
statusMessage: entry.statusMessage ?? null
})
}
+27 -10
View File
@@ -55,6 +55,13 @@ export type CodexTrustEntry = {
matcher?: string
/** Optional statusMessage field. */
statusMessage?: string
/** Verbatim hash to write instead of computing one. Used when carrying a
* Codex-written approval across files, so trust survives even if Codex's
* hash algorithm drifts from computeTrustedHash. Never fed into hashing. */
trustedHash?: string
/** Explicit enabled state to write. When absent, a pre-existing
* `enabled = false` on the target block is preserved. */
enabled?: boolean
}
export type CodexHookTrustState = {
@@ -308,7 +315,8 @@ export function upsertHookTrustEntriesInContent(
updated = upsertTrustBlocks(
updated,
getTrustKeyWriteVariants(computeTrustKey(entry)),
computeTrustedHash(entry)
entry.trustedHash ?? computeTrustedHash(entry),
entry.enabled
)
}
return updated
@@ -420,7 +428,12 @@ export function escapeTomlString(value: string): string {
.replaceAll('\t', '\\t')
}
function upsertTrustBlocks(content: string, keys: readonly string[], hash: string): string {
function upsertTrustBlocks(
content: string,
keys: readonly string[],
hash: string,
explicitEnabled?: boolean
): string {
const ranges = keys
.flatMap((key) => findTrustBlockRanges(content, key))
.filter(
@@ -431,7 +444,7 @@ function upsertTrustBlocks(content: string, keys: readonly string[], hash: strin
)
.sort((a, b) => a.start - b.start)
if (ranges.length === 0) {
const block = buildTrustBlocks(keys, hash, true)
const block = buildTrustBlocks(keys, hash, explicitEnabled ?? true)
if (content.length === 0) {
return `${block}\n`
}
@@ -446,13 +459,17 @@ function upsertTrustBlocks(content: string, keys: readonly string[], hash: strin
// silently re-enabled by the next auto-install on app start.
// If duplicate blocks already exist, treat any disabled copy as authoritative
// while collapsing the malformed TOML back to one table.
const enabled = !ranges.some((range) => {
const existingBlock = content.slice(range.headerLineEnd, range.end)
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(
existingBlock
)
return enabledMatch?.[1] === 'false'
})
// An explicit enabled state (write-back promotion of an in-Orca /hooks
// toggle) overrides that preservation: it IS the user's latest decision.
const enabled =
explicitEnabled ??
!ranges.some((range) => {
const existingBlock = content.slice(range.headerLineEnd, range.end)
const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(
existingBlock
)
return enabledMatch?.[1] === 'false'
})
const block = buildTrustBlocks(keys, hash, enabled)
let cursor = 0
let deduped = ''
+82 -84
View File
@@ -16,7 +16,6 @@ import {
wrapWindowsCmdHookCommand,
writeHooksJson,
writeManagedScript,
type HookCommandConfig,
type HookDefinition
} from '../agent-hooks/installer-utils'
import {
@@ -44,6 +43,16 @@ import {
} from './config-toml-trust'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
import {
CODEX_HOOK_EVENT_LABEL,
createCodexHookTrustEntry,
getCodexHookTrustSignature,
getCodexManagedScriptFileName
} from './codex-hook-identity'
import {
promoteCodexRuntimeHookApprovalsToSystem,
snapshotCodexRuntimeHookTrustProvenance
} from './hook-trust-promotion'
// Why: PreToolUse/PostToolUse give the dashboard a live readout of the
// in-flight tool (name + input preview) between UserPromptSubmit and Stop.
@@ -73,29 +82,21 @@ function getCodexConfigTomlPath(): string {
return join(getOrcaManagedCodexHomePath(), 'config.toml')
}
// Why: Codex's hash key uses the snake_case event label (see
// codex-rs/hooks/src/lib.rs::hook_event_key_label). Our hooks.json uses the
// PascalCase serde-rename. Map between them at one place so the trust-write
// path can't drift from the install path.
// Why: the managed-event subset of the shared PascalCase→label map; the
// full mapping lives in codex-hook-identity.ts so promotion can't drift.
const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> = {
SessionStart: 'session_start',
UserPromptSubmit: 'user_prompt_submit',
PreToolUse: 'pre_tool_use',
PermissionRequest: 'permission_request',
PostToolUse: 'post_tool_use',
Stop: 'stop'
SessionStart: CODEX_HOOK_EVENT_LABEL.SessionStart!,
UserPromptSubmit: CODEX_HOOK_EVENT_LABEL.UserPromptSubmit!,
PreToolUse: CODEX_HOOK_EVENT_LABEL.PreToolUse!,
PermissionRequest: CODEX_HOOK_EVENT_LABEL.PermissionRequest!,
PostToolUse: CODEX_HOOK_EVENT_LABEL.PostToolUse!,
Stop: CODEX_HOOK_EVENT_LABEL.Stop!
}
const CODEX_MANAGED_EVENT_LABELS = new Set<CodexEventLabel>(
CODEX_EVENTS.map((eventName) => CODEX_EVENT_LABEL[eventName])
)
const CODEX_HOOK_EVENT_LABEL: Record<string, CodexEventLabel> = {
...CODEX_EVENT_LABEL,
PreCompact: 'pre_compact',
PostCompact: 'post_compact'
}
const CODEX_PLUGIN_ONLY_HOOK_PLACEHOLDERS = [
'${CLAUDE_PLUGIN_ROOT}',
'${CLAUDE_PLUGIN_DATA}',
@@ -112,12 +113,8 @@ type MirroredRuntimeUserHookTrustEntry = {
enabled: boolean
}
function getManagedScriptFileName(): string {
return process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh'
}
function getManagedScriptPath(): string {
return getSharedManagedScriptPath(getManagedScriptFileName())
return getSharedManagedScriptPath(getCodexManagedScriptFileName())
}
function getManagedCommand(scriptPath: string): string {
@@ -151,7 +148,7 @@ function collectManagedTrustEntries(
if (!isManagedCommand(hook.command)) {
return
}
const entry = createHookTrustEntry(
const entry = createCodexHookTrustEntry(
sourcePath,
eventName,
groupIndex,
@@ -167,32 +164,6 @@ function collectManagedTrustEntries(
return entries
}
function createHookTrustEntry(
sourcePath: string,
eventName: string,
groupIndex: number,
handlerIndex: number,
definition: HookDefinition,
hook: HookCommandConfig
): CodexTrustEntry | null {
const eventLabel = CODEX_HOOK_EVENT_LABEL[eventName]
if (!eventLabel || !hook.command) {
return null
}
return {
sourcePath,
eventLabel,
groupIndex,
handlerIndex,
command: hook.command,
...(typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {}),
...(typeof hook.async === 'boolean' ? { async: hook.async } : {}),
...(typeof definition.matcher === 'string' ? { matcher: definition.matcher } : {}),
...(typeof hook.statusMessage === 'string' ? { statusMessage: hook.statusMessage } : {})
}
}
function removeMatchingTrustEntries(configPath: string, entries: readonly CodexTrustEntry[]): void {
if (entries.length === 0) {
return
@@ -218,7 +189,7 @@ function removeStaleRuntimeHookTrustEntries(
const expectedHashes = new Map(
expectedEntries.map((entry) => [
normalizeHookTrustKeyForLookup(computeTrustKey(entry)),
computeTrustedHash(entry)
entry.trustedHash ?? computeTrustedHash(entry)
])
)
const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath)
@@ -238,17 +209,6 @@ function removeStaleRuntimeHookTrustEntries(
}
}
function getTrustSignature(entry: CodexTrustEntry): string {
return JSON.stringify({
eventLabel: entry.eventLabel,
command: entry.command,
timeoutSec: Math.max(1, entry.timeoutSec ?? 600),
async: entry.async ?? false,
matcher: entry.matcher ?? null,
statusMessage: entry.statusMessage ?? null
})
}
function commandUsesCodexPluginOnlyPlaceholder(command: string | undefined): boolean {
return (
typeof command === 'string' &&
@@ -316,12 +276,17 @@ function getRuntimeHooksWithSystemUserHooks(
}
}
type TrustedSystemHookSignatureState = {
enabled: boolean
trustedHash: string
}
function getTrustedSystemUserHookSignatures(
systemConfigPath: string,
systemHooks: Record<string, HookDefinition[]>,
isManagedCommand: (command: string | undefined) => boolean
): Map<string, boolean> {
const signatures = new Map<string, boolean>()
): Map<string, TrustedSystemHookSignatureState> {
const signatures = new Map<string, TrustedSystemHookSignatureState>()
let trustEntries: Map<string, CodexHookTrustState>
try {
trustEntries = readHookTrustEntries(getSystemCodexConfigTomlPath())
@@ -342,7 +307,7 @@ function getTrustedSystemUserHookSignatures(
if (isManagedCommand(hook.command)) {
return
}
const entry = createHookTrustEntry(
const entry = createCodexHookTrustEntry(
systemConfigPath,
eventName,
groupIndex,
@@ -353,20 +318,15 @@ function getTrustedSystemUserHookSignatures(
if (!entry) {
return
}
const expectedHash = computeTrustedHash(entry)
const state = trustEntries.get(computeTrustKey(entry))
const enabled =
state?.trustedHash === expectedHash
? state.enabled !== false
: trustedHashesByEvent.get(entry.eventLabel)?.get(expectedHash)
if (enabled === undefined) {
const state = resolveTrustedSystemHookState(entry, trustEntries, trustedHashesByEvent)
if (!state) {
return
}
const signature = getTrustSignature(entry)
const signature = getCodexHookTrustSignature(entry)
// Why: runtime deduping collapses identical system hook definitions;
// if any duplicate remains enabled, keep the mirrored hook enabled.
if (enabled || !signatures.has(signature)) {
signatures.set(signature, enabled)
if (state.enabled || !signatures.has(signature)) {
signatures.set(signature, state)
}
})
})
@@ -374,6 +334,31 @@ function getTrustedSystemUserHookSignatures(
return signatures
}
function resolveTrustedSystemHookState(
entry: CodexTrustEntry,
trustEntries: ReadonlyMap<string, CodexHookTrustState>,
trustedHashesByEvent: ReadonlyMap<CodexEventLabel, Map<string, boolean>>
): TrustedSystemHookSignatureState | null {
const expectedHash = computeTrustedHash(entry)
const state = trustEntries.get(computeTrustKey(entry))
if (state?.trustedHash === expectedHash) {
return { enabled: state.enabled !== false, trustedHash: expectedHash }
}
const reorderedEnabled = trustedHashesByEvent.get(entry.eventLabel)?.get(expectedHash)
if (reorderedEnabled !== undefined) {
return { enabled: reorderedEnabled, trustedHash: expectedHash }
}
if (state?.trustedHash) {
// Why: carry a key-matched system hash verbatim instead of dropping it as
// stale. Codex is the authority on its own hash algorithm; recomputing
// here is what turned #7110-style hash drift into an endless re-approval
// loop. If the hash is genuinely stale (edited hook), Codex prompts —
// exactly what a plain ~/.codex session would do.
return { enabled: state.enabled !== false, trustedHash: state.trustedHash }
}
return null
}
function getTrustedSystemHookHashesByEvent(
systemConfigPath: string,
trustEntries: ReadonlyMap<string, CodexHookTrustState>
@@ -406,7 +391,7 @@ function getTrustedSystemHookHashesByEvent(
function collectMirroredRuntimeUserHookTrustEntries(
runtimeConfigPath: string,
runtimeHooks: Record<string, HookDefinition[]>,
trustedSystemHookSignatures: ReadonlyMap<string, boolean>,
trustedSystemHookSignatures: ReadonlyMap<string, TrustedSystemHookSignatureState>,
isManagedCommand: (command: string | undefined) => boolean
): MirroredRuntimeUserHookTrustEntry[] {
if (trustedSystemHookSignatures.size === 0) {
@@ -424,7 +409,7 @@ function collectMirroredRuntimeUserHookTrustEntries(
if (isManagedCommand(hook.command)) {
return
}
const entry = createHookTrustEntry(
const entry = createCodexHookTrustEntry(
runtimeConfigPath,
eventName,
groupIndex,
@@ -435,10 +420,13 @@ function collectMirroredRuntimeUserHookTrustEntries(
if (!entry) {
return
}
const signature = getTrustSignature(entry)
const enabled = trustedSystemHookSignatures.get(signature)
if (enabled !== undefined) {
entries.push({ entry, enabled })
const signature = getCodexHookTrustSignature(entry)
const state = trustedSystemHookSignatures.get(signature)
if (state !== undefined) {
entries.push({
entry: { ...entry, trustedHash: state.trustedHash },
enabled: state.enabled
})
}
})
})
@@ -533,7 +521,7 @@ function cleanupLegacySystemManagedHooks(): void {
return
}
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
const nextHooks = { ...config.hooks }
const trustEntries: CodexTrustEntry[] = []
let removedManagedHook = false
@@ -853,6 +841,11 @@ export class CodexHookService {
install(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
// Why: must run before this install rewrites hooks.json/config.toml —
// approvals the user made inside Orca-launched Codex are keyed to the
// previous launch's runtime layout, and stale-trust cleanup below would
// delete them once the system config stops backing them.
promoteCodexRuntimeHookApprovalsToSystem()
const config = readHooksJson(configPath)
if (!config) {
return {
@@ -868,7 +861,7 @@ export class CodexHookService {
// install sweeps stale entries left by older builds or a different
// Electron userData path (dev vs. prod). Without this, repeated installs
// accumulate duplicate hook entries pointing at defunct scripts.
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
const command = getManagedCommand(scriptPath)
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand)
const nextHooks = hookPlan.hooks
@@ -953,6 +946,7 @@ export class CodexHookService {
detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.`
}
}
snapshotCodexRuntimeHookTrustProvenance()
try {
cleanupLegacySystemManagedHooks()
cleanupLegacyCodexProfileHooks()
@@ -1060,6 +1054,9 @@ export class CodexHookService {
refreshRuntimeUserHooks(): AgentHookInstallStatus {
const configPath = getConfigPath()
// Why: same as install() — capture in-Orca approvals before this refresh
// rewrites the runtime files they are keyed against.
promoteCodexRuntimeHookApprovalsToSystem()
const config = readHooksJson(configPath)
if (!config) {
// Why: disabled launch prep used to call remove(); preserve its legacy
@@ -1074,7 +1071,7 @@ export class CodexHookService {
}
}
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
const hookPlan = getRuntimeHooksWithSystemUserHooks(config.hooks, isManagedCommand)
config.hooks = hookPlan.hooks
writeCodexHooksJson(configPath, hookPlan.hooks)
@@ -1099,6 +1096,7 @@ export class CodexHookService {
detail: `User hooks refreshed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.`
}
}
snapshotCodexRuntimeHookTrustProvenance()
cleanupLegacyManagedHookRepresentations()
return this.getStatus()
@@ -1124,7 +1122,7 @@ export class CodexHookService {
const nextHooks = { ...config.hooks }
// Why: same broad matcher as install(), so remove() also cleans up stale
// entries from older builds even if the current scriptPath has moved.
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (!Array.isArray(definitions)) {
// Why: a malformed hooks.json entry (non-array value for an event name)
+399
View File
@@ -0,0 +1,399 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
import {
computeTrustKey,
computeTrustedHash,
readHookTrustEntries,
upsertHookTrustEntriesInContent,
type CodexTrustEntry
} from './config-toml-trust'
const { getPathMock, homedirMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
homedirMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof Os>()
return {
...actual,
homedir: homedirMock
}
})
import { CodexHookService } from './hook-service'
let tmpHome: string
let userDataDir: string
let previousUserDataPath: string | undefined
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-home-'))
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-user-data-'))
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(tmpHome)
getPathMock.mockImplementation((name: string) => {
if (name === 'userData') {
return userDataDir
}
throw new Error(`unexpected app.getPath(${name})`)
})
})
afterEach(() => {
rmSync(tmpHome, { recursive: true, force: true })
rmSync(userDataDir, { recursive: true, force: true })
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
vi.clearAllMocks()
})
const USER_HOOK_COMMAND = 'echo my-personal-hook'
function systemCodexDir(): string {
return join(tmpHome, '.codex')
}
function runtimeHomeDir(): string {
return join(userDataDir, 'codex-runtime-home', 'home')
}
function writeSystemUserHook(commands: string[] = [USER_HOOK_COMMAND]): void {
mkdirSync(systemCodexDir(), { recursive: true })
writeFileSync(
join(systemCodexDir(), 'hooks.json'),
JSON.stringify({
hooks: {
Stop: commands.map((command) => ({ hooks: [{ type: 'command', command }] }))
}
})
)
}
// Simulates the Codex TUI persisting a /hooks approval: Codex writes a trust
// entry keyed to the file that declares the hook, using its own hash. The
// production hash algorithm is reproduced by computeTrustedHash.
function simulateCodexApproval(entry: CodexTrustEntry, options?: { hash?: string }): void {
const tomlPath = join(runtimeHomeDir(), 'config.toml')
const existing = existsSync(tomlPath) ? readFileSync(tomlPath, 'utf-8') : ''
const approvalEntry = options?.hash ? { ...entry, trustedHash: options.hash } : entry
writeFileSync(tomlPath, upsertHookTrustEntriesInContent(existing, [approvalEntry]))
}
function runtimeUserStopEntry(): CodexTrustEntry {
// install() prepends the managed status hook on Stop, so the mirrored user
// hook lands at groupIndex 1.
return {
sourcePath: join(runtimeHomeDir(), 'hooks.json'),
eventLabel: 'stop',
groupIndex: 1,
handlerIndex: 0,
command: USER_HOOK_COMMAND
}
}
function systemUserStopEntry(groupIndex = 0): CodexTrustEntry {
return {
sourcePath: join(systemCodexDir(), 'hooks.json'),
eventLabel: 'stop',
groupIndex,
handlerIndex: 0,
command: USER_HOOK_COMMAND
}
}
function readSystemToml(): string {
const tomlPath = join(systemCodexDir(), 'config.toml')
return existsSync(tomlPath) ? readFileSync(tomlPath, 'utf-8') : ''
}
describe('codex hook trust write-back promotion', () => {
it('keeps an in-Orca approval of a user hook across launches and promotes it to ~/.codex', () => {
writeSystemUserHook()
const service = new CodexHookService()
service.install()
// The mirrored user hook has no system trust yet, so no runtime trust
// entry exists for it — Codex would show it as pending review.
const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml')
const approvalKey = computeTrustKey(runtimeUserStopEntry())
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)).toBeUndefined()
simulateCodexApproval(runtimeUserStopEntry())
const approvedHash = computeTrustedHash(runtimeUserStopEntry())
service.install()
// Approval survives the relaunch instead of being wiped as stale…
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.trustedHash).toBe(approvedHash)
// …and is promoted into the user's real config keyed to their hooks.json.
const systemState = readHookTrustEntries(join(systemCodexDir(), 'config.toml')).get(
computeTrustKey(systemUserStopEntry())
)
expect(systemState?.trustedHash).toBe(approvedHash)
expect(systemState?.enabled).not.toBe(false)
// Steady state: another launch with no external changes rewrites nothing.
const systemTomlAfterPromotion = readSystemToml()
const runtimeTomlAfterPromotion = readFileSync(runtimeTomlPath, 'utf-8')
service.install()
expect(readSystemToml()).toBe(systemTomlAfterPromotion)
expect(readFileSync(runtimeTomlPath, 'utf-8')).toBe(runtimeTomlAfterPromotion)
})
it('never promotes trust for the Orca-managed status hook into ~/.codex', () => {
writeSystemUserHook()
const service = new CodexHookService()
service.install()
// Simulate Codex rewriting the managed Stop hook's trust entry (as an
// approval after hash drift would).
const runtimeHooksPath = join(runtimeHomeDir(), 'hooks.json')
const managedCommand = (
JSON.parse(readFileSync(runtimeHooksPath, 'utf-8')) as {
hooks: { Stop: { hooks: { command: string }[] }[] }
}
).hooks.Stop[0]!.hooks[0]!.command
simulateCodexApproval(
{
sourcePath: runtimeHooksPath,
eventLabel: 'stop',
groupIndex: 0,
handlerIndex: 0,
command: managedCommand
},
{ hash: 'sha256:codex-corrected-managed-hash' }
)
service.install()
expect(readSystemToml()).not.toContain(managedCommand)
expect(readSystemToml()).not.toContain('codex-corrected-managed-hash')
})
it('does not resurrect trust the user revoked in ~/.codex/config.toml', () => {
writeSystemUserHook()
// Pre-trust the hook in the system config, as a terminal Codex session would.
const systemTomlPath = join(systemCodexDir(), 'config.toml')
writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()]))
const service = new CodexHookService()
service.install()
const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml')
const approvalKey = computeTrustKey(runtimeUserStopEntry())
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)).toBeDefined()
// User revokes in the system config; the runtime copy must not win.
writeFileSync(systemTomlPath, '')
service.install()
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)).toBeUndefined()
expect(readSystemToml()).not.toContain('[hooks.state.')
})
it('promotes an in-Orca disable of a mirrored user hook back to the system config', () => {
writeSystemUserHook()
const systemTomlPath = join(systemCodexDir(), 'config.toml')
writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()]))
const service = new CodexHookService()
service.install()
// User disables the hook via /hooks inside Orca-launched Codex.
const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml')
const runtimeToml = readFileSync(runtimeTomlPath, 'utf-8')
const approvalKey = computeTrustKey(runtimeUserStopEntry())
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.enabled).not.toBe(false)
writeFileSync(
runtimeTomlPath,
upsertHookTrustEntriesInContent(runtimeToml, [{ ...runtimeUserStopEntry(), enabled: false }])
)
service.install()
const systemState = readHookTrustEntries(systemTomlPath).get(
computeTrustKey(systemUserStopEntry())
)
expect(systemState?.enabled).toBe(false)
// The mirrored runtime entry reflects the disable on the next launch too.
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.enabled).toBe(false)
})
it('carries a Codex-written hash verbatim when it differs from the reproduced hash', () => {
// Simulates Codex changing its trust hash algorithm: the approval hash in
// the runtime config no longer matches computeTrustedHash's output.
writeSystemUserHook()
const service = new CodexHookService()
service.install()
const driftedHash = 'sha256:codex-next-gen-hash-orca-cannot-reproduce'
simulateCodexApproval(runtimeUserStopEntry(), { hash: driftedHash })
service.install()
const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml')
const approvalKey = computeTrustKey(runtimeUserStopEntry())
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.trustedHash).toBe(driftedHash)
expect(
readHookTrustEntries(join(systemCodexDir(), 'config.toml')).get(
computeTrustKey(systemUserStopEntry())
)?.trustedHash
).toBe(driftedHash)
// And the launch after that still keeps it.
service.install()
expect(readHookTrustEntries(runtimeTomlPath).get(approvalKey)?.trustedHash).toBe(driftedHash)
})
it('does not touch ~/.codex on the first launch after upgrading (no provenance yet)', () => {
// Simulates an existing install: runtime home fully materialized by a
// build without provenance snapshots, managed hooks only.
const service = new CodexHookService()
service.install()
rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true })
service.install()
expect(readSystemToml()).toBe('')
expect(existsSync(join(systemCodexDir(), 'config.toml'))).toBe(false)
})
it('re-promoting mirrored trust without provenance is a no-op on ~/.codex', () => {
// Existing install with a system-trusted user hook, upgraded to this
// build: the mirrored runtime entry has no provenance, so promotion must
// sit out this launch and leave the system config byte-identical.
writeSystemUserHook()
const systemTomlPath = join(systemCodexDir(), 'config.toml')
writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()]))
const service = new CodexHookService()
service.install()
rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true })
const systemTomlBefore = readSystemToml()
service.install()
expect(readSystemToml()).toBe(systemTomlBefore)
})
it('does not resurrect trust revoked in ~/.codex before the first provenance snapshot', () => {
// Old build mirrored a system-trusted hook into the runtime home; the
// user then revoked it in ~/.codex/config.toml and upgraded to this
// build. The stale runtime mirror must not be mistaken for an approval.
writeSystemUserHook()
const systemTomlPath = join(systemCodexDir(), 'config.toml')
writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()]))
const service = new CodexHookService()
service.install()
rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true })
writeFileSync(systemTomlPath, '')
service.install()
expect(readSystemToml()).not.toContain('[hooks.state.')
expect(
readHookTrustEntries(join(runtimeHomeDir(), 'config.toml')).get(
computeTrustKey(runtimeUserStopEntry())
)
).toBeUndefined()
})
it('does not flip a hook the user disabled in ~/.codex back to enabled after upgrading', () => {
// Old build mirrored the hook enabled=true; the user then set
// enabled = false in ~/.codex/config.toml and upgraded to this build.
writeSystemUserHook()
const systemTomlPath = join(systemCodexDir(), 'config.toml')
writeFileSync(systemTomlPath, upsertHookTrustEntriesInContent('', [systemUserStopEntry()]))
const service = new CodexHookService()
service.install()
rmSync(join(runtimeHomeDir(), '.orca-hook-trust-provenance.json'), { force: true })
writeFileSync(
systemTomlPath,
upsertHookTrustEntriesInContent('', [{ ...systemUserStopEntry(), enabled: false }])
)
service.install()
expect(
readHookTrustEntries(systemTomlPath).get(computeTrustKey(systemUserStopEntry()))?.enabled
).toBe(false)
expect(
readHookTrustEntries(join(runtimeHomeDir(), 'config.toml')).get(
computeTrustKey(runtimeUserStopEntry())
)?.enabled
).toBe(false)
})
it('promotes one approval to every identical system hook collapsed by deduping', () => {
writeSystemUserHook([USER_HOOK_COMMAND, USER_HOOK_COMMAND])
const service = new CodexHookService()
service.install()
simulateCodexApproval(runtimeUserStopEntry())
service.install()
const systemTrust = readHookTrustEntries(join(systemCodexDir(), 'config.toml'))
const approvedHash = computeTrustedHash(runtimeUserStopEntry())
expect(systemTrust.get(computeTrustKey(systemUserStopEntry(0)))?.trustedHash).toBe(approvedHash)
expect(systemTrust.get(computeTrustKey(systemUserStopEntry(1)))?.trustedHash).toBe(approvedHash)
})
it('skips promotion when the approved hook no longer exists in ~/.codex/hooks.json', () => {
writeSystemUserHook()
const service = new CodexHookService()
service.install()
simulateCodexApproval(runtimeUserStopEntry())
// User deletes the hook from their system hooks.json before relaunching.
writeFileSync(join(systemCodexDir(), 'hooks.json'), JSON.stringify({ hooks: {} }))
service.install()
expect(readSystemToml()).not.toContain('[hooks.state.')
// The runtime copy of the deleted hook (and its approval) is cleaned up.
const runtimeTomlPath = join(runtimeHomeDir(), 'config.toml')
expect(
readHookTrustEntries(runtimeTomlPath).get(computeTrustKey(runtimeUserStopEntry()))
).toBeUndefined()
})
it('promotes approvals recorded while status hooks are disabled (refresh path)', () => {
writeSystemUserHook()
const service = new CodexHookService()
service.refreshRuntimeUserHooks()
// Without the managed status hook, the mirrored user hook sits at group 0.
const refreshedRuntimeEntry: CodexTrustEntry = {
...runtimeUserStopEntry(),
groupIndex: 0
}
simulateCodexApproval(refreshedRuntimeEntry)
const approvedHash = computeTrustedHash(refreshedRuntimeEntry)
service.refreshRuntimeUserHooks()
expect(
readHookTrustEntries(join(systemCodexDir(), 'config.toml')).get(
computeTrustKey(systemUserStopEntry())
)?.trustedHash
).toBe(approvedHash)
expect(
readHookTrustEntries(join(runtimeHomeDir(), 'config.toml')).get(
computeTrustKey(refreshedRuntimeEntry)
)?.trustedHash
).toBe(approvedHash)
})
})
+257
View File
@@ -0,0 +1,257 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import {
createManagedCommandMatcher,
readHooksJson,
type HookDefinition
} from '../agent-hooks/installer-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import {
getCodexCanonicalTrustPath,
normalizeHookTrustKeyForLookup,
parseTrustKey,
readHookTrustEntries,
upsertHookTrustEntries,
type CodexTrustEntry
} from './config-toml-trust'
import {
CODEX_EVENT_NAME_BY_LABEL,
createCodexHookTrustEntry,
getCodexHookTrustSignature,
getCodexManagedScriptFileName
} from './codex-hook-identity'
// Why: ~/.codex/config.toml is the single source of truth for user-hook
// trust, but the Codex TUI can only write approvals into the runtime
// CODEX_HOME it was launched with. Without promoting those approvals back,
// removeStaleRuntimeHookTrustEntries deletes them on the next launch and the
// user re-approves the same hooks forever.
type HookTrustProvenanceEntry = {
trustedHash?: string
enabled?: boolean
}
type HookTrustProvenanceFile = {
version: 1
entries: Record<string, HookTrustProvenanceEntry>
}
function getProvenancePath(runtimeHomePath: string): string {
return join(runtimeHomePath, '.orca-hook-trust-provenance.json')
}
function readHookTrustProvenance(
runtimeHomePath: string
): Map<string, HookTrustProvenanceEntry> | null {
const provenancePath = getProvenancePath(runtimeHomePath)
if (!existsSync(provenancePath)) {
return null
}
try {
const parsed: unknown = JSON.parse(readFileSync(provenancePath, 'utf-8'))
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null
}
const entries = (parsed as HookTrustProvenanceFile).entries
if (!entries || typeof entries !== 'object' || Array.isArray(entries)) {
return null
}
const result = new Map<string, HookTrustProvenanceEntry>()
for (const [key, value] of Object.entries(entries)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
result.set(key, {
...(typeof value.trustedHash === 'string' ? { trustedHash: value.trustedHash } : {}),
...(typeof value.enabled === 'boolean' ? { enabled: value.enabled } : {})
})
}
}
return result
} catch {
return null
}
}
/**
* Records the runtime config.toml trust state Orca leaves behind after an
* install/refresh, so the next launch can tell "entry Orca wrote" apart from
* "entry Codex wrote after a user approval". Call after all trust writes.
*/
export function snapshotCodexRuntimeHookTrustProvenance(): void {
try {
const runtimeHomePath = getOrcaManagedCodexHomePath()
const runtimeHooksPath = join(runtimeHomePath, 'hooks.json')
const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath)
const entries: Record<string, HookTrustProvenanceEntry> = {}
for (const [key, state] of readHookTrustEntries(join(runtimeHomePath, 'config.toml'))) {
const parsed = parseTrustKey(key)
if (!parsed || getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalRuntimeHooksPath) {
continue
}
entries[normalizeHookTrustKeyForLookup(key)] = {
...(state.trustedHash !== undefined ? { trustedHash: state.trustedHash } : {}),
...(state.enabled !== undefined ? { enabled: state.enabled } : {})
}
}
const file: HookTrustProvenanceFile = { version: 1, entries }
writeFileSync(getProvenancePath(runtimeHomePath), `${JSON.stringify(file, null, 2)}\n`, {
encoding: 'utf-8',
mode: 0o600
})
} catch (error) {
// Best effort — a missing snapshot only means the next launch re-promotes
// entries that already match the system config, which upsert no-ops.
console.warn('[codex-hook-promotion] failed to snapshot hook trust provenance', error)
}
}
/**
* Promotes hook approvals the user made inside Orca-launched Codex (written
* by Codex into the runtime config.toml) into ~/.codex/config.toml, keyed to
* the user's own hooks.json. Runs before the config mirror so the promoted
* trust is mirrored back on the same launch.
*/
export function promoteCodexRuntimeHookApprovalsToSystem(): void {
try {
promoteCodexRuntimeHookApprovalsToSystemUnsafe()
} catch (error) {
// Why: promotion is best-effort launch prep; a malformed runtime file
// must not block hook install or the Codex launch itself.
console.warn('[codex-hook-promotion] failed to promote runtime hook approvals', error)
}
}
function promoteCodexRuntimeHookApprovalsToSystemUnsafe(): void {
const runtimeHomePath = getOrcaManagedCodexHomePath()
const systemHomePath = getSystemCodexHomePath()
const runtimeHooksPath = join(runtimeHomePath, 'hooks.json')
const systemHooksPath = join(systemHomePath, 'hooks.json')
const canonicalRuntimeHooksPath = getCodexCanonicalTrustPath(runtimeHooksPath)
if (canonicalRuntimeHooksPath === getCodexCanonicalTrustPath(systemHooksPath)) {
return
}
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
if (!existsSync(runtimeTomlPath)) {
return
}
// Why: without a snapshot of what Orca last wrote (first launch after
// upgrading to a build with promotion, or a corrupted snapshot), a mirrored
// copy of since-revoked system trust is indistinguishable from a genuine
// in-Orca approval. Promoting would resurrect trust the user revoked in
// ~/.codex, so skip this launch — install() writes the first snapshot and
// promotion starts on the next one.
const provenance = readHookTrustProvenance(runtimeHomePath)
if (!provenance) {
return
}
const runtimeTrust = readHookTrustEntries(runtimeTomlPath)
if (runtimeTrust.size === 0) {
return
}
// Why: promotion inspects the hooks.json layout Codex actually approved
// against — the one still on disk from the previous launch — so it must run
// before install() rewrites the runtime hooks.json.
const runtimeConfig = readHooksJson(runtimeHooksPath)
const systemConfig = readHooksJson(systemHooksPath)
if (!runtimeConfig?.hooks || !systemConfig?.hooks) {
return
}
const isManagedCommand = createManagedCommandMatcher(getCodexManagedScriptFileName())
const promotions: CodexTrustEntry[] = []
for (const [key, state] of runtimeTrust) {
if (!state.trustedHash) {
continue
}
const parsed = parseTrustKey(key)
if (!parsed || getCodexCanonicalTrustPath(parsed.sourcePath) !== canonicalRuntimeHooksPath) {
continue
}
const previous = provenance.get(normalizeHookTrustKeyForLookup(key))
if (
previous &&
previous.trustedHash === state.trustedHash &&
(previous.enabled ?? true) === (state.enabled ?? true)
) {
// Orca wrote this entry and nothing touched it since — not an approval.
continue
}
const eventName = CODEX_EVENT_NAME_BY_LABEL[parsed.eventLabel]
const runtimeDefinitions = runtimeConfig.hooks[eventName]
const definition = Array.isArray(runtimeDefinitions)
? runtimeDefinitions[parsed.groupIndex]
: undefined
const hook = Array.isArray(definition?.hooks)
? definition.hooks[parsed.handlerIndex]
: undefined
// Why: never write trust for Orca's managed status hook into the user's
// real config — mutating ~/.codex for Orca's own hooks is exactly what the
// runtime CODEX_HOME isolation exists to prevent.
if (!definition || !hook?.command || isManagedCommand(hook.command)) {
continue
}
const runtimeEntry = createCodexHookTrustEntry(
runtimeHooksPath,
eventName,
parsed.groupIndex,
parsed.handlerIndex,
definition,
hook
)
if (!runtimeEntry) {
continue
}
collectSystemPromotionTargets(
promotions,
systemHooksPath,
systemConfig.hooks,
eventName,
getCodexHookTrustSignature(runtimeEntry),
isManagedCommand,
state.trustedHash,
state.enabled ?? true
)
}
if (promotions.length > 0) {
upsertHookTrustEntries(join(systemHomePath, 'config.toml'), promotions)
}
}
// Why: the runtime layout differs from the system one (managed hook
// prepended, duplicates collapsed), so approvals map back by hook content —
// and one runtime hook can map to several identical system entries.
function collectSystemPromotionTargets(
promotions: CodexTrustEntry[],
systemHooksPath: string,
systemHooks: Record<string, HookDefinition[]>,
eventName: string,
signature: string,
isManagedCommand: (command: string | undefined) => boolean,
trustedHash: string,
enabled: boolean
): void {
const systemDefinitions = systemHooks[eventName]
if (!Array.isArray(systemDefinitions)) {
return
}
systemDefinitions.forEach((systemDefinition, groupIndex) => {
const hooks = Array.isArray(systemDefinition.hooks) ? systemDefinition.hooks : []
hooks.forEach((systemHook, handlerIndex) => {
if (isManagedCommand(systemHook.command)) {
return
}
const systemEntry = createCodexHookTrustEntry(
systemHooksPath,
eventName,
groupIndex,
handlerIndex,
systemDefinition,
systemHook
)
if (!systemEntry || getCodexHookTrustSignature(systemEntry) !== signature) {
return
}
promotions.push({ ...systemEntry, trustedHash, enabled })
})
})
}
@@ -133,7 +133,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
// src/renderer/src/components/terminal-pane/layout-serialization.ts (copied
// as a literal because a main-process test must not import a renderer module).
const POST_REPLAY_MODE_RESET =
'\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?2004l'
'\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1004l\x1b[?2004l'
const fresh = new HeadlessEmulator({ cols: 80, rows: 24 })
fresh.writeSync('\x1b[2J\x1b[3J\x1b[H')
fresh.writeSync(adapterScrollback as string)
@@ -13,8 +13,9 @@ import { HeadlessEmulator } from './headless-emulator'
const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q'
const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u'
const RESET_MOUSE_REPORTING = '\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l'
// Verbatim from layout-serialization.ts (the reattach path the remote onSnapshot uses).
const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h\x1b[?1004l`
const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l`
function writeXterm(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, resolve))
@@ -51,12 +52,12 @@ function renderVisible(term: Terminal): string {
}
describe('#7329 remote-server snapshot corruption', () => {
it('leaves bracketed-paste + mouse modes armed after a reattach snapshot', async () => {
it('disarms rehydrated mouse modes but keeps bracketed paste after a reattach snapshot', async () => {
const emu = new HeadlessEmulator({ cols: 80, rows: 24 })
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
try {
// A live remote shell that armed bracketed paste (bash 4.4+/readline
// default) and vt200+SGR mouse (a TUI the user just exited).
// default) and vt200+SGR mouse (a TUI the user just exited uncleanly).
await emulatorWrite(emu, '\x1b[?2004h\x1b[?1000h\x1b[?1006h')
await emulatorWrite(emu, 'user@host:~$ ')
@@ -68,11 +69,35 @@ describe('#7329 remote-server snapshot corruption', () => {
await replayRemoteSnapshot(term, snapshot)
// After the remote reattach reset, the renderer xterm is STILL in
// bracketed-paste + mouse mode — even though POST_REPLAY_REATTACH_RESET
// ran. Cold restore's POST_REPLAY_MODE_RESET would have cleared these.
expect(term.modes.bracketedPasteMode).toBe(true) // leak
expect(term.modes.mouseTrackingMode).not.toBe('none') // leak
// Bracketed paste survives — the live shell armed it and won't re-arm
// until the next prompt. Mouse reporting must NOT survive: with a plain
// shell in the foreground, xterm would emit `35;x;yM` motion reports the
// shell echoes as literal input on every pointer move. (Live agent panes
// preserve mouse via POST_REPLAY_LIVE_AGENT_REATTACH_RESET instead.)
expect(term.modes.bracketedPasteMode).toBe(true)
expect(term.modes.mouseTrackingMode).toBe('none')
} finally {
emu.dispose()
term.dispose()
}
})
it('disarms any-motion (?1003) and pixel-encoded (?1016) mouse modes left by a killed TUI', async () => {
const emu = new HeadlessEmulator({ cols: 80, rows: 24 })
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
try {
// An agent TUI armed any-motion tracking, then was SIGKILLed — no
// disarm bytes ever reach the daemon, so its tracker keeps the mode.
await emulatorWrite(emu, '\x1b[?1003h\x1b[?1016h')
await emulatorWrite(emu, 'user@host:~$ ')
const snapshot = emu.getSnapshot({ scrollbackRows: 0 })
expect(snapshot.rehydrateSequences).toContain('\x1b[?1003h')
expect(snapshot.rehydrateSequences).toContain('\x1b[?1016h')
await replayRemoteSnapshot(term, snapshot)
expect(term.modes.mouseTrackingMode).toBe('none')
} finally {
emu.dispose()
term.dispose()
+29
View File
@@ -270,6 +270,35 @@ describe('git remote operations', () => {
await expect(gitPush('/repo', false)).rejects.toThrow('fatal: something obscure happened')
})
it('preserves redacted pre-push hook output from failed pushes', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
.mockRejectedValueOnce(
new Error(
[
'Command failed: git push https://x-access-token:ghp_secret@github.com/acme/repo.git HEAD',
'husky - pre-push hook failed',
'eslint found 2 errors',
"error: failed to push some refs to 'https://ghp_tailSecret@github.com/acme/repo.git'"
].join('\n')
)
)
let caught: Error | undefined
try {
await gitPush('/repo', false)
} catch (error) {
caught = error as Error
}
expect(caught).toBeInstanceOf(Error)
expect(caught?.message).toContain('husky - pre-push hook failed')
expect(caught?.message).toContain('eslint found 2 errors')
expect(caught?.message).not.toContain('x-access-token')
expect(caught?.message).not.toContain('ghp_secret')
expect(caught?.message).not.toContain('ghp_tailSecret')
})
it('strips embedded credentials from push error messages', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('no branch'))
+13 -3
View File
@@ -14,6 +14,7 @@ import {
getCanonicalUserDataPath,
migrateMobilePairingDataToCanonicalUserDataPath
} from './persistence'
import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store'
import { applyAppIcon } from './app-icon'
import { StatsCollector, initStatsPath } from './stats/collector'
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
@@ -110,7 +111,7 @@ import {
ensureAutoUpdaterConfigured
} from './window/attach-main-window-services'
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
import { createSystemTray, destroySystemTray } from './tray/system-tray'
import { createSystemTray, destroySystemTray, setTrayAttention } from './tray/system-tray'
import { focusExistingMainWindow } from './window/focus-existing-window'
import { notifyMainWindowBecameVisible } from './window/main-window-visibility'
import { CodexAccountService } from './codex-accounts/service'
@@ -584,6 +585,7 @@ if (hasSingleInstanceLock) {
// orca-dev in dev mode) but before app.setName('Orca') inside whenReady
// (which would change the resolved path on case-sensitive filesystems).
initDataPath()
initOrcaProfilePaths()
// Why: same timing constraint as initDataPath — capture the userData path
// before app.setName changes it. See persistence.ts:20-28.
initStatsPath()
@@ -1004,6 +1006,10 @@ function openMainWindow(): BrowserWindow {
// macOS dock re-activation recreates the BrowserWindow.
window.on('show', notifyMainWindowBecameVisible)
window.on('restore', notifyMainWindowBecameVisible)
// Why: showing/restoring the window means the user is back, so clear the
// tray attention dot set while it was minimized/hidden (see notifications.ts).
window.on('show', () => setTrayAttention(false))
window.on('restore', () => setTrayAttention(false))
agentHookServer.setListener(
({
paneKey,
@@ -1654,7 +1660,8 @@ app.whenReady().then(async () => {
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
app.setName(devInstanceIdentity.name)
store = new Store()
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
logStartupMilestone('store-loaded')
// Why: must run before ClaudeRuntimeAuthService's constructor sync — a Claude
// CLI that survived the restart inside the daemon still holds the current
@@ -1682,7 +1689,10 @@ app.whenReady().then(async () => {
}
// Why: browser sessions are used by desktop webviews and runtime profile
// commands, so initialize them at app startup instead of a renderer IPC path.
initializeBrowserSessionsForApp()
initializeBrowserSessionsForApp({
orcaProfileId: activeOrcaProfile.profile.id,
profileDirectory: activeOrcaProfile.profileDirectory
})
unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast()
agentAwakeService = new AgentAwakeService()
agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun)
+2
View File
@@ -237,6 +237,8 @@ function session(
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume ${sessionId}`
}
}
@@ -0,0 +1,91 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
export type NotificationAuthorizationStatus = 'authorized' | 'denied' | 'not-determined' | 'unknown'
const HELPER_EXECUTABLE = 'orca-notification-status'
const HELPER_TIMEOUT_MS = 4000
let cachedHelperPath: string | null | undefined
/**
* Resolves the bundled notification-status helper binary.
*
* Why: the helper must live in Contents/MacOS next to the Electron executable
* — NSBundle resolves the process's bundle by walking up from the executable
* path, macOS keys notification records to that identity, and macOS 26 aborts
* UNUserNotificationCenter for executables run from Contents/Resources
* (#7929). Dev copies and packaged builds (extraFiles) both place it there.
*/
function resolveHelperPath(): string | null {
if (cachedHelperPath !== undefined) {
return cachedHelperPath
}
if (process.platform !== 'darwin') {
cachedHelperPath = null
return cachedHelperPath
}
const candidate = join(dirname(process.execPath), HELPER_EXECUTABLE)
cachedHelperPath = existsSync(candidate) ? candidate : null
return cachedHelperPath
}
/**
* Reads the app's real macOS notification authorization via a helper binary
* calling UNUserNotificationCenter.getNotificationSettings. Returns null when
* the helper is unavailable or fails, so callers can fall back to weaker
* delivery-probe evidence.
*
* Why a helper at all: Electron exposes no API for notification authorization
* (scheduling silently succeeds even while macOS is suppressing display), so
* the only truthful signal is the native settings read.
*/
let readInFlight: Promise<NotificationAuthorizationStatus | null> | null = null
export function readNotificationAuthorizationStatus(): Promise<NotificationAuthorizationStatus | null> {
const helperPath = resolveHelperPath()
if (!helperPath) {
return Promise.resolve(null)
}
// Why: simultaneous agent completions across worktrees each consult the
// readout — one in-flight helper run answers all of them.
if (readInFlight) {
return readInFlight
}
readInFlight = runStatusHelper(helperPath).finally(() => {
readInFlight = null
})
return readInFlight
}
function runStatusHelper(helperPath: string): Promise<NotificationAuthorizationStatus | null> {
return new Promise((resolve) => {
execFile(helperPath, [], { timeout: HELPER_TIMEOUT_MS }, (error, stdout) => {
if (error) {
resolve(null)
return
}
try {
const parsed = JSON.parse(String(stdout).trim()) as { authorization?: string }
switch (parsed.authorization) {
case 'authorized':
case 'provisional':
case 'ephemeral':
resolve('authorized')
return
case 'denied':
resolve('denied')
return
case 'not-determined':
resolve('not-determined')
return
default:
resolve('unknown')
}
} catch {
resolve(null)
}
})
})
}
+392 -79
View File
@@ -70,6 +70,25 @@ vi.mock('electron', () => ({
}
}))
const { readAuthorizationStatusMock } = vi.hoisted(() => ({
readAuthorizationStatusMock: vi.fn(
(): Promise<'authorized' | 'denied' | 'not-determined' | 'unknown' | null> =>
Promise.resolve(null)
)
}))
vi.mock('./notification-authorization-status', () => ({
readNotificationAuthorizationStatus: readAuthorizationStatusMock
}))
// Why: notifications.ts pulls in the tray module (for the minimized attention
// dot), which transitively loads app-icon/electron-toolkit; stub it so this
// suite stays focused on notification dispatch and avoids that import chain.
const setTrayAttentionMock = vi.hoisted(() => vi.fn())
vi.mock('../tray/system-tray', () => ({
setTrayAttention: setTrayAttentionMock
}))
import {
registerNotificationHandlers,
triggerStartupNotificationRegistration
@@ -98,9 +117,12 @@ describe('registerNotificationHandlers', () => {
notificationRemoveListenerMock.mockClear()
notificationIsSupportedMock.mockReset()
notificationIsSupportedMock.mockReturnValue(true)
readAuthorizationStatusMock.mockReset()
readAuthorizationStatusMock.mockResolvedValue(null)
getAllWindowsMock.mockReset()
getAllWindowsMock.mockReturnValue([])
shellOpenExternalMock.mockClear()
setTrayAttentionMock.mockClear()
})
afterEach(() => {
@@ -167,7 +189,7 @@ describe('registerNotificationHandlers', () => {
return call[1] as () => void
}
it('registers the IPC handler', () => {
it('registers the IPC handler', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -183,7 +205,7 @@ describe('registerNotificationHandlers', () => {
expect(handleMock).toHaveBeenCalledWith('notifications:dispatch', expect.any(Function))
})
it('opens the current macOS app notification settings entry', () => {
it('opens the current macOS app notification settings entry', async () => {
const originalPlatform = process.platform
const originalBundleId = process.env.ORCA_DEV_MACOS_BUNDLE_ID
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
@@ -216,7 +238,7 @@ describe('registerNotificationHandlers', () => {
}
})
it('opens Windows notification settings', () => {
it('opens Windows notification settings', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
try {
@@ -240,7 +262,7 @@ describe('registerNotificationHandlers', () => {
}
})
it('suppresses notifications when disabled in settings', () => {
it('suppresses notifications when disabled in settings', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -253,14 +275,14 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({
delivered: false,
reason: 'disabled'
})
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('suppresses active-worktree notifications while Orca is focused', () => {
it('suppresses active-worktree notifications while Orca is focused', async () => {
getAllWindowsMock.mockReturnValue([
{
isDestroyed: () => false,
@@ -280,14 +302,73 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete', isActiveWorktree: true })).toEqual({
expect(await handler({}, { source: 'agent-task-complete', isActiveWorktree: true })).toEqual({
delivered: false,
reason: 'suppressed-focus'
})
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('delivers a notification when the event is allowed', () => {
describe('minimized tray attention dot', () => {
function registerEnabledNotifications(): void {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: true
}
})
} as never)
}
it('lights the tray dot for an agent completion while the window is hidden', () => {
getAllWindowsMock.mockReturnValue([
{ isDestroyed: () => false, isVisible: () => false, isMinimized: () => false } as never
])
registerEnabledNotifications()
getDispatchHandler()({}, { source: 'agent-task-complete' })
expect(setTrayAttentionMock).toHaveBeenCalledWith(true)
})
it('lights the tray dot for a terminal bell while the window is minimized', () => {
getAllWindowsMock.mockReturnValue([
{ isDestroyed: () => false, isVisible: () => true, isMinimized: () => true } as never
])
registerEnabledNotifications()
getDispatchHandler()({}, { source: 'terminal-bell' })
expect(setTrayAttentionMock).toHaveBeenCalledWith(true)
})
it('does not light the tray dot while the window is visible', () => {
getAllWindowsMock.mockReturnValue([
{ isDestroyed: () => false, isVisible: () => true, isMinimized: () => false } as never
])
registerEnabledNotifications()
getDispatchHandler()({}, { source: 'agent-task-complete' })
expect(setTrayAttentionMock).not.toHaveBeenCalled()
})
it('does not light the tray dot for non-bell/completion sources', () => {
getAllWindowsMock.mockReturnValue([
{ isDestroyed: () => false, isVisible: () => false, isMinimized: () => false } as never
])
registerEnabledNotifications()
getDispatchHandler()({}, { source: 'test' })
expect(setTrayAttentionMock).not.toHaveBeenCalled()
})
})
it('delivers a notification when the event is allowed', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -301,7 +382,10 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler({}, { source: 'agent-task-complete', repoLabel: 'orca', worktreeLabel: 'feat/notis' })
await handler(
{},
{ source: 'agent-task-complete', repoLabel: 'orca', worktreeLabel: 'feat/notis' }
)
).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith(
expectedNativeNotificationOptions({
@@ -312,7 +396,7 @@ describe('registerNotificationHandlers', () => {
expect(notificationShowMock).toHaveBeenCalledTimes(1)
})
it('uses the macOS default notification sound when no custom sound is configured', () => {
it('uses the macOS default notification sound when no custom sound is configured', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
try {
@@ -329,7 +413,7 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith({
title: 'Orca notifications are on',
body: 'This is a test notification from Orca.',
@@ -340,7 +424,7 @@ describe('registerNotificationHandlers', () => {
}
})
it('does not request a native macOS sound when a custom sound is configured', () => {
it('does not request a native macOS sound when a custom sound is configured', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
try {
@@ -357,7 +441,7 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith({
title: 'Orca notifications are on',
body: 'This is a test notification from Orca.',
@@ -368,7 +452,7 @@ describe('registerNotificationHandlers', () => {
}
})
it('focuses the originating terminal pane when a notification with paneKey is clicked', () => {
it('focuses the originating terminal pane when a notification with paneKey is clicked', async () => {
const webContentsSend = vi.fn()
const restore = vi.fn()
const focus = vi.fn()
@@ -396,7 +480,7 @@ describe('registerNotificationHandlers', () => {
const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111'
const handler = getDispatchHandler()
expect(
handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1', paneKey })
await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1', paneKey })
).toEqual({ delivered: true })
expect(vi.getTimerCount()).toBe(1)
@@ -420,7 +504,7 @@ describe('registerNotificationHandlers', () => {
})
})
it('clears the retained notification fallback timer when the native notification closes', () => {
it('clears the retained notification fallback timer when the native notification closes', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -433,7 +517,7 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true })
expect(vi.getTimerCount()).toBe(1)
const closeHandler = getNotificationEventHandler('close')
@@ -443,7 +527,7 @@ describe('registerNotificationHandlers', () => {
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler)
})
it('releases retained notifications when native delivery fails', () => {
it('releases retained notifications when native delivery fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
registerNotificationHandlers({
@@ -458,7 +542,7 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({ delivered: true })
expect(vi.getTimerCount()).toBe(1)
const failedHandler = getNotificationEventHandler('failed')
@@ -474,7 +558,7 @@ describe('registerNotificationHandlers', () => {
}
})
it('formats agent-task-complete with the agent response when a status snapshot is present', () => {
it('formats agent-task-complete with the agent response when a status snapshot is present', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -488,7 +572,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@@ -512,7 +596,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('includes the repo name when multiple repos are active', () => {
it('includes the repo name when multiple repos are active', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -526,7 +610,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@@ -549,7 +633,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('keeps a readable body when no assistant response was captured', () => {
it('keeps a readable body when no assistant response was captured', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -563,7 +647,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@@ -586,7 +670,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('formats blocked and interrupted agent snapshots distinctly', () => {
it('formats blocked and interrupted agent snapshots distinctly', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -600,7 +684,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@@ -614,7 +698,7 @@ describe('registerNotificationHandlers', () => {
).toEqual({ delivered: true })
vi.advanceTimersByTime(5001)
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@@ -644,7 +728,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('normalizes custom agent labels and re-bounds multiline assistant previews', () => {
it('normalizes custom agent labels and re-bounds multiline assistant previews', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -659,7 +743,7 @@ describe('registerNotificationHandlers', () => {
const longAssistantMessage = `Line one\n\n${'x'.repeat(400)}`
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@@ -686,7 +770,7 @@ describe('registerNotificationHandlers', () => {
expect(options.body.length).toBeLessThanOrEqual(180)
})
it('uses tool context before falling back when no prompt or assistant preview exists', () => {
it('uses tool context before falling back when no prompt or assistant preview exists', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -700,7 +784,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@@ -722,7 +806,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('uses rich formatter output for mobile notifications before the native support guard', () => {
it('uses rich formatter output for mobile notifications before the native support guard', async () => {
notificationIsSupportedMock.mockReturnValue(false)
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
@@ -741,7 +825,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{
source: 'agent-task-complete',
@@ -765,7 +849,7 @@ describe('registerNotificationHandlers', () => {
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications when notifications are disabled', () => {
it('does not dispatch mobile notifications when notifications are disabled', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
@@ -782,7 +866,7 @@ describe('registerNotificationHandlers', () => {
)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'disabled'
})
@@ -790,7 +874,7 @@ describe('registerNotificationHandlers', () => {
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications when the source is disabled', () => {
it('does not dispatch mobile notifications when the source is disabled', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
@@ -807,7 +891,7 @@ describe('registerNotificationHandlers', () => {
)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'source-disabled'
})
@@ -815,7 +899,7 @@ describe('registerNotificationHandlers', () => {
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications for focused active-worktree notifications', () => {
it('does not dispatch mobile notifications for focused active-worktree notifications', async () => {
getAllWindowsMock.mockReturnValue([
{
isDestroyed: () => false,
@@ -839,7 +923,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(
handler(
await handler(
{},
{ source: 'agent-task-complete', worktreeId: 'repo::wt1', isActiveWorktree: true }
)
@@ -851,7 +935,7 @@ describe('registerNotificationHandlers', () => {
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('does not dispatch mobile notifications for cooldown-suppressed bursts', () => {
it('does not dispatch mobile notifications for cooldown-suppressed bursts', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
@@ -868,10 +952,10 @@ describe('registerNotificationHandlers', () => {
)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'cooldown'
})
@@ -882,7 +966,7 @@ describe('registerNotificationHandlers', () => {
)
})
it('does not forward explicit desktop test notifications to mobile clients', () => {
it('does not forward explicit desktop test notifications to mobile clients', async () => {
const dispatchMobileNotification = vi.fn()
registerNotificationHandlers(
{
@@ -899,12 +983,12 @@ describe('registerNotificationHandlers', () => {
)
const handler = getDispatchHandler()
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(dispatchMobileNotification).not.toHaveBeenCalled()
})
it('dismisses active native notifications and fans out mobile dismissal once per id', () => {
it('dismisses active native notifications and fans out mobile dismissal once per id', async () => {
const dispatchMobileNotification = vi.fn()
const dismissMobileNotification = vi.fn()
registerNotificationHandlers(
@@ -923,7 +1007,7 @@ describe('registerNotificationHandlers', () => {
const dispatchHandler = getDispatchHandler()
expect(
dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:one' })
await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:one' })
).toEqual({ delivered: true })
const dismissHandler = getDismissHandler()
@@ -935,7 +1019,7 @@ describe('registerNotificationHandlers', () => {
expect(dismissMobileNotification).toHaveBeenCalledWith('agent:one')
})
it('fans out mobile dismissal even when there is no active native notification', () => {
it('fans out mobile dismissal even when there is no active native notification', async () => {
const dismissMobileNotification = vi.fn()
registerNotificationHandlers(
{
@@ -958,7 +1042,7 @@ describe('registerNotificationHandlers', () => {
expect(dismissMobileNotification).toHaveBeenCalledWith('agent:missing')
})
it('closes the previous native notification when replacing the same id', () => {
it('closes the previous native notification when replacing the same id', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -972,18 +1056,18 @@ describe('registerNotificationHandlers', () => {
const dispatchHandler = getDispatchHandler()
expect(
dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' })
await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' })
).toEqual({ delivered: true })
vi.advanceTimersByTime(5001)
expect(
dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' })
await dispatchHandler({}, { source: 'agent-task-complete', notificationId: 'agent:replace' })
).toEqual({ delivered: true })
expect(notificationCloseMock).toHaveBeenCalledTimes(1)
expect(notificationShowMock).toHaveBeenCalledTimes(2)
})
it('silences the native notification when a custom sound is configured', () => {
it('silences the native notification when a custom sound is configured', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -997,7 +1081,7 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationCtorMock).toHaveBeenCalledWith({
title: 'Orca notifications are on',
body: 'This is a test notification from Orca.',
@@ -1005,7 +1089,7 @@ describe('registerNotificationHandlers', () => {
})
})
it('returns source-disabled when the specific source toggle is off', () => {
it('returns source-disabled when the specific source toggle is off', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -1018,13 +1102,13 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({
delivered: false,
reason: 'source-disabled'
})
})
it('deduplicates repeated notifications for the same worktree', () => {
it('deduplicates repeated notifications for the same worktree', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -1037,23 +1121,23 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getDispatchHandler()
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'cooldown'
})
vi.advanceTimersByTime(5001)
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(notificationShowMock).toHaveBeenCalledTimes(2)
})
it('bounds notification cooldown keys during unique worktree bursts', () => {
it('bounds notification cooldown keys during unique worktree bursts', async () => {
notificationIsSupportedMock.mockReturnValue(false)
registerNotificationHandlers({
getSettings: () => ({
@@ -1068,24 +1152,24 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
for (let i = 0; i < 75; i++) {
expect(handler({}, { source: 'terminal-bell', worktreeId: `repo::wt-${i}` })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: `repo::wt-${i}` })).toEqual({
delivered: false,
reason: 'not-supported'
})
}
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-0' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-0' })).toEqual({
delivered: false,
reason: 'not-supported'
})
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-74' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt-74' })).toEqual({
delivered: false,
reason: 'cooldown'
})
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('deduplicates agent-task-complete and terminal-bell for the same worktree', () => {
it('deduplicates agent-task-complete and terminal-bell for the same worktree', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -1099,17 +1183,46 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'agent-task-complete', worktreeId: 'repo::wt1' })).toEqual({
delivered: true
})
expect(handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
expect(await handler({}, { source: 'terminal-bell', worktreeId: 'repo::wt1' })).toEqual({
delivered: false,
reason: 'cooldown'
})
expect(notificationShowMock).toHaveBeenCalledTimes(1)
})
it('does not cooldown explicit test notifications', () => {
it('skips native delivery and reports blocked-by-system when macOS would swallow it', async () => {
const originalPlatform = process.platform
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
try {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
})
} as never)
readAuthorizationStatusMock.mockResolvedValue('denied')
const handler = getDispatchHandler()
expect(await handler({}, { source: 'agent-task-complete' })).toEqual({
delivered: false,
reason: 'blocked-by-system'
})
// Why: a swallowed native notification would still pile up in the
// Notification Center delivered list — skip creating it entirely.
expect(notificationCtorMock).not.toHaveBeenCalled()
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
}
})
it('does not cooldown explicit test notifications', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -1123,8 +1236,8 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(await handler({}, { source: 'test' })).toEqual({ delivered: true })
expect(notificationShowMock).toHaveBeenCalledTimes(2)
})
@@ -1143,6 +1256,9 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
const result = handler({}, { source: 'test', requireDisplayConfirmation: true })
// Why: the darwin authorization gate resolves before the notification is
// created, so flush microtasks before grabbing its event listeners.
await vi.advanceTimersByTimeAsync(0)
const showHandler = getNotificationOnceEventHandler('show')
const failedHandler = getNotificationOnceEventHandler('failed')
showHandler()
@@ -1168,6 +1284,7 @@ describe('registerNotificationHandlers', () => {
const handler = getDispatchHandler()
const result = handler({}, { source: 'test', requireDisplayConfirmation: true })
await vi.advanceTimersByTimeAsync(0)
const showHandler = getNotificationOnceEventHandler('show')
const failedHandler = getNotificationOnceEventHandler('failed')
await vi.advanceTimersByTimeAsync(2501)
@@ -1217,13 +1334,13 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getLoadSoundHandler()
await expect(handler({})).resolves.toEqual({
expect(await handler({})).toEqual({
ok: false,
reason: 'unsupported-type'
})
})
it('resolves the sound path without reading the file', () => {
it('resolves the sound path without reading the file', async () => {
const soundPath = join(tempDir, 'sound.ogg')
writeFileSync(soundPath, Buffer.from([1, 2, 3]))
registerNotificationHandlers({
@@ -1239,10 +1356,10 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getResolveSoundPathHandler()
expect(handler({})).toEqual({ ok: true, path: soundPath })
expect(await handler({})).toEqual({ ok: true, path: soundPath })
})
it('rejects unsupported types from resolveSoundPath without touching the disk', () => {
it('rejects unsupported types from resolveSoundPath without touching the disk', async () => {
registerNotificationHandlers({
getSettings: () => ({
notifications: {
@@ -1256,7 +1373,203 @@ describe('registerNotificationHandlers', () => {
} as never)
const handler = getResolveSoundPathHandler()
expect(handler({})).toEqual({ ok: false, reason: 'unsupported-type' })
expect(await handler({})).toEqual({ ok: false, reason: 'unsupported-type' })
})
})
describe('notifications:probeDelivery', () => {
const originalPlatform = process.platform
function getProbeDeliveryHandler(): (event: unknown, args?: { force?: boolean }) => unknown {
const call = handleMock.mock.calls.find(
(c: unknown[]) => c[0] === 'notifications:probeDelivery'
)
if (!call) {
throw new Error('notifications:probeDelivery handler not registered')
}
return call[1] as (event: unknown, args?: { force?: boolean }) => unknown
}
function getProbeOnceEventHandler(eventName: string): (...args: unknown[]) => void {
// Why: findLast — a test may run several probes, and only the newest
// probe's listeners can settle the pending promise.
const call = notificationOnceMock.mock.calls.findLast((c: unknown[]) => c[0] === eventName)
if (!call) {
throw new Error(`Probe notification ${eventName} once handler not registered`)
}
return call[1] as (...args: unknown[]) => void
}
function createStore(ui: Record<string, unknown> = {}): {
getSettings: () => unknown
getUI: () => Record<string, unknown>
updateUI: ReturnType<typeof vi.fn>
} {
const state = { ...ui }
return {
getSettings: () => ({
notifications: {
enabled: true,
agentTaskComplete: true,
terminalBell: true,
suppressWhenFocused: false
}
}),
getUI: () => state,
updateUI: vi.fn((updates: Record<string, unknown>) => {
Object.assign(state, updates)
})
}
}
beforeEach(() => {
vi.useFakeTimers()
handleMock.mockReset()
removeHandlerMock.mockReset()
notificationCtorMock.mockClear()
notificationShowMock.mockClear()
notificationCloseMock.mockClear()
notificationOnMock.mockClear()
notificationOnceMock.mockClear()
notificationRemoveListenerMock.mockClear()
notificationIsSupportedMock.mockReset()
notificationIsSupportedMock.mockReturnValue(true)
readAuthorizationStatusMock.mockReset()
readAuthorizationStatusMock.mockResolvedValue(null)
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
it('reports unsupported on non-darwin platforms without probing', async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
const store = createStore()
registerNotificationHandlers(store as never)
await expect(getProbeDeliveryHandler()({})).resolves.toEqual({
state: 'unsupported',
authoritative: false
})
expect(notificationCtorMock).not.toHaveBeenCalled()
expect(store.updateUI).not.toHaveBeenCalled()
})
it('reports authoritative states straight from the authorization readout', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
readAuthorizationStatusMock.mockResolvedValue('authorized')
expect(await handler({})).toEqual({ state: 'delivered', authoritative: true })
readAuthorizationStatusMock.mockResolvedValue('denied')
expect(await handler({})).toEqual({ state: 'blocked', authoritative: true })
// No probe notifications were needed for either readout.
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('fires one dialog-trigger probe per session while the decision is pending', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
readAuthorizationStatusMock.mockResolvedValue('not-determined')
expect(await handler({})).toEqual({
state: 'awaiting-decision',
authoritative: true
})
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
// Polling again while pending must not spam more probe notifications.
expect(await handler({}, { force: true })).toEqual({
state: 'awaiting-decision',
authoritative: true
})
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
})
it('marks the one-shot permission registration as done so startup cannot re-prompt', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const result = getProbeDeliveryHandler()({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(store.updateUI).toHaveBeenCalledWith({ notificationPermissionRequested: true })
getProbeOnceEventHandler('failed')({}, 'not allowed')
await expect(result).resolves.toEqual({ state: 'blocked', authoritative: false })
})
it('falls back to delivery probes when the readout is unavailable', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const result = getProbeDeliveryHandler()({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(notificationShowMock).toHaveBeenCalledTimes(1)
getProbeOnceEventHandler('show')()
await expect(result).resolves.toEqual({ state: 'delivered', authoritative: false })
// No persisted confirmation on purpose: OS permission changes between runs.
expect(store.updateUI).not.toHaveBeenCalledWith({ notificationDeliveryConfirmed: true })
})
it('serves session evidence without probing again until forced', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
const probeResult = handler({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
getProbeOnceEventHandler('show')()
await expect(probeResult).resolves.toEqual({ state: 'delivered', authoritative: false })
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
// Cached session evidence answers non-force calls with no new probe.
expect(await handler({})).toEqual({ state: 'delivered', authoritative: false })
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
// Force bypasses the cache and schedules a fresh probe.
const forced = handler({}, { force: true }) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(notificationCtorMock).toHaveBeenCalledTimes(2)
getProbeOnceEventHandler('show')()
await expect(forced).resolves.toEqual({ state: 'delivered', authoritative: false })
})
it('serves cached failure evidence after a rejected probe', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
const probeResult = handler({}, { force: true }) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
getProbeOnceEventHandler('failed')({}, 'Notifications are not allowed for this application')
await expect(probeResult).resolves.toEqual({ state: 'blocked', authoritative: false })
expect(await handler({})).toEqual({ state: 'blocked', authoritative: false })
expect(notificationCtorMock).toHaveBeenCalledTimes(1)
})
it('resolves blocked on timeout without recording a definitive failure', async () => {
const store = createStore()
registerNotificationHandlers(store as never)
const handler = getProbeDeliveryHandler()
const probeResult = handler({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(3001)
await expect(probeResult).resolves.toEqual({ state: 'blocked', authoritative: false })
expect(notificationCloseMock).toHaveBeenCalledTimes(1)
// A timeout is ambiguous evidence, so the next non-force call probes again.
const secondResult = handler({}) as Promise<unknown>
await vi.advanceTimersByTimeAsync(0)
expect(notificationCtorMock).toHaveBeenCalledTimes(2)
getProbeOnceEventHandler('show')()
await expect(secondResult).resolves.toEqual({ state: 'delivered', authoritative: false })
})
})
@@ -1288,7 +1601,7 @@ describe('triggerStartupNotificationRegistration', () => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
it('shows welcome notification when not yet requested', () => {
it('shows welcome notification when not yet requested', async () => {
const store = {
getUI: () => ({ notificationPermissionRequested: undefined }),
updateUI: vi.fn()
@@ -1304,7 +1617,7 @@ describe('triggerStartupNotificationRegistration', () => {
expect(notificationShowMock).toHaveBeenCalledTimes(1)
})
it('does not fire when notificationPermissionRequested flag is set', () => {
it('does not fire when notificationPermissionRequested flag is set', async () => {
const store = {
getUI: () => ({ notificationPermissionRequested: true }),
updateUI: vi.fn()
@@ -1315,7 +1628,7 @@ describe('triggerStartupNotificationRegistration', () => {
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('does nothing on non-darwin platforms', () => {
it('does nothing on non-darwin platforms', async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
const store = {
getUI: () => ({ notificationPermissionRequested: undefined }),
@@ -1327,7 +1640,7 @@ describe('triggerStartupNotificationRegistration', () => {
expect(notificationCtorMock).not.toHaveBeenCalled()
})
it('clears startup notification timers when the notification is clicked', () => {
it('clears startup notification timers when the notification is clicked', async () => {
const store = {
getUI: () => ({ notificationPermissionRequested: undefined }),
updateUI: vi.fn()
@@ -1345,7 +1658,7 @@ describe('triggerStartupNotificationRegistration', () => {
expect(notificationRemoveListenerMock).toHaveBeenCalledWith('show', expect.any(Function))
})
it('cleans up startup notification registration when native delivery fails', () => {
it('cleans up startup notification registration when native delivery fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const store = {
+306 -107
View File
@@ -13,6 +13,7 @@ import thumpSoundPath from '../../../resources/notification-sounds/thump.mp3?ass
import twoToneSoundPath from '../../../resources/notification-sounds/two-tone.mp3?asset'
import type { Store } from '../persistence'
import type {
NotificationDeliveryProbeResult,
NotificationDispatchRequest,
NotificationDispatchResult,
NotificationDismissResult,
@@ -23,7 +24,10 @@ import type {
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { buildNotificationOptions } from './notification-options'
import { readNotificationAuthorizationStatus } from './notification-authorization-status'
import { parsePaneKey } from '../../shared/stable-pane-id'
import { setTrayAttention } from '../tray/system-tray'
import { isMainWindowVisible } from '../window/main-window-visibility'
const NOTIFICATION_COOLDOWN_MS = 5000
const MAX_RECENT_NOTIFICATION_KEYS = 50
@@ -96,6 +100,113 @@ function retainNotificationUntilRelease(
return release
}
const NOTIFICATION_PROBE_RESULT_TIMEOUT_MS = 3000
const NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS = 4000
// Why: Electron has no API to read macOS UNUserNotificationCenter
// authorization, so the freshest signal we have is what happened to the last
// notification we scheduled. Session-scoped on purpose: OS-level permission
// can change between runs, and a stale positive renders a false green card.
let lastObservedDeliveryOutcome: 'delivered' | 'failed' | null = null
let deliveryProbeInFlight: Promise<NotificationDeliveryProbeResult> | null = null
// Why: firing one probe notification is what instantiates Electron's
// presenter and pops the macOS permission dialog. Once per session is enough
// while the authorization readout reports the decision as pending.
let permissionDialogTriggeredThisSession = false
/**
* Fallback signal for hosts without the native helper. Schedules a silent
* probe notification and reports whether macOS accepted it. 'failed' means
* the request was rejected (permission denied, or an unsigned build). On a
* fresh install the probe also instantiates Electron's notification
* presenter, which is what makes macOS pop the "Allow notifications?" dialog.
*
* Known ambiguity with no public API to resolve it (verified on macOS 26):
* while the dialog is unanswered — and when notifications are toggled off in
* System Settings after being authorized — macOS still accepts requests and
* silently swallows them, so 'delivered' can over-report. 'failed' fires for
* hard rejections (unsigned builds, dialog-level denial). The bundled
* notification-status helper exists precisely to avoid this ambiguity.
*/
function probeNotificationDelivery(): Promise<NotificationDeliveryProbeResult> {
if (deliveryProbeInFlight) {
return deliveryProbeInFlight
}
permissionDialogTriggeredThisSession = true
const probe = new Notification({
title: 'Orca notifications are on',
body: 'Orca will alert you when agents finish or terminals need attention.',
silent: true
})
activeNotifications.add(probe)
deliveryProbeInFlight = new Promise<NotificationDeliveryProbeResult>((resolve) => {
let settled = false
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
function releaseProbe(): void {
activeNotifications.delete(probe)
probe.removeListener('show', onShow)
probe.removeListener('failed', onFailed)
probe.close()
}
function settle(state: 'delivered' | 'blocked'): void {
if (settled) {
return
}
settled = true
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
}
lastObservedDeliveryOutcome = state === 'delivered' ? 'delivered' : 'failed'
resolve({ state, authoritative: false })
}
function onShow(): void {
settle('delivered')
// Why: when delivery works the probe banner is visible, so it doubles
// as the user-facing confirmation — let it linger briefly instead of
// vanishing the instant it appears.
const closeTimer = setTimeout(releaseProbe, NOTIFICATION_PROBE_BANNER_CLOSE_DELAY_MS)
if (typeof closeTimer.unref === 'function') {
closeTimer.unref()
}
}
function onFailed(_event: unknown, _error?: string): void {
// Why: a rejected probe is an expected outcome (denied permission), not
// an anomaly — logging it would spam the console on every poll while
// the onboarding card waits for the user to allow notifications.
settle('blocked')
releaseProbe()
}
probe.once('show', onShow)
probe.once('failed', onFailed)
// Why: don't record a 'failed' outcome on timeout — a missing callback is
// ambiguous, while the 'failed' event is a definitive rejection.
timeoutTimer = setTimeout(() => {
if (!settled) {
settled = true
resolve({ state: 'blocked', authoritative: false })
releaseProbe()
}
}, NOTIFICATION_PROBE_RESULT_TIMEOUT_MS)
if (typeof timeoutTimer.unref === 'function') {
timeoutTimer.unref()
}
probe.show()
}).finally(() => {
deliveryProbeInFlight = null
})
return deliveryProbeInFlight
}
function getMacNotificationSettingsUrl(): string {
const bundleId = process.env.ORCA_DEV_MACOS_BUNDLE_ID ?? MACOS_PACKAGED_BUNDLE_ID
return `${MACOS_NOTIFICATION_SETTINGS_URL}?id=${encodeURIComponent(bundleId)}`
@@ -203,10 +314,15 @@ function pruneRecentNotifications(recentNotifications: Map<string, number>, now:
export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void {
const recentNotifications = new Map<string, number>()
// Why: handler registration marks a fresh session — permission evidence
// from a previous registration must not leak into the new one.
lastObservedDeliveryOutcome = null
deliveryProbeInFlight = null
permissionDialogTriggeredThisSession = false
ipcMain.removeHandler('notifications:openSystemSettings')
ipcMain.removeHandler('notifications:getPermissionStatus')
ipcMain.removeHandler('notifications:requestPermission')
ipcMain.removeHandler('notifications:probeDelivery')
ipcMain.handle('notifications:openSystemSettings', (): void => {
openNotificationSystemSettings()
})
@@ -225,10 +341,52 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
})
ipcMain.handle('notifications:getPermissionStatus', getPermissionStatus)
ipcMain.handle('notifications:requestPermission', (): NotificationPermissionStatusResult => {
triggerStartupNotificationRegistration(store)
return getPermissionStatus()
})
ipcMain.handle(
'notifications:probeDelivery',
async (_event, args?: { force?: boolean }): Promise<NotificationDeliveryProbeResult> => {
// Why: macOS-only. Windows/Linux have no equivalent first-use permission
// dialog, so the onboarding card that consumes this never renders there.
if (process.platform !== 'darwin' || !Notification.isSupported()) {
return { state: 'unsupported', authoritative: false }
}
// Why: probes (and the native helper's first-launch path) surface the
// macOS permission dialog — mark the one-shot startup registration as
// done so it can't fire a second prompt later.
if (store.getUI().notificationPermissionRequested !== true) {
store.updateUI({ notificationPermissionRequested: true })
}
// Preferred source: the bundled helper reads the real
// UNUserNotificationCenter authorization. Silent, so polling with it
// tracks System Settings changes live without flashing banners.
const authorization = await readNotificationAuthorizationStatus()
if (authorization === 'authorized') {
lastObservedDeliveryOutcome = 'delivered'
return { state: 'delivered', authoritative: true }
}
if (authorization === 'denied') {
lastObservedDeliveryOutcome = 'failed'
return { state: 'blocked', authoritative: true }
}
if (authorization === 'not-determined') {
// Why: the dialog only appears once something asks — fire a single
// probe per session to trigger it, then report the pending decision.
if (!permissionDialogTriggeredThisSession) {
void probeNotificationDelivery()
}
return { state: 'awaiting-decision', authoritative: true }
}
// Helper unavailable ('unknown' status is also unusable evidence):
// fall back to scheduling-based probes with session caching, which
// avoids repeated probe banners when delivery works.
if (!args?.force && lastObservedDeliveryOutcome !== null) {
return {
state: lastObservedDeliveryOutcome === 'delivered' ? 'delivered' : 'blocked',
authoritative: false
}
}
return probeNotificationDelivery()
}
)
ipcMain.removeHandler('notifications:dismiss')
ipcMain.handle('notifications:dismiss', (_event, ids: string[]): NotificationDismissResult => {
@@ -255,6 +413,22 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
_event,
args: NotificationDispatchRequest
): NotificationDispatchResult | Promise<NotificationDispatchResult> => {
// Why: a terminal bell or agent completion that arrives while the window
// is minimized/hidden lights the tray attention dot — a passive cue that
// clears on window show/restore (see index.ts). Placed before the
// focus-suppression, cooldown, and enabled gates below so those do not
// hold back the dot. It rides the notification dispatch, so it follows the
// renderer's per-source decision to notify: bells always reach here, while
// an agent completion is suppressed upstream when its notification is
// disabled. Tray exists only on Windows, so setTrayAttention no-ops
// elsewhere.
if (args.source === 'agent-task-complete' || args.source === 'terminal-bell') {
const activeWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null
if (!isMainWindowVisible(activeWindow)) {
setTrayAttention(true)
}
}
const settings = store.getSettings().notifications
if (!settings.enabled) {
return { delivered: false, reason: 'disabled' }
@@ -317,116 +491,140 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime
return { delivered: false, reason: 'not-supported' }
}
if (getEffectiveNotificationSoundId(settings) !== 'system') {
notificationOptions.silent = true
} else if (process.platform === 'darwin') {
// Why: macOS treats an unset notification sound as silent. When Orca is
// using the OS sound, ask Electron for the default notification sound.
notificationOptions.sound = 'default'
}
const notification = new Notification(notificationOptions)
if (args.notificationId) {
const previous = activeNotificationsById.get(args.notificationId)
if (previous) {
previous.notification.close()
previous.release()
function deliverNativeNotification():
| NotificationDispatchResult
| Promise<NotificationDispatchResult> {
if (getEffectiveNotificationSoundId(settings) !== 'system') {
notificationOptions.silent = true
} else if (process.platform === 'darwin') {
// Why: macOS treats an unset notification sound as silent. When Orca is
// using the OS sound, ask Electron for the default notification sound.
notificationOptions.sound = 'default'
}
}
// Why: prevent GC from collecting the notification (and its click
// handler) while it's still visible in macOS Notification Center.
let clickHandler: (() => void) | null = null
let failedHandler: ((_event: unknown, error?: string) => void) | null = null
const entryForId: { notification: Notification; release: () => void } | null =
args.notificationId ? { notification, release: () => {} } : null
const release = retainNotificationUntilRelease(notification, () => {
if (clickHandler) {
notification.removeListener('click', clickHandler)
clickHandler = null
}
if (failedHandler) {
notification.removeListener('failed', failedHandler)
failedHandler = null
}
if (
args.notificationId &&
activeNotificationsById.get(args.notificationId) === entryForId
) {
activeNotificationsById.delete(args.notificationId)
}
})
if (entryForId && args.notificationId) {
entryForId.release = release
activeNotificationsById.set(args.notificationId, entryForId)
}
failedHandler = (_event, error) => {
// Why: Electron 42's macOS UNNotification backend reports unsigned
// apps and native delivery errors here; release immediately instead
// of retaining a dead notification until the fallback timer.
logNativeNotificationFailure(args.source, error)
release()
}
notification.on('failed', failedHandler)
// Why: clicking a notification should bring Orca to the foreground and
// switch to the worktree/pane that triggered it. Worktree activation owns
// repo/sidebar state; the optional focusTerminal follow-up uses the stable
// pane leaf id so split-pane notifications land on the exact pane.
// Why: worktreeId is formatted as "repoId::worktreePath". If the
// separator is missing we cannot reliably extract a repoId, so skip
// the click-to-navigate binding — the notification still fires but
// clicking it will not attempt to switch to an unknown worktree.
if (args.worktreeId && args.worktreeId.includes('::')) {
const repoId = getRepoIdFromWorktreeId(args.worktreeId)
clickHandler = () => {
release()
const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())
if (!win) {
return
}
if (process.platform === 'darwin') {
app.focus({ steal: true })
}
if (win.isMinimized()) {
win.restore()
}
win.focus()
win.webContents.send('ui:activateWorktree', {
repoId,
worktreeId: args.worktreeId
})
const paneTarget = args.paneKey ? parsePaneKey(args.paneKey) : null
if (paneTarget) {
win.webContents.send('ui:focusTerminal', {
tabId: paneTarget.tabId,
worktreeId: args.worktreeId,
leafId: paneTarget.leafId,
ackPaneKeyOnSuccess: args.paneKey,
flashFocusedPane: true,
scrollToBottomIfOutputSinceLastView: true
})
const notification = new Notification(notificationOptions)
if (args.notificationId) {
const previous = activeNotificationsById.get(args.notificationId)
if (previous) {
previous.notification.close()
previous.release()
}
}
notification.on('click', clickHandler)
}
const displayConfirmation = args.requireDisplayConfirmation
? waitForNotificationDisplay(notification)
: null
notification.show()
if (displayConfirmation) {
return displayConfirmation.then((displayed) => {
if (!displayed) {
release()
return { delivered: false, reason: 'not-displayed' }
// Why: prevent GC from collecting the notification (and its click
// handler) while it's still visible in macOS Notification Center.
let clickHandler: (() => void) | null = null
let failedHandler: ((_event: unknown, error?: string) => void) | null = null
const entryForId: { notification: Notification; release: () => void } | null =
args.notificationId ? { notification, release: () => {} } : null
const release = retainNotificationUntilRelease(notification, () => {
if (clickHandler) {
notification.removeListener('click', clickHandler)
clickHandler = null
}
if (failedHandler) {
notification.removeListener('failed', failedHandler)
failedHandler = null
}
if (
args.notificationId &&
activeNotificationsById.get(args.notificationId) === entryForId
) {
activeNotificationsById.delete(args.notificationId)
}
return { delivered: true }
})
if (entryForId && args.notificationId) {
entryForId.release = release
activeNotificationsById.set(args.notificationId, entryForId)
}
failedHandler = (_event, error) => {
// Why: Electron 42's macOS UNNotification backend reports unsigned
// apps and native delivery errors here; release immediately instead
// of retaining a dead notification until the fallback timer.
logNativeNotificationFailure(args.source, error)
// A definitive rejection — feeds the permission card's evidence.
lastObservedDeliveryOutcome = 'failed'
release()
}
notification.on('failed', failedHandler)
// Why: clicking a notification should bring Orca to the foreground and
// switch to the worktree/pane that triggered it. Worktree activation owns
// repo/sidebar state; the optional focusTerminal follow-up uses the stable
// pane leaf id so split-pane notifications land on the exact pane.
// Why: worktreeId is formatted as "repoId::worktreePath". If the
// separator is missing we cannot reliably extract a repoId, so skip
// the click-to-navigate binding — the notification still fires but
// clicking it will not attempt to switch to an unknown worktree.
if (args.worktreeId && args.worktreeId.includes('::')) {
const repoId = getRepoIdFromWorktreeId(args.worktreeId)
clickHandler = () => {
release()
const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())
if (!win) {
return
}
if (process.platform === 'darwin') {
app.focus({ steal: true })
}
if (win.isMinimized()) {
win.restore()
}
win.focus()
win.webContents.send('ui:activateWorktree', {
repoId,
worktreeId: args.worktreeId
})
const paneTarget = args.paneKey ? parsePaneKey(args.paneKey) : null
if (paneTarget) {
win.webContents.send('ui:focusTerminal', {
tabId: paneTarget.tabId,
worktreeId: args.worktreeId,
leafId: paneTarget.leafId,
ackPaneKeyOnSuccess: args.paneKey,
flashFocusedPane: true,
scrollToBottomIfOutputSinceLastView: true
})
}
}
notification.on('click', clickHandler)
}
const displayConfirmation = args.requireDisplayConfirmation
? waitForNotificationDisplay(notification)
: null
notification.show()
if (displayConfirmation) {
return displayConfirmation.then((displayed) => {
if (!displayed) {
release()
return { delivered: false, reason: 'not-displayed' }
}
lastObservedDeliveryOutcome = 'delivered'
return { delivered: true }
})
}
return { delivered: true }
}
return { delivered: true }
if (process.platform !== 'darwin') {
return deliverNativeNotification()
}
// Why: macOS silently swallows accepted notifications while permission
// is denied or the permission dialog is unanswered (verified on macOS
// 26). Skip the doomed native notification and tell the caller, so the
// renderer can surface an in-app fallback pointing at System Settings.
// The mobile dispatch above is unaffected — paired devices have their
// own notification channel.
return readNotificationAuthorizationStatus().then((authorization) => {
if (authorization === 'denied' || authorization === 'not-determined') {
lastObservedDeliveryOutcome = 'failed'
return { delivered: false, reason: 'blocked-by-system' }
}
return deliverNativeNotification()
})
}
)
@@ -564,6 +762,7 @@ export function triggerStartupNotificationRegistration(store: Store): void {
// Why: Electron 42 requires code-signed macOS apps for UNNotification
// delivery. Unsigned builds fail here instead of producing the permission UI.
logNativeNotificationFailure('startup registration', error)
lastObservedDeliveryOutcome = 'failed'
cleanup()
}
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
handlers,
createCloudLinkedOrcaProfileMock,
connectCurrentOrcaProfileMock,
getCurrentOrcaProfileAuthStatusMock,
refreshCurrentOrcaProfileAuthMock,
selectCurrentOrcaProfileOrgMock,
signOutCurrentOrcaProfileMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
createCloudLinkedOrcaProfileMock: vi.fn(),
connectCurrentOrcaProfileMock: vi.fn(),
getCurrentOrcaProfileAuthStatusMock: vi.fn(),
refreshCurrentOrcaProfileAuthMock: vi.fn(),
selectCurrentOrcaProfileOrgMock: vi.fn(),
signOutCurrentOrcaProfileMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
exit: vi.fn(),
getPath: () => '/tmp/orca-user-data',
relaunch: vi.fn()
},
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
})
}
}))
vi.mock('../tray/system-tray', () => ({
destroySystemTray: vi.fn()
}))
vi.mock('../orca-profiles/profile-index-store', () => ({
createLocalOrcaProfile: vi.fn(),
getOrcaProfileListState: vi.fn(),
seedNewOrcaProfileTelemetryConsent: vi.fn(),
setActiveOrcaProfile: vi.fn()
}))
vi.mock('../orca-profiles/profile-project-transfer', () => ({
transferOrcaProfileProject: vi.fn()
}))
vi.mock('../orca-profiles/profile-cloud-service', () => ({
createCloudLinkedOrcaProfile: createCloudLinkedOrcaProfileMock,
connectCurrentOrcaProfile: connectCurrentOrcaProfileMock,
getCurrentOrcaProfileAuthStatus: getCurrentOrcaProfileAuthStatusMock,
refreshCurrentOrcaProfileAuth: refreshCurrentOrcaProfileAuthMock,
selectCurrentOrcaProfileOrg: selectCurrentOrcaProfileOrgMock,
signOutCurrentOrcaProfile: signOutCurrentOrcaProfileMock
}))
import { registerOrcaProfileHandlers } from './orca-profiles'
describe('registerOrcaProfileHandlers auth channels', () => {
beforeEach(() => {
handlers.clear()
createCloudLinkedOrcaProfileMock.mockReset()
connectCurrentOrcaProfileMock.mockReset()
getCurrentOrcaProfileAuthStatusMock.mockReset()
refreshCurrentOrcaProfileAuthMock.mockReset()
selectCurrentOrcaProfileOrgMock.mockReset()
signOutCurrentOrcaProfileMock.mockReset()
})
it('returns auth status for the current profile', async () => {
const status = {
activeProfileId: 'local-default',
configured: false,
state: 'unconfigured',
persistence: 'none'
}
getCurrentOrcaProfileAuthStatusMock.mockReturnValue(status)
registerOrcaProfileHandlers({
flush: vi.fn(),
freezeWrites: vi.fn(),
getSettings: () => ({})
} as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:authStatus')?.(null))).resolves.toBe(
status
)
expect(getCurrentOrcaProfileAuthStatusMock).toHaveBeenCalledWith('/tmp/orca-user-data')
})
it('connects and signs out the current profile through the cloud service', async () => {
const connectResult = { status: 'unconfigured', auth: { activeProfileId: 'local-default' } }
const signOutResult = { status: 'signed-out', auth: { activeProfileId: 'local-default' } }
connectCurrentOrcaProfileMock.mockResolvedValue(connectResult)
signOutCurrentOrcaProfileMock.mockResolvedValue(signOutResult)
registerOrcaProfileHandlers({
flush: vi.fn(),
freezeWrites: vi.fn(),
getSettings: () => ({})
} as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:connectCurrent')?.(null))
).resolves.toBe(connectResult)
await expect(
Promise.resolve(handlers.get('orcaProfiles:signOutCurrent')?.(null))
).resolves.toBe(signOutResult)
expect(connectCurrentOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data')
expect(signOutCurrentOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data')
})
it('refreshes profile auth through the cloud service', async () => {
const refreshResult = { status: 'refreshed', auth: { activeProfileId: 'local-default' } }
refreshCurrentOrcaProfileAuthMock.mockResolvedValue(refreshResult)
registerOrcaProfileHandlers({
flush: vi.fn(),
freezeWrites: vi.fn(),
getSettings: () => ({})
} as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:refreshAuth')?.(null))).resolves.toBe(
refreshResult
)
expect(refreshCurrentOrcaProfileAuthMock).toHaveBeenCalledWith('/tmp/orca-user-data')
})
it('validates organization selection before calling the cloud service', async () => {
const selectResult = { status: 'selected', auth: { activeProfileId: 'local-default' } }
selectCurrentOrcaProfileOrgMock.mockResolvedValue(selectResult)
registerOrcaProfileHandlers({
flush: vi.fn(),
freezeWrites: vi.fn(),
getSettings: () => ({})
} as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:selectOrg')?.(null, { orgId: ' org-1 ' }))
).resolves.toBe(selectResult)
expect(selectCurrentOrcaProfileOrgMock).toHaveBeenCalledWith('/tmp/orca-user-data', 'org-1')
await expect(
Promise.resolve(handlers.get('orcaProfiles:selectOrg')?.(null, { orgId: ' ' }))
).rejects.toThrow('invalid_orca_profile_org_selection')
})
it('creates cloud-linked profiles with trimmed optional args', async () => {
const createResult = {
status: 'created',
auth: { activeProfileId: 'local-default' },
activeProfileId: 'local-default',
profiles: [],
profile: { id: 'cloud-1' }
}
createCloudLinkedOrcaProfileMock.mockResolvedValue(createResult)
registerOrcaProfileHandlers({
flush: vi.fn(),
freezeWrites: vi.fn(),
getSettings: () => ({})
} as never)
await expect(
Promise.resolve(
handlers.get('orcaProfiles:createCloudLinked')?.(null, { orgId: ' org-1 ', name: ' Acme ' })
)
).resolves.toBe(createResult)
expect(createCloudLinkedOrcaProfileMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
orgId: 'org-1',
name: 'Acme'
})
})
})
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
handlers,
listOrcaProfileOrgMembersMock,
inviteOrcaProfileOrgMemberMock,
revokeOrcaProfileOrgInviteMock,
changeOrcaProfileOrgMemberRoleMock,
removeOrcaProfileOrgMemberMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
listOrcaProfileOrgMembersMock: vi.fn(),
inviteOrcaProfileOrgMemberMock: vi.fn(),
revokeOrcaProfileOrgInviteMock: vi.fn(),
changeOrcaProfileOrgMemberRoleMock: vi.fn(),
removeOrcaProfileOrgMemberMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
})
}
}))
vi.mock('../orca-profiles/profile-storage-paths', () => ({
getProfileUserDataPath: () => '/tmp/orca-user-data'
}))
vi.mock('../orca-profiles/profile-cloud-org-members-service', () => ({
listOrcaProfileOrgMembers: listOrcaProfileOrgMembersMock,
inviteOrcaProfileOrgMember: inviteOrcaProfileOrgMemberMock,
revokeOrcaProfileOrgInvite: revokeOrcaProfileOrgInviteMock,
changeOrcaProfileOrgMemberRole: changeOrcaProfileOrgMemberRoleMock,
removeOrcaProfileOrgMember: removeOrcaProfileOrgMemberMock
}))
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
function invoke(channel: string, args?: unknown): unknown {
const handler = handlers.get(channel)
if (!handler) {
throw new Error(`No handler for ${channel}`)
}
return handler({}, args)
}
describe('registerOrcaProfileOrgMemberHandlers', () => {
beforeEach(() => {
handlers.clear()
listOrcaProfileOrgMembersMock.mockReset().mockResolvedValue({ status: 'ok', roster: {} })
inviteOrcaProfileOrgMemberMock.mockReset().mockResolvedValue({ status: 'ok' })
revokeOrcaProfileOrgInviteMock.mockReset().mockResolvedValue({ status: 'ok' })
changeOrcaProfileOrgMemberRoleMock.mockReset().mockResolvedValue({ status: 'ok' })
removeOrcaProfileOrgMemberMock.mockReset().mockResolvedValue({ status: 'ok' })
registerOrcaProfileOrgMemberHandlers()
})
it('registers all five org-member channels', () => {
expect([...handlers.keys()].sort()).toEqual(
[
'orcaProfiles:orgInviteRevoke',
'orcaProfiles:orgMemberChangeRole',
'orcaProfiles:orgMemberInvite',
'orcaProfiles:orgMemberRemove',
'orcaProfiles:orgMembersList'
].sort()
)
})
it('forwards a valid invite to the service with a trimmed email', async () => {
await invoke('orcaProfiles:orgMemberInvite', {
orgId: 'org-1',
email: ' new@example.com ',
role: 'admin'
})
expect(inviteOrcaProfileOrgMemberMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
orgId: 'org-1',
email: 'new@example.com',
role: 'admin'
})
})
it('rejects an invite with a missing org id', async () => {
await expect(
invoke('orcaProfiles:orgMemberInvite', { email: 'a@b.com', role: 'member' })
).rejects.toThrow('invalid_orca_profile_org_selection')
expect(inviteOrcaProfileOrgMemberMock).not.toHaveBeenCalled()
})
it('rejects an invite with an unknown role', async () => {
await expect(
invoke('orcaProfiles:orgMemberInvite', { orgId: 'org-1', email: 'a@b.com', role: 'root' })
).rejects.toThrow('invalid_orca_org_role')
})
it('rejects a role change with a blank user id', async () => {
await expect(
invoke('orcaProfiles:orgMemberChangeRole', { orgId: 'org-1', userId: ' ', role: 'admin' })
).rejects.toThrow('invalid_orca_org_member_user')
})
it('forwards remove and revoke with validated args', async () => {
await invoke('orcaProfiles:orgMemberRemove', { orgId: 'org-1', userId: 'user-2' })
expect(removeOrcaProfileOrgMemberMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
orgId: 'org-1',
userId: 'user-2'
})
await invoke('orcaProfiles:orgInviteRevoke', { orgId: 'org-1', email: 'gone@b.com' })
expect(revokeOrcaProfileOrgInviteMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
orgId: 'org-1',
email: 'gone@b.com'
})
})
})
@@ -0,0 +1,128 @@
import { ipcMain } from 'electron'
import type {
OrcaOrgRole,
OrcaProfileOrgInviteRevokeArgs,
OrcaProfileOrgMemberChangeRoleArgs,
OrcaProfileOrgMemberInviteArgs,
OrcaProfileOrgMemberMutationResult,
OrcaProfileOrgMemberRemoveArgs,
OrcaProfileOrgMembersListArgs,
OrcaProfileOrgMembersListResult
} from '../../shared/orca-profiles'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import {
changeOrcaProfileOrgMemberRole,
inviteOrcaProfileOrgMember,
listOrcaProfileOrgMembers,
removeOrcaProfileOrgMember,
revokeOrcaProfileOrgInvite
} from '../orca-profiles/profile-cloud-org-members-service'
function orgMembersScopedArgs(args: unknown): { orgId: string; record: Record<string, unknown> } {
if (!args || typeof args !== 'object') {
throw new Error('invalid_orca_profile_org_selection')
}
const record = args as Record<string, unknown>
const orgId = typeof record.orgId === 'string' ? record.orgId.trim() : ''
if (!orgId) {
throw new Error('invalid_orca_profile_org_selection')
}
return { orgId, record }
}
function orgRoleFromUnknown(value: unknown): OrcaOrgRole {
if (value === 'owner' || value === 'admin' || value === 'member') {
return value
}
throw new Error('invalid_orca_org_role')
}
function orgEmailFromUnknown(value: unknown): string {
const email = typeof value === 'string' ? value.trim() : ''
if (!email) {
throw new Error('invalid_orca_org_member_email')
}
return email
}
function orgUserIdFromUnknown(value: unknown): string {
const userId = typeof value === 'string' ? value.trim() : ''
if (!userId) {
throw new Error('invalid_orca_org_member_user')
}
return userId
}
function orgMemberInviteArgsFromUnknown(args: unknown): OrcaProfileOrgMemberInviteArgs {
const { orgId, record } = orgMembersScopedArgs(args)
return { orgId, email: orgEmailFromUnknown(record.email), role: orgRoleFromUnknown(record.role) }
}
function orgInviteRevokeArgsFromUnknown(args: unknown): OrcaProfileOrgInviteRevokeArgs {
const { orgId, record } = orgMembersScopedArgs(args)
return { orgId, email: orgEmailFromUnknown(record.email) }
}
function orgMemberChangeRoleArgsFromUnknown(args: unknown): OrcaProfileOrgMemberChangeRoleArgs {
const { orgId, record } = orgMembersScopedArgs(args)
return {
orgId,
userId: orgUserIdFromUnknown(record.userId),
role: orgRoleFromUnknown(record.role)
}
}
function orgMemberRemoveArgsFromUnknown(args: unknown): OrcaProfileOrgMemberRemoveArgs {
const { orgId, record } = orgMembersScopedArgs(args)
return { orgId, userId: orgUserIdFromUnknown(record.userId) }
}
export function registerOrcaProfileOrgMemberHandlers(): void {
ipcMain.handle(
'orcaProfiles:orgMembersList',
async (
_event,
rawArgs: OrcaProfileOrgMembersListArgs
): Promise<OrcaProfileOrgMembersListResult> =>
listOrcaProfileOrgMembers(getProfileUserDataPath(), orgMembersScopedArgs(rawArgs).orgId)
)
ipcMain.handle(
'orcaProfiles:orgMemberInvite',
async (
_event,
rawArgs: OrcaProfileOrgMemberInviteArgs
): Promise<OrcaProfileOrgMemberMutationResult> =>
inviteOrcaProfileOrgMember(getProfileUserDataPath(), orgMemberInviteArgsFromUnknown(rawArgs))
)
ipcMain.handle(
'orcaProfiles:orgInviteRevoke',
async (
_event,
rawArgs: OrcaProfileOrgInviteRevokeArgs
): Promise<OrcaProfileOrgMemberMutationResult> =>
revokeOrcaProfileOrgInvite(getProfileUserDataPath(), orgInviteRevokeArgsFromUnknown(rawArgs))
)
ipcMain.handle(
'orcaProfiles:orgMemberChangeRole',
async (
_event,
rawArgs: OrcaProfileOrgMemberChangeRoleArgs
): Promise<OrcaProfileOrgMemberMutationResult> =>
changeOrcaProfileOrgMemberRole(
getProfileUserDataPath(),
orgMemberChangeRoleArgsFromUnknown(rawArgs)
)
)
ipcMain.handle(
'orcaProfiles:orgMemberRemove',
async (
_event,
rawArgs: OrcaProfileOrgMemberRemoveArgs
): Promise<OrcaProfileOrgMemberMutationResult> =>
removeOrcaProfileOrgMember(getProfileUserDataPath(), orgMemberRemoveArgsFromUnknown(rawArgs))
)
}
+321
View File
@@ -0,0 +1,321 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
handlers,
appExitMock,
appQuitMock,
appRelaunchMock,
destroySystemTrayMock,
createLocalOrcaProfileMock,
getOrcaProfileListStateMock,
seedNewOrcaProfileTelemetryConsentMock,
setActiveOrcaProfileMock,
transferOrcaProfileProjectMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
appExitMock: vi.fn(),
appQuitMock: vi.fn(),
appRelaunchMock: vi.fn(),
destroySystemTrayMock: vi.fn(),
createLocalOrcaProfileMock: vi.fn(),
getOrcaProfileListStateMock: vi.fn(),
seedNewOrcaProfileTelemetryConsentMock: vi.fn(),
setActiveOrcaProfileMock: vi.fn(),
transferOrcaProfileProjectMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
exit: appExitMock,
quit: appQuitMock,
relaunch: appRelaunchMock,
getPath: () => '/tmp/orca-user-data'
},
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
})
}
}))
vi.mock('../tray/system-tray', () => ({
destroySystemTray: destroySystemTrayMock
}))
vi.mock('../orca-profiles/profile-index-store', () => ({
createLocalOrcaProfile: createLocalOrcaProfileMock,
getOrcaProfileListState: getOrcaProfileListStateMock,
seedNewOrcaProfileTelemetryConsent: seedNewOrcaProfileTelemetryConsentMock,
setActiveOrcaProfile: setActiveOrcaProfileMock
}))
function makeStoreMock(flush = vi.fn()): {
flush: typeof flush
freezeWrites: ReturnType<typeof vi.fn>
getSettings: () => Record<string, never>
} {
return { flush, freezeWrites: vi.fn(), getSettings: () => ({}) }
}
vi.mock('../orca-profiles/profile-project-transfer', () => ({
transferOrcaProfileProject: transferOrcaProfileProjectMock
}))
import { registerOrcaProfileHandlers } from './orca-profiles'
describe('registerOrcaProfileHandlers', () => {
beforeEach(() => {
vi.useFakeTimers()
handlers.clear()
appExitMock.mockReset()
appQuitMock.mockReset()
appRelaunchMock.mockReset()
destroySystemTrayMock.mockReset()
createLocalOrcaProfileMock.mockReset()
getOrcaProfileListStateMock.mockReset()
seedNewOrcaProfileTelemetryConsentMock.mockReset()
setActiveOrcaProfileMock.mockReset()
transferOrcaProfileProjectMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('registers list and create handlers', async () => {
const listState = {
activeProfileId: 'local-default',
profiles: [{ id: 'local-default', name: 'Personal' }]
}
const createState = {
...listState,
profile: { id: 'local-work', name: 'Work' }
}
getOrcaProfileListStateMock.mockReturnValue(listState)
createLocalOrcaProfileMock.mockReturnValue(createState)
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({
...listState,
multiProfileUi: false
})
await expect(
Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(null, { name: 'Work' }))
).resolves.toBe(createState)
expect(createLocalOrcaProfileMock).toHaveBeenCalledWith({ name: 'Work' })
})
it('reports multiProfileUi when the env flag is set', async () => {
const previous = process.env.ORCA_MULTI_PROFILE_UI
process.env.ORCA_MULTI_PROFILE_UI = '1'
try {
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({
activeProfileId: 'local-default',
profiles: [],
multiProfileUi: true
})
} finally {
if (previous === undefined) {
delete process.env.ORCA_MULTI_PROFILE_UI
} else {
process.env.ORCA_MULTI_PROFILE_UI = previous
}
}
})
it('marks the target profile active, flushes, and relaunches', async () => {
const flush = vi.fn()
const onBeforeRelaunch = vi.fn()
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
setActiveOrcaProfileMock.mockReturnValue({
activeProfileId: 'local-work',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch })
const resultPromise = Promise.resolve(
handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })
)
await expect(resultPromise).resolves.toEqual({ status: 'relaunching' })
expect(setActiveOrcaProfileMock).toHaveBeenCalledWith('local-work')
expect(flush).toHaveBeenCalledOnce()
expect(onBeforeRelaunch).toHaveBeenCalledOnce()
expect(flush.mock.invocationCallOrder[0]).toBeLessThan(
setActiveOrcaProfileMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
)
expect(appRelaunchMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(appRelaunchMock).toHaveBeenCalledOnce()
// Why quit, not exit: before-quit/will-quit teardown (scrollback capture,
// PTY kill, daemon checkpoints) must run on a profile switch.
expect(appQuitMock).toHaveBeenCalledOnce()
expect(appExitMock).not.toHaveBeenCalled()
})
it('does not mark a profile active when current profile flush fails', async () => {
const flush = vi.fn(() => {
throw new Error('flush_failed')
})
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock(flush) as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }))
).rejects.toThrow('flush_failed')
expect(setActiveOrcaProfileMock).not.toHaveBeenCalled()
expect(appRelaunchMock).not.toHaveBeenCalled()
})
it('does not relaunch when switching to the active profile', async () => {
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-default' }))
).resolves.toEqual({ status: 'already-active' })
expect(setActiveOrcaProfileMock).not.toHaveBeenCalled()
expect(appRelaunchMock).not.toHaveBeenCalled()
})
it('rejects invalid profile ids', async () => {
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: ' ' }))
).rejects.toThrow('invalid_orca_profile_id')
})
it('transfers projects between inactive profiles after flushing active state', async () => {
const flush = vi.fn()
const result = {
status: 'transferred',
mode: 'copy',
sourceProfileId: 'personal',
targetProfileId: 'work',
sourceRepoId: 'repo-1',
targetRepoId: 'repo-2',
targetProjectId: 'repo:repo-2'
}
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'personal',
profiles: []
})
transferOrcaProfileProjectMock.mockReturnValue(result)
registerOrcaProfileHandlers(makeStoreMock(flush) as never)
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
sourceProfileId: ' personal ',
targetProfileId: ' work ',
repoId: ' repo-1 ',
mode: 'copy'
})
)
).resolves.toBe(result)
expect(flush).toHaveBeenCalledOnce()
expect(transferOrcaProfileProjectMock).toHaveBeenCalledWith(
{
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'copy'
},
'/tmp/orca-user-data'
)
})
it('moves a project out of the active profile and relaunches into the target profile', async () => {
const flush = vi.fn()
const onBeforeRelaunch = vi.fn()
const result = {
status: 'transferred',
mode: 'move',
sourceProfileId: 'personal',
targetProfileId: 'work',
sourceRepoId: 'repo-1',
targetRepoId: 'repo-1',
targetProjectId: 'repo:repo-1'
}
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'personal',
profiles: []
})
transferOrcaProfileProjectMock.mockReturnValue(result)
registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch })
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'move'
})
)
).resolves.toEqual({ ...result, willRelaunch: true })
expect(onBeforeRelaunch).toHaveBeenCalledOnce()
expect(flush).toHaveBeenCalledOnce()
expect(transferOrcaProfileProjectMock).toHaveBeenCalledWith(
{
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'move'
},
'/tmp/orca-user-data'
)
expect(setActiveOrcaProfileMock).toHaveBeenCalledWith('work')
expect(appRelaunchMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(appRelaunchMock).toHaveBeenCalledOnce()
expect(appQuitMock).toHaveBeenCalledOnce()
expect(appExitMock).not.toHaveBeenCalled()
})
it('rejects transfers that would mutate the active target profile offline', async () => {
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'work',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'copy'
})
)
).rejects.toThrow('active_target_orca_profile_transfer_requires_relaunch')
expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled()
})
})
+291
View File
@@ -0,0 +1,291 @@
import { app, ipcMain } from 'electron'
import type { Store } from '../persistence'
import type {
CreateLocalOrcaProfileArgs,
CreateLocalOrcaProfileResult,
CreateCloudLinkedOrcaProfileArgs,
CreateCloudLinkedOrcaProfileResult,
FindOrcaProfileProjectsByPathArgs,
FindOrcaProfileProjectsByPathResult,
OrcaProfileListResult,
RefreshCurrentOrcaProfileAuthResult,
SwitchOrcaProfileArgs,
SwitchOrcaProfileResult,
TransferOrcaProfileProjectArgs,
TransferOrcaProfileProjectResult,
ConnectCurrentOrcaProfileResult,
OrcaProfileAuthStatus,
SelectOrcaProfileOrgArgs,
SelectOrcaProfileOrgResult,
SignOutCurrentOrcaProfileResult
} from '../../shared/orca-profiles'
import {
createLocalOrcaProfile,
getOrcaProfileListState,
seedNewOrcaProfileTelemetryConsent,
setActiveOrcaProfile
} from '../orca-profiles/profile-index-store'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import { isMultiProfileUiEnabled } from '../orca-profiles/profile-ui-scope'
import { transferOrcaProfileProject } from '../orca-profiles/profile-project-transfer'
import { findOrcaProfileProjectsByPath } from '../orca-profiles/profile-project-presence'
import { normalizeExecutionHostId } from '../../shared/execution-host'
import {
createCloudLinkedOrcaProfile,
connectCurrentOrcaProfile,
getCurrentOrcaProfileAuthStatus,
refreshCurrentOrcaProfileAuth,
selectCurrentOrcaProfileOrg,
signOutCurrentOrcaProfile
} from '../orca-profiles/profile-cloud-service'
import { registerOrcaProfileOrgMemberHandlers } from './orca-profile-org-members-handlers'
type RegisterOrcaProfileHandlersOptions = {
onBeforeRelaunch?: () => void | Promise<void>
}
function profileIdFromArgs(args: unknown): string {
if (
!args ||
typeof args !== 'object' ||
typeof (args as SwitchOrcaProfileArgs).profileId !== 'string'
) {
throw new Error('invalid_orca_profile_id')
}
const profileId = (args as SwitchOrcaProfileArgs).profileId.trim()
if (!profileId) {
throw new Error('invalid_orca_profile_id')
}
return profileId
}
function transferProjectArgsFromUnknown(args: unknown): TransferOrcaProfileProjectArgs {
if (!args || typeof args !== 'object') {
throw new Error('invalid_orca_profile_project_transfer')
}
const candidate = args as TransferOrcaProfileProjectArgs
const sourceProfileId = candidate.sourceProfileId?.trim()
const targetProfileId = candidate.targetProfileId?.trim()
const repoId = candidate.repoId?.trim()
const mode = candidate.mode
if (!sourceProfileId || !targetProfileId || !repoId || (mode !== 'move' && mode !== 'copy')) {
throw new Error('invalid_orca_profile_project_transfer')
}
return {
sourceProfileId,
targetProfileId,
repoId,
mode
}
}
function findProjectsByPathArgsFromUnknown(args: unknown): FindOrcaProfileProjectsByPathArgs {
if (!args || typeof args !== 'object') {
throw new Error('invalid_orca_profile_project_path')
}
const candidate = args as FindOrcaProfileProjectsByPathArgs
const path = typeof candidate.path === 'string' ? candidate.path.trim() : ''
if (!path) {
throw new Error('invalid_orca_profile_project_path')
}
let executionHostId: FindOrcaProfileProjectsByPathArgs['executionHostId'] = null
if (candidate.executionHostId !== null && candidate.executionHostId !== undefined) {
if (typeof candidate.executionHostId !== 'string') {
throw new Error('invalid_orca_profile_project_path')
}
executionHostId = normalizeExecutionHostId(candidate.executionHostId)
if (!executionHostId) {
throw new Error('invalid_orca_profile_project_path')
}
}
return {
path,
connectionId:
typeof candidate.connectionId === 'string' ? candidate.connectionId.trim() || null : null,
executionHostId,
excludeProfileId:
typeof candidate.excludeProfileId === 'string'
? candidate.excludeProfileId.trim() || null
: null
}
}
function orgIdFromUnknown(args: unknown): string {
if (!args || typeof args !== 'object') {
throw new Error('invalid_orca_profile_org_selection')
}
const orgId = (args as SelectOrcaProfileOrgArgs).orgId?.trim()
if (!orgId) {
throw new Error('invalid_orca_profile_org_selection')
}
return orgId
}
function createCloudLinkedProfileArgsFromUnknown(args: unknown): CreateCloudLinkedOrcaProfileArgs {
if (!args || typeof args !== 'object') {
return {}
}
const candidate = args as CreateCloudLinkedOrcaProfileArgs
const orgId = typeof candidate.orgId === 'string' ? candidate.orgId.trim() : undefined
const name = typeof candidate.name === 'string' ? candidate.name.trim() : undefined
return {
...(orgId ? { orgId } : {}),
...(name ? { name } : {})
}
}
async function runBeforeProfileRelaunch(
onBeforeRelaunch?: () => void | Promise<void>
): Promise<void> {
try {
await onBeforeRelaunch?.()
} catch (error) {
console.warn(
'[orca-profiles] Pre-relaunch cleanup failed; continuing profile switch:',
error instanceof Error ? error.name : typeof error
)
}
}
function scheduleProfileRelaunch(): void {
setTimeout(() => {
app.relaunch()
// Why: app.quit() (not app.exit) so before-quit/will-quit still run —
// renderer scrollback capture, PTY kill, stats flush, and daemon final
// checkpoints must not be skipped on a profile switch.
app.quit()
}, 150)
}
export function registerOrcaProfileHandlers(
store: Store,
options: RegisterOrcaProfileHandlersOptions = {}
): void {
ipcMain.handle(
'orcaProfiles:list',
(): OrcaProfileListResult => ({
...getOrcaProfileListState(),
multiProfileUi: isMultiProfileUiEnabled()
})
)
ipcMain.handle(
'orcaProfiles:authStatus',
(): OrcaProfileAuthStatus => getCurrentOrcaProfileAuthStatus(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:createLocal',
(_event, args?: CreateLocalOrcaProfileArgs): CreateLocalOrcaProfileResult => {
const result = createLocalOrcaProfile(args)
seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry)
return result
}
)
ipcMain.handle(
'orcaProfiles:switch',
async (_event, args: SwitchOrcaProfileArgs): Promise<SwitchOrcaProfileResult> => {
const profileId = profileIdFromArgs(args)
const current = getOrcaProfileListState()
if (profileId === current.activeProfileId) {
return { status: 'already-active' }
}
// Why: the current profile must be persisted before the global index
// points startup at the target profile.
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
store.flush()
setActiveOrcaProfile(profileId)
scheduleProfileRelaunch()
return { status: 'relaunching' }
}
)
ipcMain.handle(
'orcaProfiles:transferProject',
async (
_event,
rawArgs: TransferOrcaProfileProjectArgs
): Promise<TransferOrcaProfileProjectResult> => {
const args = transferProjectArgsFromUnknown(rawArgs)
const current = getOrcaProfileListState()
if (args.targetProfileId === current.activeProfileId) {
throw new Error('active_target_orca_profile_transfer_requires_relaunch')
}
if (args.mode === 'move' && args.sourceProfileId === current.activeProfileId) {
// Why: transfer before any relaunch side effect so a duplicate-target
// or validation failure cannot strand the app in a quitting state.
// flush→transfer→freeze runs synchronously with no interleaving, and
// the freeze keeps late sync saves from resurrecting the moved
// project from stale memory before the relaunch.
store.flush()
const result = transferOrcaProfileProject(args, getProfileUserDataPath())
if (result.status === 'transferred') {
store.freezeWrites()
await runBeforeProfileRelaunch(options.onBeforeRelaunch)
setActiveOrcaProfile(args.targetProfileId)
scheduleProfileRelaunch()
return { ...result, willRelaunch: true }
}
return result
}
store.flush()
return transferOrcaProfileProject(args, getProfileUserDataPath())
}
)
ipcMain.handle(
'orcaProfiles:findProjectProfiles',
(_event, rawArgs: FindOrcaProfileProjectsByPathArgs): FindOrcaProfileProjectsByPathResult =>
findOrcaProfileProjectsByPath(
findProjectsByPathArgsFromUnknown(rawArgs),
getProfileUserDataPath()
)
)
ipcMain.handle(
'orcaProfiles:connectCurrent',
async (): Promise<ConnectCurrentOrcaProfileResult> =>
connectCurrentOrcaProfile(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:createCloudLinked',
async (
_event,
rawArgs?: CreateCloudLinkedOrcaProfileArgs
): Promise<CreateCloudLinkedOrcaProfileResult> => {
const result = await createCloudLinkedOrcaProfile(
getProfileUserDataPath(),
createCloudLinkedProfileArgsFromUnknown(rawArgs)
)
if (result.status === 'created') {
seedNewOrcaProfileTelemetryConsent(result.profile.id, store.getSettings().telemetry)
}
return result
}
)
ipcMain.handle(
'orcaProfiles:refreshAuth',
async (): Promise<RefreshCurrentOrcaProfileAuthResult> =>
refreshCurrentOrcaProfileAuth(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:signOutCurrent',
async (): Promise<SignOutCurrentOrcaProfileResult> =>
signOutCurrentOrcaProfile(getProfileUserDataPath())
)
ipcMain.handle(
'orcaProfiles:selectOrg',
async (_event, rawArgs: SelectOrcaProfileOrgArgs): Promise<SelectOrcaProfileOrgResult> =>
selectCurrentOrcaProfileOrg(getProfileUserDataPath(), orgIdFromUnknown(rawArgs))
)
registerOrcaProfileOrgMemberHandlers()
}
@@ -31,6 +31,7 @@ const {
registerRuntimeEnvironmentHandlersMock,
registerEphemeralVmHandlersMock,
registerAiVaultHandlersMock,
registerOrcaProfileHandlersMock,
registerCodexAccountHandlersMock,
registerAgentHookHandlersMock,
registerAgentTrustHandlersMock,
@@ -90,6 +91,7 @@ const {
registerRuntimeEnvironmentHandlersMock: vi.fn(),
registerEphemeralVmHandlersMock: vi.fn(),
registerAiVaultHandlersMock: vi.fn(),
registerOrcaProfileHandlersMock: vi.fn(),
registerCodexAccountHandlersMock: vi.fn(),
registerAgentHookHandlersMock: vi.fn(),
registerAgentTrustHandlersMock: vi.fn(),
@@ -283,6 +285,10 @@ vi.mock('./ai-vault', () => ({
registerAiVaultHandlers: registerAiVaultHandlersMock
}))
vi.mock('./orca-profiles', () => ({
registerOrcaProfileHandlers: registerOrcaProfileHandlersMock
}))
vi.mock('./codex-accounts', () => ({
registerCodexAccountHandlers: registerCodexAccountHandlersMock
}))
@@ -378,6 +384,7 @@ describe('registerCoreHandlers', () => {
registerRuntimeEnvironmentHandlersMock.mockReset()
registerEphemeralVmHandlersMock.mockReset()
registerAiVaultHandlersMock.mockReset()
registerOrcaProfileHandlersMock.mockReset()
registerCodexAccountHandlersMock.mockReset()
registerAgentHookHandlersMock.mockReset()
registerAgentTrustHandlersMock.mockReset()
@@ -476,6 +483,7 @@ describe('registerCoreHandlers', () => {
expect(registerWorkspacePortHandlersMock).toHaveBeenCalledWith(store)
expect(registerLocalhostWorktreeLabelHandlersMock).toHaveBeenCalledWith(store)
expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store)
expect(registerOrcaProfileHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch })
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
expect(registerEmulatorFrameStreamHandlersMock).toHaveBeenCalled()
+4
View File
@@ -50,6 +50,7 @@ import { registerUIHandlers, setTrustedUIRendererWebContentsId } from './ui'
import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream'
import { registerEmulatorVideoStreamHandlers } from './emulator-video-stream'
import { registerSpeechHandlers } from './speech'
import { registerOrcaProfileHandlers } from './orca-profiles'
import { registerCodexAccountHandlers } from './codex-accounts'
import { registerAgentHookHandlers } from './agent-hooks'
import { registerAgentTrustHandlers } from './agent-trust'
@@ -156,6 +157,9 @@ export function registerCoreHandlers(
registerKeybindingHandlers(keybindings)
}
registerTelemetryHandlers(store)
registerOrcaProfileHandlers(store, {
onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch
})
registerBrowserHandlers()
registerShellHandlers()
registerPetHandlers()
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'
import {
allowsPlaintextOrcaCloudSession,
getOrcaCloudAuthConfig,
isOrcaCloudDevAuthEnabled
} from './profile-cloud-auth-config'
vi.mock('electron', () => ({
app: {
isPackaged: false
}
}))
describe('Orca cloud auth config', () => {
it('reports unconfigured without both API URL and client ID', () => {
expect(getOrcaCloudAuthConfig({})).toEqual({
configured: false,
setupMessage: 'Orca Cloud sign-in is not configured for this build.'
})
})
it('builds default desktop auth endpoints from the API URL', () => {
const state = getOrcaCloudAuthConfig({
ORCA_CLOUD_API_URL: 'https://orca-cloud.example/',
ORCA_CLOUD_CLIENT_ID: 'desktop-client'
})
expect(state).toEqual({
configured: true,
config: {
apiBaseUrl: 'https://orca-cloud.example',
authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize',
sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session',
refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh',
capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities',
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
})
})
it('allows loopback HTTP endpoints for local desktop auth development', () => {
const state = getOrcaCloudAuthConfig({
ORCA_CLOUD_API_URL: 'http://localhost:4100',
ORCA_CLOUD_CLIENT_ID: 'desktop-client'
})
expect(state.configured).toBe(true)
})
it('rejects loopback HTTP endpoints in packaged builds', () => {
expect(
getOrcaCloudAuthConfig(
{
ORCA_CLOUD_API_URL: 'http://localhost:4100',
ORCA_CLOUD_CLIENT_ID: 'desktop-client'
},
true
)
).toMatchObject({ configured: false })
const httpsState = getOrcaCloudAuthConfig(
{
ORCA_CLOUD_API_URL: 'https://orca-cloud.example',
ORCA_CLOUD_CLIENT_ID: 'desktop-client'
},
true
)
expect(httpsState.configured).toBe(true)
})
it('rejects non-HTTPS non-loopback API URLs', () => {
expect(
getOrcaCloudAuthConfig({
ORCA_CLOUD_API_URL: 'http://orca-cloud.example',
ORCA_CLOUD_CLIENT_ID: 'desktop-client'
})
).toMatchObject({ configured: false })
})
it('allows dev plaintext sessions only outside production', () => {
expect(
allowsPlaintextOrcaCloudSession({
ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1',
NODE_ENV: 'development'
})
).toBe(true)
expect(
allowsPlaintextOrcaCloudSession({
ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1',
NODE_ENV: 'production'
})
).toBe(false)
})
it('ignores dev flags in packaged builds even without NODE_ENV', () => {
// Why: packaged main bundles never define NODE_ENV, so packaged-ness must
// gate the escape hatches on its own.
expect(allowsPlaintextOrcaCloudSession({ ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION: '1' }, true)).toBe(
false
)
expect(isOrcaCloudDevAuthEnabled({ ORCA_CLOUD_DEV_AUTH: '1' }, true)).toBe(false)
})
it('allows local dev auth only outside production', () => {
expect(
isOrcaCloudDevAuthEnabled({
ORCA_CLOUD_DEV_AUTH: '1',
NODE_ENV: 'development'
})
).toBe(true)
expect(
isOrcaCloudDevAuthEnabled({
ORCA_CLOUD_DEV_AUTH: '1',
NODE_ENV: 'production'
})
).toBe(false)
})
})
@@ -0,0 +1,115 @@
import { app } from 'electron'
export type OrcaCloudAuthConfig = {
apiBaseUrl: string
authorizeEndpoint: string
sessionEndpoint: string
refreshEndpoint: string
capabilitiesEndpoint: string
profileEndpoint: string
orgEndpoint: string
logoutEndpoint: string
clientId: string
scope: string
}
const DEFAULT_SCOPE = 'openid profile email offline_access'
// Why: packaged main bundles never define NODE_ENV, so packaged-ness is the
// only reliable production signal for gating dev-only auth escape hatches.
function isPackagedOrcaBuild(): boolean {
try {
return app?.isPackaged === true
} catch {
return false
}
}
function cleanUrl(value: string | undefined, allowLoopbackHttp: boolean): string | null {
const trimmed = value?.trim()
if (!trimmed) {
return null
}
try {
const parsed = new URL(trimmed)
const loopbackHost =
parsed.hostname === '127.0.0.1' ||
parsed.hostname === 'localhost' ||
parsed.hostname === '[::1]'
if (parsed.protocol !== 'https:' && !(loopbackHost && allowLoopbackHttp)) {
return null
}
return parsed.toString().replace(/\/$/, '')
} catch {
return null
}
}
function endpoint(baseUrl: string, path: string): string {
return new URL(path, `${baseUrl}/`).toString()
}
export function getOrcaCloudAuthConfig(
env: NodeJS.ProcessEnv = process.env,
packaged: boolean = isPackagedOrcaBuild()
): { configured: true; config: OrcaCloudAuthConfig } | { configured: false; setupMessage: string } {
// Why: loopback HTTP endpoints are a local-development convenience only;
// packaged builds must not accept plain-HTTP token endpoints via env vars.
const allowLoopbackHttp = !packaged
const cleanEndpointUrl = (value: string | undefined): string | null =>
cleanUrl(value, allowLoopbackHttp)
const apiBaseUrl = cleanEndpointUrl(env.ORCA_CLOUD_API_URL)
const clientId = env.ORCA_CLOUD_CLIENT_ID?.trim()
if (!apiBaseUrl || !clientId) {
return {
configured: false,
setupMessage: 'Orca Cloud sign-in is not configured for this build.'
}
}
const authBaseUrl = cleanEndpointUrl(env.ORCA_CLOUD_AUTH_URL) ?? apiBaseUrl
return {
configured: true,
config: {
apiBaseUrl,
authorizeEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_AUTHORIZE_URL) ??
endpoint(authBaseUrl, '/v1/desktop/auth/authorize'),
sessionEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_SESSION_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/session'),
refreshEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_REFRESH_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/refresh'),
capabilitiesEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_CAPABILITIES_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/capabilities'),
profileEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_PROFILE_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/profile'),
orgEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_ORG_URL) ?? endpoint(apiBaseUrl, '/v1/desktop/auth/org'),
logoutEndpoint:
cleanEndpointUrl(env.ORCA_CLOUD_LOGOUT_URL) ??
endpoint(apiBaseUrl, '/v1/desktop/auth/logout'),
clientId,
scope: env.ORCA_CLOUD_AUTH_SCOPE?.trim() || DEFAULT_SCOPE
}
}
}
export function allowsPlaintextOrcaCloudSession(
env: NodeJS.ProcessEnv = process.env,
packaged: boolean = isPackagedOrcaBuild()
): boolean {
return (
env.ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION === '1' && env.NODE_ENV !== 'production' && !packaged
)
}
export function isOrcaCloudDevAuthEnabled(
env: NodeJS.ProcessEnv = process.env,
packaged: boolean = isPackagedOrcaBuild()
): boolean {
return env.ORCA_CLOUD_DEV_AUTH === '1' && env.NODE_ENV !== 'production' && !packaged
}
@@ -0,0 +1,56 @@
import type { OrcaProfileAuthStatus } from '../../shared/orca-profiles'
import type { ActiveOrcaProfileState } from './profile-index-store'
import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config'
import { readOrcaCloudSession } from './profile-cloud-session-store'
export function getOrcaProfileAuthStatusFromProfile(
active: ActiveOrcaProfileState,
userDataPath: string
): OrcaProfileAuthStatus {
const configState = getOrcaCloudAuthConfig()
const devAuthEnabled = isOrcaCloudDevAuthEnabled()
const configured = configState.configured || devAuthEnabled
const cloud = active.profile.cloud
if (!cloud) {
return {
activeProfileId: active.profile.id,
configured,
state: configured ? 'local' : 'unconfigured',
persistence: 'none',
setupMessage: configured ? undefined : configState.setupMessage
}
}
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (!configured) {
return {
activeProfileId: active.profile.id,
configured: false,
state: 'unconfigured',
persistence: session.status === 'found' ? session.persistence : 'none',
cloud,
credentialError: session.status === 'decrypt-failed' ? session.error : undefined,
setupMessage: configState.setupMessage
}
}
if (session.status === 'found') {
return {
activeProfileId: active.profile.id,
configured,
state: 'connected',
persistence: session.persistence,
cloud,
organizations: session.session.organizations,
capabilities: session.session.capabilities
}
}
return {
activeProfileId: active.profile.id,
configured,
state: 'reconnect-required',
persistence: 'none',
cloud,
credentialError: session.status === 'decrypt-failed' ? session.error : undefined
}
}
@@ -0,0 +1,249 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import {
createOrcaCloudProfile,
exchangeOrcaCloudAuthCode,
refreshOrcaCloudCapabilities,
refreshOrcaCloudSession,
selectOrcaCloudOrg
} from './profile-cloud-client'
const fetchMock = vi.fn()
const config: OrcaCloudAuthConfig = {
apiBaseUrl: 'https://orca-cloud.example',
authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize',
sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session',
refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh',
capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities',
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
const session: OrcaCloudSession = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: 999,
capabilities: { flags: { share: true }, refreshedAt: 1 }
}
function mockFetchJson(value: unknown): void {
fetchMock.mockResolvedValue({
ok: true,
json: async () => value
})
}
describe('Orca cloud client', () => {
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
it('normalizes session exchange organizations', async () => {
mockFetchJson({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: 999,
cloud: {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com'
},
organizations: [
{ orgId: 'org-1', name: 'Acme', role: 'Admin' },
{ orgId: '', name: 'Ignored' }
],
capabilities: {
flags: { share: true },
refreshedAt: 123
}
})
await expect(
exchangeOrcaCloudAuthCode(config, {
code: 'code',
codeVerifier: 'verifier',
nonce: 'nonce',
redirectUri: 'http://127.0.0.1:4100/auth/callback',
state: 'state',
localProfileId: 'local-default'
})
).resolves.toMatchObject({
organizations: [{ orgId: 'org-1', name: 'Acme', role: 'Admin' }]
})
expect(fetchMock).toHaveBeenCalledWith(
config.sessionEndpoint,
expect.objectContaining({
body: JSON.stringify({
code: 'code',
codeVerifier: 'verifier',
nonce: 'nonce',
redirectUri: 'http://127.0.0.1:4100/auth/callback',
state: 'state',
localProfileId: 'local-default'
})
})
)
})
it('normalizes organization selection response metadata', async () => {
mockFetchJson({
cloud: {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
activeOrgId: 'org-2',
activeOrgName: 'Personal'
},
organizations: [
{ orgId: 'org-1', name: 'Acme' },
{ orgId: 'org-2', name: 'Personal' }
],
capabilities: {
flags: { share: false, sso: true },
refreshedAt: 456
}
})
await expect(selectOrcaCloudOrg(config, session, 'org-2')).resolves.toEqual({
cloud: expect.objectContaining({ activeOrgId: 'org-2', activeOrgName: 'Personal' }),
organizations: [
{ orgId: 'org-1', name: 'Acme', role: undefined },
{ orgId: 'org-2', name: 'Personal', role: undefined }
],
capabilities: { flags: { share: false, sso: true }, refreshedAt: 456 }
})
expect(fetchMock).toHaveBeenCalledWith(
config.orgEndpoint,
expect.objectContaining({
headers: expect.objectContaining({ authorization: 'Bearer access-token' })
})
)
})
it('creates cloud profiles with a profile-scoped session response', async () => {
mockFetchJson({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: 1000,
cloud: {
cloudProfileId: 'cloud-profile-2',
userId: 'user-1',
email: 'nina@example.com',
activeOrgId: 'org-1',
activeOrgName: 'Acme'
},
organizations: [{ orgId: 'org-1', name: 'Acme' }],
capabilities: {
flags: { share: true },
refreshedAt: 789
}
})
await expect(
createOrcaCloudProfile(config, session, { orgId: 'org-1', name: 'Acme' })
).resolves.toMatchObject({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-2' }),
organizations: [{ orgId: 'org-1', name: 'Acme', role: undefined }]
})
expect(fetchMock).toHaveBeenCalledWith(
config.profileEndpoint,
expect.objectContaining({
body: JSON.stringify({ orgId: 'org-1', name: 'Acme' })
})
)
})
it('refreshes session material without exposing refresh tokens in URLs', async () => {
mockFetchJson({
accessToken: 'rotated-access-token',
refreshToken: 'rotated-refresh-token',
expiresAt: 2000,
cloud: {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com'
},
capabilities: {
flags: { share: true },
refreshedAt: 999
}
})
await expect(refreshOrcaCloudSession(config, session)).resolves.toMatchObject({
accessToken: 'rotated-access-token',
refreshToken: 'rotated-refresh-token'
})
expect(fetchMock).toHaveBeenCalledWith(
config.refreshEndpoint,
expect.objectContaining({
body: JSON.stringify({ refreshToken: 'refresh-token' })
})
)
})
it('refreshes capability flags and optional org metadata with the current access token', async () => {
mockFetchJson({
cloud: {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com'
},
organizations: [],
capabilities: {
flags: { share: false, team: true },
refreshedAt: 1001
}
})
await expect(refreshOrcaCloudCapabilities(config, session)).resolves.toEqual({
cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-1' }),
organizations: [],
capabilities: {
flags: { share: false, team: true },
refreshedAt: 1001
}
})
expect(fetchMock).toHaveBeenCalledWith(
config.capabilitiesEndpoint,
expect.objectContaining({
headers: expect.objectContaining({ authorization: 'Bearer access-token' })
})
)
})
it('trims cloud metadata and drops blank active org fields', async () => {
mockFetchJson({
cloud: {
cloudProfileId: ' cloud-profile-1 ',
userId: ' user-1 ',
email: ' nina@example.com ',
displayName: ' Nina ',
activeOrgId: ' ',
activeOrgName: ''
},
capabilities: {
flags: {},
refreshedAt: 1002
}
})
await expect(refreshOrcaCloudCapabilities(config, session)).resolves.toMatchObject({
cloud: {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
displayName: 'Nina',
activeOrgId: undefined,
activeOrgName: undefined
}
})
})
})
@@ -0,0 +1,263 @@
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaProfileCloudSummary
} from '../../shared/orca-profiles'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
type ExchangeCodeArgs = {
code: string
codeVerifier: string
nonce: string
redirectUri: string
state: string
localProfileId: string
}
type CreateCloudProfileArgs = {
orgId?: string
name?: string
}
type SelectOrgResponse = {
cloud: OrcaProfileCloudSummary
organizations?: OrcaCloudOrgSummary[]
capabilities: OrcaCloudCapabilities
}
type CapabilityRefreshResponse = {
cloud?: OrcaProfileCloudSummary
organizations?: OrcaCloudOrgSummary[]
capabilities: OrcaCloudCapabilities
}
export class OrcaCloudRequestError extends Error {
// Why: `errorCode` carries the server's JSON `{error}` discriminator (e.g.
// 'already_member', 'cannot_remove_self') so callers can distinguish the
// precise 4xx cause without re-reading the response body.
constructor(
public readonly statusCode: number,
public readonly errorCode?: string
) {
super(`orca_cloud_request_failed_${statusCode}`)
this.name = 'OrcaCloudRequestError'
}
}
function assertString(value: unknown, field: string): string {
if (typeof value !== 'string') {
throw new Error(`invalid_orca_cloud_${field}`)
}
const trimmed = value.trim()
if (!trimmed) {
throw new Error(`invalid_orca_cloud_${field}`)
}
return trimmed
}
function optionalString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
return trimmed || undefined
}
function assertNumber(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
throw new Error(`invalid_orca_cloud_${field}`)
}
return value
}
function normalizeCapabilities(value: unknown): OrcaCloudCapabilities {
if (!value || typeof value !== 'object') {
return { flags: {}, refreshedAt: Date.now() }
}
const record = value as Record<string, unknown>
const rawFlags = record.flags
const flags: Record<string, boolean> = {}
if (rawFlags && typeof rawFlags === 'object' && !Array.isArray(rawFlags)) {
for (const [key, flag] of Object.entries(rawFlags)) {
if (typeof flag === 'boolean') {
flags[key] = flag
}
}
}
return {
flags,
refreshedAt:
typeof record.refreshedAt === 'number' && Number.isFinite(record.refreshedAt)
? record.refreshedAt
: Date.now()
}
}
function normalizeOrganizations(value: unknown): OrcaCloudOrgSummary[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const organizations: OrcaCloudOrgSummary[] = []
for (const item of value) {
if (!item || typeof item !== 'object') {
continue
}
const record = item as Record<string, unknown>
if (typeof record.orgId !== 'string' || typeof record.name !== 'string') {
continue
}
const orgId = record.orgId.trim()
const name = record.name.trim()
if (!orgId || !name) {
continue
}
organizations.push({
orgId,
name,
role: typeof record.role === 'string' && record.role.trim() ? record.role.trim() : undefined
})
}
return organizations
}
function normalizeCloudSummary(value: unknown): OrcaProfileCloudSummary {
if (!value || typeof value !== 'object') {
throw new Error('invalid_orca_cloud_profile')
}
const record = value as Record<string, unknown>
return {
cloudProfileId: assertString(record.cloudProfileId, 'profile_id'),
userId: assertString(record.userId, 'user_id'),
email: assertString(record.email, 'email'),
displayName: optionalString(record.displayName),
activeOrgId: optionalString(record.activeOrgId),
activeOrgName: optionalString(record.activeOrgName),
linkedAt:
typeof record.linkedAt === 'number' && Number.isFinite(record.linkedAt)
? record.linkedAt
: Date.now()
}
}
function normalizeSessionResponse(value: unknown): OrcaCloudSessionExchangeResponse {
if (!value || typeof value !== 'object') {
throw new Error('invalid_orca_cloud_session')
}
const record = value as Record<string, unknown>
return {
accessToken: assertString(record.accessToken, 'access_token'),
refreshToken: assertString(record.refreshToken, 'refresh_token'),
expiresAt: assertNumber(record.expiresAt, 'expires_at'),
cloud: normalizeCloudSummary(record.cloud),
organizations: normalizeOrganizations(record.organizations),
capabilities: normalizeCapabilities(record.capabilities)
}
}
const CLOUD_REQUEST_TIMEOUT_MS = 30_000
async function postJson<T>(url: string, body: unknown, accessToken?: string): Promise<T> {
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {})
},
body: JSON.stringify(body),
// Why: these are fixed first-party token endpoints; following a redirect
// would re-send refresh tokens/code verifiers to another origin, and a
// stalled server must not hang the renderer's awaited IPC call forever.
redirect: 'error',
signal: AbortSignal.timeout(CLOUD_REQUEST_TIMEOUT_MS)
})
if (!response.ok) {
throw new OrcaCloudRequestError(response.status)
}
return (await response.json()) as T
}
export async function exchangeOrcaCloudAuthCode(
config: OrcaCloudAuthConfig,
args: ExchangeCodeArgs
): Promise<OrcaCloudSessionExchangeResponse> {
return normalizeSessionResponse(
await postJson(config.sessionEndpoint, {
code: args.code,
codeVerifier: args.codeVerifier,
nonce: args.nonce,
redirectUri: args.redirectUri,
state: args.state,
localProfileId: args.localProfileId
})
)
}
export async function refreshOrcaCloudCapabilities(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession
): Promise<CapabilityRefreshResponse> {
const response = await postJson<{
cloud?: unknown
organizations?: unknown
capabilities: unknown
}>(config.capabilitiesEndpoint, {}, session.accessToken)
return {
cloud: response.cloud === undefined ? undefined : normalizeCloudSummary(response.cloud),
organizations: normalizeOrganizations(response.organizations),
capabilities: normalizeCapabilities(response.capabilities)
}
}
export async function refreshOrcaCloudSession(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession
): Promise<OrcaCloudSessionExchangeResponse> {
return normalizeSessionResponse(
await postJson(config.refreshEndpoint, {
refreshToken: session.refreshToken
})
)
}
export async function createOrcaCloudProfile(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
args: CreateCloudProfileArgs
): Promise<OrcaCloudSessionExchangeResponse> {
return normalizeSessionResponse(
await postJson(
config.profileEndpoint,
{
orgId: args.orgId,
name: args.name
},
session.accessToken
)
)
}
export async function selectOrcaCloudOrg(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
orgId: string
): Promise<SelectOrgResponse> {
const response = await postJson<{
cloud: unknown
organizations?: unknown
capabilities: unknown
}>(config.orgEndpoint, { orgId }, session.accessToken)
return {
cloud: normalizeCloudSummary(response.cloud),
organizations: normalizeOrganizations(response.organizations),
capabilities: normalizeCapabilities(response.capabilities)
}
}
export async function revokeOrcaCloudSession(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession
): Promise<void> {
await postJson(config.logoutEndpoint, { refreshToken: session.refreshToken }, session.accessToken)
}
@@ -0,0 +1,67 @@
import { randomUUID } from 'node:crypto'
import type { OrcaCloudCapabilities, OrcaCloudOrgSummary } from '../../shared/orca-profiles'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
const DEV_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
function cleanEnvString(value: string | undefined, fallback: string): string {
const trimmed = value?.trim()
return trimmed || fallback
}
function defaultDevOrganizations(): OrcaCloudOrgSummary[] {
return [
{ orgId: 'dev-personal', name: 'Personal', role: 'Owner' },
{ orgId: 'dev-acme', name: 'Acme Dev', role: 'Admin' }
]
}
function devCapabilities(): OrcaCloudCapabilities {
return {
flags: {
share: true,
team: true,
'share.create': true,
'share.manage': true,
'relay.use': true,
'team.member': true,
'enterprise.sso': true
},
refreshedAt: Date.now()
}
}
function devToken(prefix: string): string {
return `${prefix}-${randomUUID()}`
}
export function createDevOrcaCloudSession(
args: {
localProfileId?: string
cloudProfileId?: string
orgId?: string
} = {}
): OrcaCloudSessionExchangeResponse {
const organizations = defaultDevOrganizations()
const selectedOrg = organizations.find((organization) => organization.orgId === args.orgId)
const cloudProfileId =
args.cloudProfileId ??
(args.localProfileId ? `dev-cloud-${args.localProfileId}` : `dev-cloud-${randomUUID()}`)
return {
accessToken: devToken('dev-access'),
refreshToken: devToken('dev-refresh'),
expiresAt: Date.now() + DEV_SESSION_TTL_MS,
cloud: {
cloudProfileId,
userId: cleanEnvString(process.env.ORCA_CLOUD_DEV_USER_ID, 'dev-user'),
email: cleanEnvString(process.env.ORCA_CLOUD_DEV_EMAIL, 'dev@orca.local'),
displayName: cleanEnvString(process.env.ORCA_CLOUD_DEV_DISPLAY_NAME, 'Orca Dev'),
activeOrgId: selectedOrg?.orgId,
activeOrgName: selectedOrg?.name,
linkedAt: Date.now()
},
organizations,
capabilities: devCapabilities()
}
}
@@ -0,0 +1,130 @@
import type {
OrcaOrgMember,
OrcaOrgMembersRoster,
OrcaOrgPendingInvite,
OrcaProfileOrgMemberChangeRoleArgs,
OrcaProfileOrgMemberInviteArgs,
OrcaProfileOrgMemberMutationResult,
OrcaProfileOrgMemberRemoveArgs,
OrcaProfileOrgInviteRevokeArgs
} from '../../shared/orca-profiles'
// Why: dev-auth mode has no server, so the whole teammate UI is exercised
// against this in-memory per-org roster. It mirrors the shape the real client
// returns (self as owner, one signed-in teammate, one never-signed-in teammate,
// one pending invite) and the mutation endpoints' status semantics.
type DevOrgRoster = {
members: OrcaOrgMember[]
pendingInvites: OrcaOrgPendingInvite[]
}
const devRostersByOrg = new Map<string, DevOrgRoster>()
function cleanEnvString(value: string | undefined, fallback: string): string {
const trimmed = value?.trim()
return trimmed || fallback
}
function devSelf(): OrcaOrgMember {
return {
userId: cleanEnvString(process.env.ORCA_CLOUD_DEV_USER_ID, 'dev-user'),
email: cleanEnvString(process.env.ORCA_CLOUD_DEV_EMAIL, 'dev@orca.local'),
displayName: cleanEnvString(process.env.ORCA_CLOUD_DEV_DISPLAY_NAME, 'Orca Dev'),
role: 'owner'
}
}
function seedDevRoster(): DevOrgRoster {
return {
members: [
devSelf(),
{
userId: 'dev-teammate-1',
email: 'teammate@orca.local',
displayName: 'Dev Teammate',
role: 'admin'
},
// Why: userId null exercises the "hasn't signed in to Orca yet" disabled row.
{ userId: null, email: 'invited-member@orca.local', displayName: undefined, role: 'member' }
],
pendingInvites: [{ email: 'pending@orca.local', role: 'member', createdAt: Date.now() }]
}
}
function getDevRoster(orgId: string): DevOrgRoster {
const existing = devRostersByOrg.get(orgId)
if (existing) {
return existing
}
const seeded = seedDevRoster()
devRostersByOrg.set(orgId, seeded)
return seeded
}
export function listDevOrcaCloudOrgMembers(orgId: string): OrcaOrgMembersRoster {
const roster = getDevRoster(orgId)
return {
members: roster.members.map((member) => ({ ...member })),
pendingInvites: roster.pendingInvites.map((invite) => ({ ...invite })),
viewerRole: 'owner',
canManageMembers: true
}
}
export function inviteDevOrcaCloudOrgMember(
args: OrcaProfileOrgMemberInviteArgs
): OrcaProfileOrgMemberMutationResult {
const roster = getDevRoster(args.orgId)
const email = args.email.toLowerCase()
if (roster.members.some((member) => member.email.toLowerCase() === email)) {
return { status: 'conflict', reason: 'already_member' }
}
if (roster.pendingInvites.some((invite) => invite.email.toLowerCase() === email)) {
return { status: 'conflict', reason: 'already_invited' }
}
roster.pendingInvites.push({ email: args.email, role: args.role, createdAt: Date.now() })
return { status: 'ok' }
}
export function revokeDevOrcaCloudOrgInvite(
args: OrcaProfileOrgInviteRevokeArgs
): OrcaProfileOrgMemberMutationResult {
const roster = getDevRoster(args.orgId)
const email = args.email.toLowerCase()
const index = roster.pendingInvites.findIndex((invite) => invite.email.toLowerCase() === email)
if (index === -1) {
return { status: 'not-found' }
}
roster.pendingInvites.splice(index, 1)
return { status: 'ok' }
}
export function changeDevOrcaCloudOrgMemberRole(
args: OrcaProfileOrgMemberChangeRoleArgs
): OrcaProfileOrgMemberMutationResult {
const roster = getDevRoster(args.orgId)
if (args.userId === devSelf().userId) {
return { status: 'invalid', reason: 'cannot_change_own_role' }
}
const member = roster.members.find((candidate) => candidate.userId === args.userId)
if (!member) {
return { status: 'not-found' }
}
member.role = args.role
return { status: 'ok' }
}
export function removeDevOrcaCloudOrgMember(
args: OrcaProfileOrgMemberRemoveArgs
): OrcaProfileOrgMemberMutationResult {
const roster = getDevRoster(args.orgId)
if (args.userId === devSelf().userId) {
return { status: 'invalid', reason: 'cannot_remove_self' }
}
const index = roster.members.findIndex((candidate) => candidate.userId === args.userId)
if (index === -1) {
return { status: 'not-found' }
}
roster.members.splice(index, 1)
return { status: 'ok' }
}
@@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const {
beginOrcaCloudPkceFlowMock,
exchangeOrcaCloudAuthCodeMock,
revokeOrcaCloudSessionMock,
safeStorageMock
} = vi.hoisted(() => ({
beginOrcaCloudPkceFlowMock: vi.fn(),
exchangeOrcaCloudAuthCodeMock: vi.fn(),
revokeOrcaCloudSessionMock: vi.fn(),
safeStorageMock: {
decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
isEncryptionAvailable: vi.fn(() => true)
}
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: {
getPath: () => userDataPath
},
safeStorage: safeStorageMock
}))
vi.mock('./profile-cloud-pkce', () => ({
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
}))
vi.mock('./profile-cloud-client', () => ({
createOrcaCloudProfile: vi.fn(),
exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock,
refreshOrcaCloudCapabilities: vi.fn(),
refreshOrcaCloudSession: vi.fn(),
revokeOrcaCloudSession: revokeOrcaCloudSessionMock,
selectOrcaCloudOrg: vi.fn()
}))
import {
connectCurrentOrcaProfile,
createCloudLinkedOrcaProfile,
getCurrentOrcaProfileAuthStatus,
selectCurrentOrcaProfileOrg,
signOutCurrentOrcaProfile
} from './profile-cloud-service'
describe('Orca cloud dev auth service', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-dev-auth-'))
beginOrcaCloudPkceFlowMock.mockReset()
exchangeOrcaCloudAuthCodeMock.mockReset()
revokeOrcaCloudSessionMock.mockReset()
safeStorageMock.decryptString.mockReset()
safeStorageMock.encryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8'))
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
vi.unstubAllEnvs()
vi.stubEnv('NODE_ENV', 'development')
vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '1')
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('connects the active profile without PKCE or cloud endpoints', async () => {
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
configured: true,
state: 'local'
})
const result = await connectCurrentOrcaProfile(userDataPath)
expect(result.status).toBe('connected')
expect(beginOrcaCloudPkceFlowMock).not.toHaveBeenCalled()
expect(exchangeOrcaCloudAuthCodeMock).not.toHaveBeenCalled()
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
configured: true,
state: 'connected',
persistence: 'encrypted',
cloud: {
cloudProfileId: 'dev-cloud-local-default',
email: 'dev@orca.local'
},
capabilities: {
flags: expect.objectContaining({ 'share.create': true })
}
})
expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toHaveLength(2)
})
it('selects dev organizations and creates org-scoped cloud profiles locally', async () => {
await connectCurrentOrcaProfile(userDataPath)
const selected = await selectCurrentOrcaProfileOrg(userDataPath, 'dev-acme')
const created = await createCloudLinkedOrcaProfile(userDataPath, {
orgId: 'dev-acme',
name: 'Acme Dev'
})
expect(selected.status).toBe('selected')
expect(getCurrentOrcaProfileAuthStatus(userDataPath).cloud).toMatchObject({
activeOrgId: 'dev-acme',
activeOrgName: 'Acme Dev'
})
expect(created.status).toBe('created')
if (created.status === 'created') {
expect(created.profile).toMatchObject({
name: 'Acme Dev',
kind: 'cloud-linked',
cloud: expect.objectContaining({
activeOrgId: 'dev-acme',
activeOrgName: 'Acme Dev'
})
})
}
})
it('signs out locally without calling the cloud logout endpoint', async () => {
await connectCurrentOrcaProfile(userDataPath)
const result = await signOutCurrentOrcaProfile(userDataPath)
expect(result.status).toBe('signed-out')
expect(revokeOrcaCloudSessionMock).not.toHaveBeenCalled()
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
configured: true,
state: 'local',
persistence: 'none'
})
})
})
@@ -0,0 +1,92 @@
import type {
CreateCloudLinkedOrcaProfileArgs,
OrcaProfileListState
} from '../../shared/orca-profiles'
import type { ActiveOrcaProfileState } from './profile-index-store'
import { createCloudLinkedOrcaProfileRecord, linkOrcaProfileToCloud } from './profile-cloud-index'
import { readOrcaCloudSession, saveOrcaCloudSessionExchange } from './profile-cloud-session-store'
import { createDevOrcaCloudSession } from './profile-cloud-dev-auth'
type DevProfileListResult = OrcaProfileListState
type DevCreateProfileResult =
| {
status: 'created'
list: ReturnType<typeof createCloudLinkedOrcaProfileRecord>
}
| { status: 'reconnect-required' }
type DevMutationResult =
| {
status: 'updated'
list: DevProfileListResult
}
| { status: 'reconnect-required' }
export function connectDevOrcaCloudProfile(
active: ActiveOrcaProfileState,
userDataPath: string
): DevProfileListResult {
const session = createDevOrcaCloudSession({ localProfileId: active.profile.id })
saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session)
return linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath)
}
export function createDevCloudLinkedOrcaProfile(
active: ActiveOrcaProfileState,
userDataPath: string,
args: CreateCloudLinkedOrcaProfileArgs
): DevCreateProfileResult {
if (readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found') {
return { status: 'reconnect-required' }
}
const session = createDevOrcaCloudSession({ orgId: args.orgId })
const list = createCloudLinkedOrcaProfileRecord(session.cloud, { name: args.name }, userDataPath)
saveOrcaCloudSessionExchange(list.profile.id, userDataPath, session)
return { status: 'created', list }
}
export function refreshDevOrcaCloudProfile(
active: ActiveOrcaProfileState,
userDataPath: string
): DevMutationResult {
if (
!active.profile.cloud ||
readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found'
) {
return { status: 'reconnect-required' }
}
const session = createDevOrcaCloudSession({
localProfileId: active.profile.id,
cloudProfileId: active.profile.cloud.cloudProfileId,
orgId: active.profile.cloud.activeOrgId
})
saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session)
return {
status: 'updated',
list: linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath)
}
}
export function selectDevOrcaCloudOrg(
active: ActiveOrcaProfileState,
userDataPath: string,
orgId: string
): DevMutationResult {
if (
!active.profile.cloud ||
readOrcaCloudSession(active.profile.id, userDataPath).status !== 'found'
) {
return { status: 'reconnect-required' }
}
const session = createDevOrcaCloudSession({
localProfileId: active.profile.id,
cloudProfileId: active.profile.cloud.cloudProfileId,
orgId
})
saveOrcaCloudSessionExchange(active.profile.id, userDataPath, session)
return {
status: 'updated',
list: linkOrcaProfileToCloud(active.profile.id, session.cloud, userDataPath)
}
}
@@ -0,0 +1,143 @@
import { randomUUID } from 'node:crypto'
import { mkdirSync } from 'node:fs'
import type {
OrcaProfileCloudSummary,
OrcaProfileListState,
OrcaProfileSummary
} from '../../shared/orca-profiles'
import {
getOrcaProfileDirectory,
getOrcaProfileIndexPath,
loadOrCreateProfileIndex,
writeProfileIndex
} from './profile-index-store'
export type CreateCloudLinkedOrcaProfileRecordResult = OrcaProfileListState & {
profile: OrcaProfileSummary
}
function sanitizeProfileName(value: unknown, fallback: string): string {
const trimmed = typeof value === 'string' ? value.trim() : ''
return (trimmed || fallback).slice(0, 80)
}
function profileInitial(name: string): string {
return (name.match(/[A-Za-z0-9]/)?.[0] ?? 'C').toUpperCase()
}
function toCloudLinkedProfile(
profile: OrcaProfileSummary,
cloud: OrcaProfileCloudSummary,
now: number
): OrcaProfileSummary {
return {
...profile,
kind: 'cloud-linked',
cloud,
updatedAt: now,
lastOpenedAt: now
}
}
function toLocalProfile(profile: OrcaProfileSummary, now: number): OrcaProfileSummary {
const { cloud: _cloud, ...localProfile } = profile
return {
...localProfile,
kind: 'local',
updatedAt: now,
lastOpenedAt: now
}
}
export function createCloudLinkedOrcaProfileRecord(
cloud: OrcaProfileCloudSummary,
args: { name?: string },
userDataPath: string
): CreateCloudLinkedOrcaProfileRecordResult {
const index = loadOrCreateProfileIndex(userDataPath)
const now = Date.now()
const fallbackName = cloud.activeOrgName ?? cloud.displayName ?? cloud.email
const name = sanitizeProfileName(args.name, fallbackName)
const profile: OrcaProfileSummary = {
id: `cloud-${randomUUID()}`,
name,
avatar: {
kind: 'initials',
initials: profileInitial(name),
color: 'neutral'
},
kind: 'cloud-linked',
createdAt: now,
updatedAt: now,
lastOpenedAt: now,
cloud
}
const nextIndex = {
...index,
profiles: [...index.profiles, profile]
}
mkdirSync(getOrcaProfileDirectory(profile.id, userDataPath), { recursive: true })
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles,
profile
}
}
export function linkOrcaProfileToCloud(
profileId: string,
cloud: OrcaProfileCloudSummary,
userDataPath: string
): OrcaProfileListState {
const index = loadOrCreateProfileIndex(userDataPath)
const now = Date.now()
let found = false
const profiles = index.profiles.map((profile) => {
if (profile.id !== profileId) {
return profile
}
found = true
return toCloudLinkedProfile(profile, cloud, now)
})
if (!found) {
throw new Error('unknown_orca_profile')
}
const nextIndex = {
...index,
profiles
}
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles
}
}
export function unlinkOrcaProfileFromCloud(
profileId: string,
userDataPath: string
): OrcaProfileListState {
const index = loadOrCreateProfileIndex(userDataPath)
const now = Date.now()
let found = false
const profiles = index.profiles.map((profile) => {
if (profile.id !== profileId) {
return profile
}
found = true
return toLocalProfile(profile, now)
})
if (!found) {
throw new Error('unknown_orca_profile')
}
const nextIndex = {
...index,
profiles
}
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles
}
}
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import { OrcaCloudRequestError } from './profile-cloud-client'
import {
changeOrcaCloudOrgMemberRole,
inviteOrcaCloudOrgMember,
listOrcaCloudOrgMembers,
removeOrcaCloudOrgMember,
revokeOrcaCloudOrgInvite
} from './profile-cloud-org-members-client'
const fetchMock = vi.fn()
const config: OrcaCloudAuthConfig = {
apiBaseUrl: 'https://orca-cloud.example',
authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize',
sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session',
refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh',
capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities',
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
const session: OrcaCloudSession = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: 999,
capabilities: { flags: {}, refreshedAt: 1 }
}
function mockJsonResponse(value: unknown, init: { ok?: boolean; status?: number } = {}): void {
fetchMock.mockResolvedValue({
ok: init.ok ?? true,
status: init.status ?? 200,
json: async () => value
})
}
describe('Orca cloud org members client', () => {
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
it('normalizes the roster, dropping malformed rows and defaulting the viewer role', async () => {
mockJsonResponse({
members: [
{ userId: 'user-1', email: 'nina@example.com', displayName: 'Nina', role: 'admin' },
{ userId: null, email: 'pending-user@example.com', role: 'member' },
{ userId: 'user-3', email: 'weird@example.com', role: 'superadmin' },
{ userId: 'user-4' }
],
pendingInvites: [{ email: 'invitee@example.com', role: 'member', createdAt: 1712000000000 }],
viewerRole: 'not-a-role',
canManageMembers: true
})
await expect(listOrcaCloudOrgMembers(config, session, 'org-1')).resolves.toEqual({
members: [
{ userId: 'user-1', email: 'nina@example.com', displayName: 'Nina', role: 'admin' },
{ userId: null, email: 'pending-user@example.com', displayName: undefined, role: 'member' },
{ userId: 'user-3', email: 'weird@example.com', displayName: undefined, role: 'member' }
],
pendingInvites: [{ email: 'invitee@example.com', role: 'member', createdAt: 1712000000000 }],
viewerRole: 'member',
canManageMembers: true
})
expect(fetchMock).toHaveBeenCalledWith(
'https://orca-cloud.example/v1/desktop/orgs/org-1/members',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({ authorization: 'Bearer access-token' })
})
)
})
it('percent-encodes the org id in the request URL', async () => {
mockJsonResponse({
members: [],
pendingInvites: [],
viewerRole: 'owner',
canManageMembers: true
})
await listOrcaCloudOrgMembers(config, session, 'org/with space')
expect(fetchMock).toHaveBeenCalledWith(
'https://orca-cloud.example/v1/desktop/orgs/org%2Fwith%20space/members',
expect.any(Object)
)
})
it('posts invites with an email and role body', async () => {
mockJsonResponse({ ok: true })
await inviteOrcaCloudOrgMember(config, session, {
orgId: 'org-1',
email: 'new@example.com',
role: 'admin'
})
expect(fetchMock).toHaveBeenCalledWith(
'https://orca-cloud.example/v1/desktop/orgs/org-1/invites',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'new@example.com', role: 'admin' })
})
)
})
it('carries the server error code on a 409 conflict', async () => {
mockJsonResponse({ error: 'already_invited' }, { ok: false, status: 409 })
await expect(
inviteOrcaCloudOrgMember(config, session, {
orgId: 'org-1',
email: 'dupe@example.com',
role: 'member'
})
).rejects.toMatchObject({ statusCode: 409, errorCode: 'already_invited' })
})
it('surfaces a 403 as an OrcaCloudRequestError without an error code when the body is empty', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 403,
json: async () => {
throw new Error('no body')
}
})
await expect(
changeOrcaCloudOrgMemberRole(config, session, {
orgId: 'org-1',
userId: 'user-2',
role: 'admin'
})
).rejects.toBeInstanceOf(OrcaCloudRequestError)
await expect(
removeOrcaCloudOrgMember(config, session, { orgId: 'org-1', userId: 'user-2' })
).rejects.toMatchObject({ statusCode: 403, errorCode: undefined })
})
it('posts invite revocations by email', async () => {
mockJsonResponse({ ok: true })
await revokeOrcaCloudOrgInvite(config, session, { orgId: 'org-1', email: 'gone@example.com' })
expect(fetchMock).toHaveBeenCalledWith(
'https://orca-cloud.example/v1/desktop/orgs/org-1/invites/revoke',
expect.objectContaining({ body: JSON.stringify({ email: 'gone@example.com' }) })
)
})
})
@@ -0,0 +1,209 @@
import type {
OrcaOrgMember,
OrcaOrgMembersRoster,
OrcaOrgPendingInvite,
OrcaOrgRole
} from '../../shared/orca-profiles'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import { OrcaCloudRequestError } from './profile-cloud-client'
const CLOUD_REQUEST_TIMEOUT_MS = 30_000
const ORG_ROLES: readonly OrcaOrgRole[] = ['owner', 'admin', 'member']
function isOrgRole(value: unknown): value is OrcaOrgRole {
return typeof value === 'string' && (ORG_ROLES as readonly string[]).includes(value)
}
function normalizeRole(value: unknown, fallback: OrcaOrgRole): OrcaOrgRole {
return isOrgRole(value) ? value : fallback
}
function optionalString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
return trimmed || undefined
}
function normalizeTimestamp(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string') {
const parsed = Date.parse(value)
if (Number.isFinite(parsed)) {
return parsed
}
}
return Date.now()
}
function normalizeMember(value: unknown): OrcaOrgMember | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as Record<string, unknown>
const email = optionalString(record.email)
if (!email) {
return null
}
const userId = optionalString(record.userId)
return {
userId: userId ?? null,
email,
displayName: optionalString(record.displayName),
role: normalizeRole(record.role, 'member')
}
}
function normalizePendingInvite(value: unknown): OrcaOrgPendingInvite | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as Record<string, unknown>
const email = optionalString(record.email)
if (!email) {
return null
}
return {
email,
role: normalizeRole(record.role, 'member'),
createdAt: normalizeTimestamp(record.createdAt)
}
}
function normalizeRoster(value: unknown): OrcaOrgMembersRoster {
if (!value || typeof value !== 'object') {
throw new Error('invalid_orca_org_members_roster')
}
const record = value as Record<string, unknown>
const members = Array.isArray(record.members)
? record.members
.map(normalizeMember)
.filter((member): member is OrcaOrgMember => member !== null)
: []
const pendingInvites = Array.isArray(record.pendingInvites)
? record.pendingInvites
.map(normalizePendingInvite)
.filter((invite): invite is OrcaOrgPendingInvite => invite !== null)
: []
return {
members,
pendingInvites,
// Why: default to the least-privileged role so a malformed viewerRole can
// never widen the client-side management affordance; the server still
// enforces authorization on every mutation.
viewerRole: normalizeRole(record.viewerRole, 'member'),
canManageMembers: record.canManageMembers === true
}
}
function orgMembersUrl(config: OrcaCloudAuthConfig, orgId: string, path: string): string {
return `${config.apiBaseUrl}/v1/desktop/orgs/${encodeURIComponent(orgId)}${path}`
}
async function extractErrorCode(response: Response): Promise<string | undefined> {
try {
const body = (await response.json()) as unknown
if (
body &&
typeof body === 'object' &&
typeof (body as { error?: unknown }).error === 'string'
) {
return (body as { error: string }).error.trim() || undefined
}
} catch {
// Non-JSON error body; the status code alone drives the caller's mapping.
}
return undefined
}
// Why: these are fixed first-party endpoints bearing the profile's access token;
// following a redirect would leak that token to another origin, and a stalled
// server must not hang the renderer's awaited IPC call forever.
function requestInit(method: 'GET' | 'POST', accessToken: string, body?: unknown): RequestInit {
return {
method,
headers: {
...(body === undefined ? {} : { 'content-type': 'application/json' }),
authorization: `Bearer ${accessToken}`
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
redirect: 'error',
signal: AbortSignal.timeout(CLOUD_REQUEST_TIMEOUT_MS)
}
}
async function requestOrgMembers<T>(
url: string,
init: RequestInit,
parse: (value: unknown) => T
): Promise<T> {
const response = await fetch(url, init)
if (!response.ok) {
throw new OrcaCloudRequestError(response.status, await extractErrorCode(response))
}
return parse((await response.json()) as unknown)
}
export async function listOrcaCloudOrgMembers(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
orgId: string
): Promise<OrcaOrgMembersRoster> {
return requestOrgMembers(
orgMembersUrl(config, orgId, '/members'),
requestInit('GET', session.accessToken),
normalizeRoster
)
}
export async function inviteOrcaCloudOrgMember(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
args: { orgId: string; email: string; role: OrcaOrgRole }
): Promise<void> {
await requestOrgMembers(
orgMembersUrl(config, args.orgId, '/invites'),
requestInit('POST', session.accessToken, { email: args.email, role: args.role }),
() => undefined
)
}
export async function revokeOrcaCloudOrgInvite(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
args: { orgId: string; email: string }
): Promise<void> {
await requestOrgMembers(
orgMembersUrl(config, args.orgId, '/invites/revoke'),
requestInit('POST', session.accessToken, { email: args.email }),
() => undefined
)
}
export async function changeOrcaCloudOrgMemberRole(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
args: { orgId: string; userId: string; role: OrcaOrgRole }
): Promise<void> {
await requestOrgMembers(
orgMembersUrl(config, args.orgId, '/members/role'),
requestInit('POST', session.accessToken, { userId: args.userId, role: args.role }),
() => undefined
)
}
export async function removeOrcaCloudOrgMember(
config: OrcaCloudAuthConfig,
session: OrcaCloudSession,
args: { orgId: string; userId: string }
): Promise<void> {
await requestOrgMembers(
orgMembersUrl(config, args.orgId, '/members/remove'),
requestInit('POST', session.accessToken, { userId: args.userId }),
() => undefined
)
}
@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { OrcaOrgMembersRoster } from '../../shared/orca-profiles'
import { OrcaCloudRequestError } from './profile-cloud-client'
const {
runWithFreshOrcaCloudSessionMock,
listOrcaCloudOrgMembersMock,
inviteOrcaCloudOrgMemberMock,
revokeOrcaCloudOrgInviteMock,
changeOrcaCloudOrgMemberRoleMock,
removeOrcaCloudOrgMemberMock
} = vi.hoisted(() => ({
runWithFreshOrcaCloudSessionMock: vi.fn(),
listOrcaCloudOrgMembersMock: vi.fn(),
inviteOrcaCloudOrgMemberMock: vi.fn(),
revokeOrcaCloudOrgInviteMock: vi.fn(),
changeOrcaCloudOrgMemberRoleMock: vi.fn(),
removeOrcaCloudOrgMemberMock: vi.fn()
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: { getPath: () => userDataPath }
}))
vi.mock('./profile-cloud-session-refresh', () => ({
runWithFreshOrcaCloudSessionMock,
runWithFreshOrcaCloudSession: runWithFreshOrcaCloudSessionMock
}))
vi.mock('./profile-cloud-org-members-client', () => ({
listOrcaCloudOrgMembers: listOrcaCloudOrgMembersMock,
inviteOrcaCloudOrgMember: inviteOrcaCloudOrgMemberMock,
revokeOrcaCloudOrgInvite: revokeOrcaCloudOrgInviteMock,
changeOrcaCloudOrgMemberRole: changeOrcaCloudOrgMemberRoleMock,
removeOrcaCloudOrgMember: removeOrcaCloudOrgMemberMock
}))
import {
changeOrcaProfileOrgMemberRole,
inviteOrcaProfileOrgMember,
listOrcaProfileOrgMembers,
removeOrcaProfileOrgMember,
revokeOrcaProfileOrgInvite
} from './profile-cloud-org-members-service'
const fakeSession = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 3_600_000,
capabilities: { flags: {}, refreshedAt: 1 }
}
// Why: mirror the real contract — invoke the operation with a live session and
// surface its resolved value; business 4xx are returned by the operation as
// values, never thrown, so the session layer never sees them.
function runOperationDirectly(): void {
runWithFreshOrcaCloudSessionMock.mockImplementation(
async (
_config: unknown,
_active: unknown,
_path: unknown,
op: (session: unknown) => unknown
) => ({
status: 'ok',
value: await op(fakeSession)
})
)
}
function configureCloudEnv(): void {
vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client')
}
const roster: OrcaOrgMembersRoster = {
members: [{ userId: 'user-1', email: 'nina@example.com', role: 'owner' }],
pendingInvites: [],
viewerRole: 'owner',
canManageMembers: true
}
describe('Orca cloud org members service (configured)', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-org-members-'))
runWithFreshOrcaCloudSessionMock.mockReset()
listOrcaCloudOrgMembersMock.mockReset()
inviteOrcaCloudOrgMemberMock.mockReset()
revokeOrcaCloudOrgInviteMock.mockReset()
changeOrcaCloudOrgMemberRoleMock.mockReset()
removeOrcaCloudOrgMemberMock.mockReset()
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '')
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('reports unconfigured when cloud sign-in is not set up', async () => {
await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({
status: 'unconfigured'
})
expect(runWithFreshOrcaCloudSessionMock).not.toHaveBeenCalled()
})
it('returns the roster from the client', async () => {
configureCloudEnv()
runOperationDirectly()
listOrcaCloudOrgMembersMock.mockResolvedValue(roster)
await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({
status: 'ok',
roster
})
expect(listOrcaCloudOrgMembersMock).toHaveBeenCalledWith(
expect.any(Object),
fakeSession,
'org-1'
)
})
it('maps a 409 already_member invite conflict', async () => {
configureCloudEnv()
runOperationDirectly()
inviteOrcaCloudOrgMemberMock.mockRejectedValue(new OrcaCloudRequestError(409, 'already_member'))
await expect(
inviteOrcaProfileOrgMember(userDataPath, { orgId: 'org-1', email: 'a@b.com', role: 'member' })
).resolves.toEqual({ status: 'conflict', reason: 'already_member' })
})
it('maps a 403 role change to forbidden', async () => {
configureCloudEnv()
runOperationDirectly()
changeOrcaCloudOrgMemberRoleMock.mockRejectedValue(new OrcaCloudRequestError(403))
await expect(
changeOrcaProfileOrgMemberRole(userDataPath, {
orgId: 'org-1',
userId: 'user-2',
role: 'admin'
})
).resolves.toEqual({ status: 'forbidden' })
})
it('maps a 400 cannot_remove_self to an invalid result', async () => {
configureCloudEnv()
runOperationDirectly()
removeOrcaCloudOrgMemberMock.mockRejectedValue(
new OrcaCloudRequestError(400, 'cannot_remove_self')
)
await expect(
removeOrcaProfileOrgMember(userDataPath, { orgId: 'org-1', userId: 'user-1' })
).resolves.toEqual({ status: 'invalid', reason: 'cannot_remove_self' })
})
it('maps a 404 revoke to not-found', async () => {
configureCloudEnv()
runOperationDirectly()
revokeOrcaCloudOrgInviteMock.mockRejectedValue(new OrcaCloudRequestError(404))
await expect(
revokeOrcaProfileOrgInvite(userDataPath, { orgId: 'org-1', email: 'gone@b.com' })
).resolves.toEqual({ status: 'not-found' })
})
it('reports reconnect-required when the session layer cannot refresh', async () => {
configureCloudEnv()
runWithFreshOrcaCloudSessionMock.mockResolvedValue({ status: 'reconnect-required' })
await expect(listOrcaProfileOrgMembers(userDataPath, 'org-1')).resolves.toEqual({
status: 'reconnect-required'
})
})
})
describe('Orca cloud org members service (dev auth)', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-org-members-dev-'))
runWithFreshOrcaCloudSessionMock.mockReset()
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_DEV_AUTH', '1')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('serves an in-memory roster the caller can manage', async () => {
const result = await listOrcaProfileOrgMembers(userDataPath, 'dev-list-org')
if (result.status !== 'ok') {
throw new Error(`Expected ok, got ${result.status}`)
}
expect(result.roster.canManageMembers).toBe(true)
expect(result.roster.viewerRole).toBe('owner')
expect(result.roster.members[0]).toMatchObject({ role: 'owner' })
expect(result.roster.members.some((member) => member.userId === null)).toBe(true)
expect(result.roster.pendingInvites.length).toBeGreaterThan(0)
expect(runWithFreshOrcaCloudSessionMock).not.toHaveBeenCalled()
})
it('mutates the dev roster across invite and revoke', async () => {
const orgId = 'dev-mutate-org'
await expect(
inviteOrcaProfileOrgMember(userDataPath, {
orgId,
email: 'fresh@orca.local',
role: 'member'
})
).resolves.toEqual({ status: 'ok' })
const afterInvite = await listOrcaProfileOrgMembers(userDataPath, orgId)
if (afterInvite.status !== 'ok') {
throw new Error('expected ok')
}
expect(afterInvite.roster.pendingInvites.some((i) => i.email === 'fresh@orca.local')).toBe(true)
await expect(
inviteOrcaProfileOrgMember(userDataPath, {
orgId,
email: 'fresh@orca.local',
role: 'member'
})
).resolves.toEqual({ status: 'conflict', reason: 'already_invited' })
await expect(
revokeOrcaProfileOrgInvite(userDataPath, { orgId, email: 'fresh@orca.local' })
).resolves.toEqual({ status: 'ok' })
await expect(
revokeOrcaProfileOrgInvite(userDataPath, { orgId, email: 'fresh@orca.local' })
).resolves.toEqual({ status: 'not-found' })
})
it('blocks changing the dev owner (self) role', async () => {
const orgId = 'dev-self-org'
const list = await listOrcaProfileOrgMembers(userDataPath, orgId)
if (list.status !== 'ok') {
throw new Error('expected ok')
}
const self = list.roster.members.find((member) => member.role === 'owner')
await expect(
changeOrcaProfileOrgMemberRole(userDataPath, {
orgId,
userId: self?.userId ?? 'dev-user',
role: 'member'
})
).resolves.toEqual({ status: 'invalid', reason: 'cannot_change_own_role' })
})
})
@@ -0,0 +1,212 @@
import type {
OrcaProfileOrgInviteRevokeArgs,
OrcaProfileOrgMemberChangeRoleArgs,
OrcaProfileOrgMemberInviteArgs,
OrcaProfileOrgMemberMutationResult,
OrcaProfileOrgMemberRemoveArgs,
OrcaProfileOrgMembersListResult
} from '../../shared/orca-profiles'
import type { ActiveOrcaProfileState } from './profile-index-store'
import { ensureActiveOrcaProfile } from './profile-index-store'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config'
import type { OrcaCloudSession } from './profile-cloud-session-store'
import { OrcaCloudRequestError } from './profile-cloud-client'
import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh'
import {
changeOrcaCloudOrgMemberRole,
inviteOrcaCloudOrgMember,
listOrcaCloudOrgMembers,
removeOrcaCloudOrgMember,
revokeOrcaCloudOrgInvite
} from './profile-cloud-org-members-client'
import {
changeDevOrcaCloudOrgMemberRole,
inviteDevOrcaCloudOrgMember,
listDevOrcaCloudOrgMembers,
removeDevOrcaCloudOrgMember,
revokeDevOrcaCloudOrgInvite
} from './profile-cloud-dev-org-members'
type OrgCallResult<T> =
| { status: 'ok'; value: T }
| { status: 'reconnect-required' }
| { status: 'request-error'; error: OrcaCloudRequestError }
| { status: 'failed'; error: string }
// Why: only a 401 means the token itself is stale and should drive a session
// refresh/reconnect. 403/404/409/400 are business or permission outcomes the UI
// must interpret, so they are surfaced as values rather than thrown — otherwise
// runWithFreshOrcaCloudSession would treat a 403 as an auth failure and burn a
// pointless token refresh + retry before giving up.
async function runOrgMemberCall<T>(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string,
call: (session: OrcaCloudSession) => Promise<T>
): Promise<OrgCallResult<T>> {
try {
const operation = await runWithFreshOrcaCloudSession(
config,
active,
userDataPath,
async (session) => {
try {
return { ok: true as const, value: await call(session) }
} catch (error) {
if (error instanceof OrcaCloudRequestError && error.statusCode !== 401) {
return { ok: false as const, error }
}
throw error
}
}
)
if (operation.status !== 'ok') {
return { status: 'reconnect-required' }
}
const outcome = operation.value
return outcome.ok
? { status: 'ok', value: outcome.value }
: { status: 'request-error', error: outcome.error }
} catch (error) {
return { status: 'failed', error: error instanceof Error ? error.message : String(error) }
}
}
function mapMutationRequestError(error: OrcaCloudRequestError): OrcaProfileOrgMemberMutationResult {
switch (error.statusCode) {
case 403:
return { status: 'forbidden' }
case 404:
return { status: 'not-found' }
case 409:
return {
status: 'conflict',
reason: error.errorCode === 'already_member' ? 'already_member' : 'already_invited'
}
case 400:
return {
status: 'invalid',
reason:
error.errorCode === 'cannot_remove_self' ? 'cannot_remove_self' : 'cannot_change_own_role'
}
default:
return { status: 'failed', error: error.message }
}
}
function mapMutationResult(result: OrgCallResult<void>): OrcaProfileOrgMemberMutationResult {
switch (result.status) {
case 'ok':
return { status: 'ok' }
case 'reconnect-required':
return { status: 'reconnect-required' }
case 'request-error':
return mapMutationRequestError(result.error)
case 'failed':
return { status: 'failed', error: result.error }
}
}
export async function listOrcaProfileOrgMembers(
userDataPath: string,
orgId: string
): Promise<OrcaProfileOrgMembersListResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
return { status: 'ok', roster: listDevOrcaCloudOrgMembers(orgId) }
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
const result = await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
listOrcaCloudOrgMembers(configState.config, session, orgId)
)
switch (result.status) {
case 'ok':
return { status: 'ok', roster: result.value }
case 'reconnect-required':
return { status: 'reconnect-required' }
case 'request-error':
return { status: 'failed', error: result.error.message }
case 'failed':
return { status: 'failed', error: result.error }
}
}
export async function inviteOrcaProfileOrgMember(
userDataPath: string,
args: OrcaProfileOrgMemberInviteArgs
): Promise<OrcaProfileOrgMemberMutationResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
return inviteDevOrcaCloudOrgMember(args)
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
return mapMutationResult(
await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
inviteOrcaCloudOrgMember(configState.config, session, args)
)
)
}
export async function revokeOrcaProfileOrgInvite(
userDataPath: string,
args: OrcaProfileOrgInviteRevokeArgs
): Promise<OrcaProfileOrgMemberMutationResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
return revokeDevOrcaCloudOrgInvite(args)
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
return mapMutationResult(
await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
revokeOrcaCloudOrgInvite(configState.config, session, args)
)
)
}
export async function changeOrcaProfileOrgMemberRole(
userDataPath: string,
args: OrcaProfileOrgMemberChangeRoleArgs
): Promise<OrcaProfileOrgMemberMutationResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
return changeDevOrcaCloudOrgMemberRole(args)
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
return mapMutationResult(
await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
changeOrcaCloudOrgMemberRole(configState.config, session, args)
)
)
}
export async function removeOrcaProfileOrgMember(
userDataPath: string,
args: OrcaProfileOrgMemberRemoveArgs
): Promise<OrcaProfileOrgMemberMutationResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
return removeDevOrcaCloudOrgMember(args)
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured' }
}
return mapMutationResult(
await runOrgMemberCall(configState.config, active, userDataPath, (session) =>
removeOrcaCloudOrgMember(configState.config, session, args)
)
)
}
@@ -0,0 +1,127 @@
import { get } from 'node:http'
import { describe, expect, it, beforeEach, vi } from 'vitest'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
const { openExternalMock } = vi.hoisted(() => ({
openExternalMock: vi.fn()
}))
vi.mock('electron', () => ({
shell: {
openExternal: openExternalMock
}
}))
import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce'
type HttpResponse = {
body: string
statusCode: number | undefined
}
const config: OrcaCloudAuthConfig = {
apiBaseUrl: 'https://orca-cloud.example',
authorizeEndpoint: 'https://orca-cloud.example/v1/desktop/auth/authorize',
sessionEndpoint: 'https://orca-cloud.example/v1/desktop/auth/session',
refreshEndpoint: 'https://orca-cloud.example/v1/desktop/auth/refresh',
capabilitiesEndpoint: 'https://orca-cloud.example/v1/desktop/auth/capabilities',
profileEndpoint: 'https://orca-cloud.example/v1/desktop/auth/profile',
orgEndpoint: 'https://orca-cloud.example/v1/desktop/auth/org',
logoutEndpoint: 'https://orca-cloud.example/v1/desktop/auth/logout',
clientId: 'desktop-client',
scope: 'openid profile email offline_access'
}
function readHttp(url: string): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
const request = get(url, (response) => {
response.setEncoding('utf-8')
let body = ''
response.on('data', (chunk: string) => {
body += chunk
})
response.on('end', () => {
resolve({ body, statusCode: response.statusCode })
})
})
request.on('error', reject)
})
}
function callbackUrl(redirectUri: string, params: Record<string, string>): string {
const url = new URL(redirectUri)
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value)
}
return url.toString()
}
async function startedFlow(): Promise<{
authUrl: URL
flow: ReturnType<typeof beginOrcaCloudPkceFlow>
nonce: string
redirectUri: string
state: string
}> {
const flow = beginOrcaCloudPkceFlow(config, 'local-default')
await vi.waitFor(() => expect(openExternalMock).toHaveBeenCalledTimes(1))
const authUrl = new URL(String(openExternalMock.mock.calls[0]?.[0]))
const nonce = authUrl.searchParams.get('nonce')
const redirectUri = authUrl.searchParams.get('redirect_uri')
const state = authUrl.searchParams.get('state')
if (!nonce || !redirectUri || !state) {
throw new Error('Expected PKCE flow to create nonce, redirect_uri, and state')
}
return { authUrl, flow, nonce, redirectUri, state }
}
describe('Orca cloud PKCE flow', () => {
beforeEach(() => {
openExternalMock.mockReset()
openExternalMock.mockResolvedValue(undefined)
})
it('keeps the loopback listener alive after an invalid callback', async () => {
const { flow, redirectUri, state } = await startedFlow()
const invalidResponse = await readHttp(
callbackUrl(redirectUri, { code: 'wrong-code', state: 'wrong-state' })
)
expect(invalidResponse.statusCode).toBe(400)
const validResponse = await readHttp(callbackUrl(redirectUri, { code: 'real-code', state }))
expect(validResponse.statusCode).toBe(200)
await expect(flow).resolves.toMatchObject({
code: 'real-code',
redirectUri,
state
})
})
it('rejects a provider error that matches the flow state', async () => {
const { flow, redirectUri, state } = await startedFlow()
const observedFlow = flow.catch((error: unknown) => error)
const response = await readHttp(callbackUrl(redirectUri, { error: 'access_denied', state }))
expect(response.statusCode).toBe(400)
await expect(observedFlow).resolves.toMatchObject({ message: 'orca_cloud_auth_denied' })
})
it('adds desktop PKCE parameters to the authorize URL', async () => {
const { authUrl, flow, nonce, redirectUri, state } = await startedFlow()
expect(authUrl.searchParams.get('client_id')).toBe('desktop-client')
expect(authUrl.searchParams.get('response_type')).toBe('code')
expect(authUrl.searchParams.get('redirect_uri')).toBe(redirectUri)
expect(authUrl.searchParams.get('scope')).toBe('openid profile email offline_access')
expect(authUrl.searchParams.get('nonce')).toBe(nonce)
expect(authUrl.searchParams.get('state')).toBe(state)
expect(authUrl.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]+$/)
expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256')
expect(authUrl.searchParams.get('local_profile_id')).toBe('local-default')
await readHttp(callbackUrl(redirectUri, { code: 'real-code', state }))
await expect(flow).resolves.toMatchObject({ code: 'real-code', nonce })
})
})
@@ -0,0 +1,142 @@
import { createHash, randomBytes } from 'node:crypto'
import { createServer, type Server, type ServerResponse } from 'node:http'
import { shell } from 'electron'
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
export type OrcaCloudAuthorizationCode = {
code: string
codeVerifier: string
nonce: string
redirectUri: string
state: string
}
const AUTH_TIMEOUT_MS = 5 * 60 * 1000
function base64Url(buffer: Buffer): string {
return buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
}
function createCodeVerifier(): string {
return base64Url(randomBytes(32))
}
function createCodeChallenge(verifier: string): string {
return base64Url(createHash('sha256').update(verifier).digest())
}
function closeServer(server: Server): void {
try {
// Why: keep-alive sockets from the browser can delay 'close' (and the
// timeout cleanup) until the browser drops the connection.
server.closeAllConnections?.()
server.close()
} catch {
// Already closed.
}
}
export function beginOrcaCloudPkceFlow(
config: OrcaCloudAuthConfig,
localProfileId: string
): Promise<OrcaCloudAuthorizationCode> {
const codeVerifier = createCodeVerifier()
const nonce = base64Url(randomBytes(32))
const state = base64Url(randomBytes(32))
return new Promise((resolve, reject) => {
let settled = false
let redirectUri = ''
function rejectFlow(error: Error): void {
if (settled) {
return
}
settled = true
reject(error)
closeServer(server)
}
function resolveFlow(code: string): void {
if (settled) {
return
}
settled = true
resolve({
code,
codeVerifier,
nonce,
redirectUri,
state
})
closeServer(server)
}
function writeInvalidCallback(response: ServerResponse): void {
response.writeHead(400)
response.end('Invalid Orca sign-in response.')
}
const server = createServer((request, response) => {
try {
const url = new URL(request.url ?? '/', 'http://127.0.0.1')
if (url.pathname !== '/auth/callback') {
response.writeHead(404)
response.end('Not found')
return
}
const code = url.searchParams.get('code')
const returnedState = url.searchParams.get('state')
if (returnedState !== state) {
// Why: stray loopback probes must not be able to cancel the user's login.
writeInvalidCallback(response)
return
}
if (url.searchParams.has('error')) {
response.writeHead(400)
response.end('Orca sign-in was cancelled.')
rejectFlow(new Error('orca_cloud_auth_denied'))
return
}
if (!code) {
writeInvalidCallback(response)
return
}
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
response.end('<!doctype html><title>Orca</title><p>You can return to Orca.</p>')
resolveFlow(code)
} catch (error) {
rejectFlow(error instanceof Error ? error : new Error('orca_cloud_auth_callback_failed'))
}
})
const timeout = setTimeout(() => {
rejectFlow(new Error('orca_cloud_auth_timeout'))
}, AUTH_TIMEOUT_MS)
server.once('close', () => clearTimeout(timeout))
server.once('error', rejectFlow)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
rejectFlow(new Error('orca_cloud_auth_loopback_unavailable'))
return
}
redirectUri = `http://127.0.0.1:${address.port}/auth/callback`
const authorizeUrl = new URL(config.authorizeEndpoint)
authorizeUrl.searchParams.set('client_id', config.clientId)
authorizeUrl.searchParams.set('response_type', 'code')
authorizeUrl.searchParams.set('redirect_uri', redirectUri)
authorizeUrl.searchParams.set('scope', config.scope)
authorizeUrl.searchParams.set('nonce', nonce)
authorizeUrl.searchParams.set('state', state)
authorizeUrl.searchParams.set('code_challenge', createCodeChallenge(codeVerifier))
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
authorizeUrl.searchParams.set('local_profile_id', localProfileId)
void shell.openExternal(authorizeUrl.toString()).catch((error) => {
rejectFlow(
error instanceof Error ? error : new Error('orca_cloud_auth_browser_open_failed')
)
})
})
})
}
@@ -0,0 +1,247 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaProfileCloudSummary
} from '../../shared/orca-profiles'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
const {
beginOrcaCloudPkceFlowMock,
createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCodeMock,
refreshOrcaCloudCapabilitiesMock,
refreshOrcaCloudSessionMock,
selectOrcaCloudOrgMock,
OrcaCloudRequestErrorMock,
safeStorageMock
} = vi.hoisted(() => ({
beginOrcaCloudPkceFlowMock: vi.fn(),
createOrcaCloudProfileMock: vi.fn(),
exchangeOrcaCloudAuthCodeMock: vi.fn(),
refreshOrcaCloudCapabilitiesMock: vi.fn(),
refreshOrcaCloudSessionMock: vi.fn(),
selectOrcaCloudOrgMock: vi.fn(),
OrcaCloudRequestErrorMock: class OrcaCloudRequestError extends Error {
constructor(public readonly statusCode: number) {
super(`orca_cloud_request_failed_${statusCode}`)
this.name = 'OrcaCloudRequestError'
}
},
safeStorageMock: {
decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
isEncryptionAvailable: vi.fn(() => true)
}
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: {
getPath: () => userDataPath
},
safeStorage: safeStorageMock
}))
vi.mock('./profile-cloud-pkce', () => ({
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
}))
vi.mock('./profile-cloud-client', () => ({
OrcaCloudRequestError: OrcaCloudRequestErrorMock,
createOrcaCloudProfile: createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock,
refreshOrcaCloudCapabilities: refreshOrcaCloudCapabilitiesMock,
refreshOrcaCloudSession: refreshOrcaCloudSessionMock,
revokeOrcaCloudSession: vi.fn(),
selectOrcaCloudOrg: selectOrcaCloudOrgMock
}))
import {
connectCurrentOrcaProfile,
createCloudLinkedOrcaProfile,
getCurrentOrcaProfileAuthStatus,
refreshCurrentOrcaProfileAuth,
selectCurrentOrcaProfileOrg
} from './profile-cloud-service'
const cloudSummary: OrcaProfileCloudSummary = {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
displayName: 'Nina',
linkedAt: 10
}
const capabilities: OrcaCloudCapabilities = {
flags: { share: true },
refreshedAt: 11
}
const organizations: OrcaCloudOrgSummary[] = [
{ orgId: 'org-1', name: 'Acme', role: 'Admin' },
{ orgId: 'org-2', name: 'Personal' }
]
function futureExpiresAt(): number {
return Date.now() + 3_600_000
}
function configureCloudEnv(): void {
vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client')
}
function mockSuccessfulConnect(): void {
beginOrcaCloudPkceFlowMock.mockResolvedValue({
code: 'auth-code',
codeVerifier: 'code-verifier',
nonce: 'nonce',
redirectUri: 'http://127.0.0.1:4100/auth/callback',
state: 'state'
})
exchangeOrcaCloudAuthCodeMock.mockResolvedValue({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: futureExpiresAt(),
cloud: cloudSummary,
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
}
function mockSuccessfulSessionRefresh(): void {
refreshOrcaCloudSessionMock.mockResolvedValue({
accessToken: 'rotated-access-token',
refreshToken: 'rotated-refresh-token',
expiresAt: futureExpiresAt(),
cloud: cloudSummary,
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
}
describe('Orca cloud profile auth-failure retry', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-auth-retry-'))
beginOrcaCloudPkceFlowMock.mockReset()
createOrcaCloudProfileMock.mockReset()
exchangeOrcaCloudAuthCodeMock.mockReset()
refreshOrcaCloudCapabilitiesMock.mockReset()
refreshOrcaCloudSessionMock.mockReset()
selectOrcaCloudOrgMock.mockReset()
safeStorageMock.decryptString.mockReset()
safeStorageMock.encryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8'))
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('refreshes and retries cloud profile creation after an auth failure', async () => {
configureCloudEnv()
mockSuccessfulConnect()
mockSuccessfulSessionRefresh()
await connectCurrentOrcaProfile(userDataPath)
createOrcaCloudProfileMock
.mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401))
.mockResolvedValue({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: futureExpiresAt(),
cloud: { ...cloudSummary, cloudProfileId: 'cloud-profile-2' },
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
const result = await createCloudLinkedOrcaProfile(userDataPath, { name: 'Acme' })
expect(result.status).toBe('created')
expect(createOrcaCloudProfileMock).toHaveBeenNthCalledWith(
2,
expect.any(Object),
expect.objectContaining({ accessToken: 'rotated-access-token' }),
{ name: 'Acme' }
)
})
it('refreshes and retries capability refresh after an auth failure', async () => {
configureCloudEnv()
mockSuccessfulConnect()
mockSuccessfulSessionRefresh()
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudCapabilitiesMock
.mockRejectedValueOnce(new OrcaCloudRequestErrorMock(403))
.mockResolvedValue({
capabilities: { flags: { share: false }, refreshedAt: 26 } satisfies OrcaCloudCapabilities
})
const result = await refreshCurrentOrcaProfileAuth(userDataPath)
expect(result.status).toBe('refreshed')
expect(refreshOrcaCloudCapabilitiesMock).toHaveBeenNthCalledWith(
2,
expect.any(Object),
expect.objectContaining({ accessToken: 'rotated-access-token' })
)
expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toEqual({
flags: { share: false },
refreshedAt: 26
})
})
it('requires reconnect when a retried capability refresh is still unauthorized', async () => {
configureCloudEnv()
mockSuccessfulConnect()
mockSuccessfulSessionRefresh()
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudCapabilitiesMock
.mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401))
.mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401))
const result = await refreshCurrentOrcaProfileAuth(userDataPath)
expect(result.status).toBe('reconnect-required')
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
state: 'reconnect-required',
persistence: 'none',
cloud: cloudSummary
})
})
it('refreshes and retries organization selection after an auth failure', async () => {
configureCloudEnv()
mockSuccessfulConnect()
mockSuccessfulSessionRefresh()
await connectCurrentOrcaProfile(userDataPath)
selectOrcaCloudOrgMock
.mockRejectedValueOnce(new OrcaCloudRequestErrorMock(401))
.mockResolvedValue({
cloud: { ...cloudSummary, activeOrgId: 'org-1', activeOrgName: 'Acme' },
organizations,
capabilities
})
const result = await selectCurrentOrcaProfileOrg(userDataPath, 'org-1')
expect(result.status).toBe('selected')
expect(selectOrcaCloudOrgMock).toHaveBeenNthCalledWith(
2,
expect.any(Object),
expect.objectContaining({ accessToken: 'rotated-access-token' }),
'org-1'
)
})
})
@@ -0,0 +1,254 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaProfileCloudSummary
} from '../../shared/orca-profiles'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
const {
beginOrcaCloudPkceFlowMock,
createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCodeMock,
refreshOrcaCloudCapabilitiesMock,
refreshOrcaCloudSessionMock,
OrcaCloudRequestErrorMock,
safeStorageMock
} = vi.hoisted(() => ({
beginOrcaCloudPkceFlowMock: vi.fn(),
createOrcaCloudProfileMock: vi.fn(),
exchangeOrcaCloudAuthCodeMock: vi.fn(),
refreshOrcaCloudCapabilitiesMock: vi.fn(),
refreshOrcaCloudSessionMock: vi.fn(),
OrcaCloudRequestErrorMock: class OrcaCloudRequestError extends Error {
constructor(public readonly statusCode: number) {
super(`orca_cloud_request_failed_${statusCode}`)
this.name = 'OrcaCloudRequestError'
}
},
safeStorageMock: {
decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
isEncryptionAvailable: vi.fn(() => true)
}
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: {
getPath: () => userDataPath
},
safeStorage: safeStorageMock
}))
vi.mock('./profile-cloud-pkce', () => ({
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
}))
vi.mock('./profile-cloud-client', () => ({
OrcaCloudRequestError: OrcaCloudRequestErrorMock,
createOrcaCloudProfile: createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock,
refreshOrcaCloudCapabilities: refreshOrcaCloudCapabilitiesMock,
refreshOrcaCloudSession: refreshOrcaCloudSessionMock,
revokeOrcaCloudSession: vi.fn(),
selectOrcaCloudOrg: vi.fn()
}))
import {
connectCurrentOrcaProfile,
createCloudLinkedOrcaProfile,
getCurrentOrcaProfileAuthStatus,
refreshCurrentOrcaProfileAuth
} from './profile-cloud-service'
const cloudSummary: OrcaProfileCloudSummary = {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
displayName: 'Nina',
linkedAt: 10
}
const capabilities: OrcaCloudCapabilities = {
flags: { share: true },
refreshedAt: 11
}
const organizations: OrcaCloudOrgSummary[] = [
{ orgId: 'org-1', name: 'Acme', role: 'Admin' },
{ orgId: 'org-2', name: 'Personal' }
]
function futureExpiresAt(): number {
return Date.now() + 3_600_000
}
function configureCloudEnv(): void {
vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client')
}
function mockSuccessfulConnect(expiresAt = futureExpiresAt()): void {
beginOrcaCloudPkceFlowMock.mockResolvedValue({
code: 'auth-code',
codeVerifier: 'code-verifier',
nonce: 'nonce',
redirectUri: 'http://127.0.0.1:4100/auth/callback',
state: 'state'
})
exchangeOrcaCloudAuthCodeMock.mockResolvedValue({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt,
cloud: cloudSummary,
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
}
describe('Orca cloud profile service session refresh', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-refresh-'))
beginOrcaCloudPkceFlowMock.mockReset()
createOrcaCloudProfileMock.mockReset()
exchangeOrcaCloudAuthCodeMock.mockReset()
refreshOrcaCloudCapabilitiesMock.mockReset()
refreshOrcaCloudSessionMock.mockReset()
safeStorageMock.decryptString.mockReset()
safeStorageMock.encryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8'))
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('refreshes an expired access token before creating cloud profiles', async () => {
configureCloudEnv()
mockSuccessfulConnect(Date.now() - 1_000)
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudSessionMock.mockResolvedValue({
accessToken: 'rotated-access-token',
refreshToken: 'rotated-refresh-token',
expiresAt: futureExpiresAt(),
cloud: cloudSummary,
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
createOrcaCloudProfileMock.mockResolvedValue({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: futureExpiresAt(),
cloud: {
...cloudSummary,
cloudProfileId: 'cloud-profile-2',
activeOrgId: 'org-1',
activeOrgName: 'Acme'
},
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
const result = await createCloudLinkedOrcaProfile(userDataPath, {
orgId: 'org-1',
name: 'Acme'
})
expect(result.status).toBe('created')
expect(refreshOrcaCloudSessionMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ refreshToken: 'refresh-token' })
)
expect(createOrcaCloudProfileMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ accessToken: 'rotated-access-token' }),
{ orgId: 'org-1', name: 'Acme' }
)
})
it('refreshes capability flags for the connected profile', async () => {
configureCloudEnv()
mockSuccessfulConnect()
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudCapabilitiesMock.mockResolvedValue({
capabilities: {
flags: { share: false, team: true },
refreshedAt: 25
}
})
const result = await refreshCurrentOrcaProfileAuth(userDataPath)
expect(result.status).toBe('refreshed')
expect(refreshOrcaCloudCapabilitiesMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ accessToken: 'access-token' })
)
expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toEqual({
flags: { share: false, team: true },
refreshedAt: 25
})
})
it('clears stale active org metadata when capability refresh returns no active org', async () => {
configureCloudEnv()
mockSuccessfulConnect()
exchangeOrcaCloudAuthCodeMock.mockResolvedValue({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: futureExpiresAt(),
cloud: { ...cloudSummary, activeOrgId: 'org-1', activeOrgName: 'Acme' },
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudCapabilitiesMock.mockResolvedValue({
cloud: cloudSummary,
organizations: [],
capabilities: {
flags: { share: false },
refreshedAt: 31
}
})
const result = await refreshCurrentOrcaProfileAuth(userDataPath)
const status = getCurrentOrcaProfileAuthStatus(userDataPath)
expect(result.status).toBe('refreshed')
expect(status.cloud?.activeOrgId).toBeUndefined()
expect(status.cloud?.activeOrgName).toBeUndefined()
expect(status.organizations).toEqual([])
expect(status.capabilities).toEqual({
flags: { share: false },
refreshedAt: 31
})
})
it('requires reconnect when an expired refresh token is rejected', async () => {
configureCloudEnv()
mockSuccessfulConnect(Date.now() - 1_000)
await connectCurrentOrcaProfile(userDataPath)
refreshOrcaCloudSessionMock.mockRejectedValue(new OrcaCloudRequestErrorMock(401))
const result = await refreshCurrentOrcaProfileAuth(userDataPath)
expect(result.status).toBe('reconnect-required')
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
state: 'reconnect-required',
persistence: 'none',
cloud: cloudSummary
})
})
})
@@ -0,0 +1,285 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaProfileCloudSummary
} from '../../shared/orca-profiles'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
const {
beginOrcaCloudPkceFlowMock,
createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCodeMock,
revokeOrcaCloudSessionMock,
selectOrcaCloudOrgMock,
safeStorageMock
} = vi.hoisted(() => ({
beginOrcaCloudPkceFlowMock: vi.fn(),
createOrcaCloudProfileMock: vi.fn(),
exchangeOrcaCloudAuthCodeMock: vi.fn(),
revokeOrcaCloudSessionMock: vi.fn(),
selectOrcaCloudOrgMock: vi.fn(),
safeStorageMock: {
decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
isEncryptionAvailable: vi.fn(() => true)
}
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: {
getPath: () => userDataPath
},
safeStorage: safeStorageMock
}))
vi.mock('./profile-cloud-pkce', () => ({
beginOrcaCloudPkceFlow: beginOrcaCloudPkceFlowMock
}))
vi.mock('./profile-cloud-client', () => ({
createOrcaCloudProfile: createOrcaCloudProfileMock,
exchangeOrcaCloudAuthCode: exchangeOrcaCloudAuthCodeMock,
revokeOrcaCloudSession: revokeOrcaCloudSessionMock,
selectOrcaCloudOrg: selectOrcaCloudOrgMock
}))
import {
connectCurrentOrcaProfile,
createCloudLinkedOrcaProfile,
getCurrentOrcaProfileAuthStatus,
selectCurrentOrcaProfileOrg,
signOutCurrentOrcaProfile
} from './profile-cloud-service'
const cloudSummary: OrcaProfileCloudSummary = {
cloudProfileId: 'cloud-profile-1',
userId: 'user-1',
email: 'nina@example.com',
displayName: 'Nina',
linkedAt: 10
}
const capabilities: OrcaCloudCapabilities = {
flags: { share: true },
refreshedAt: 11
}
const organizations: OrcaCloudOrgSummary[] = [
{ orgId: 'org-1', name: 'Acme', role: 'Admin' },
{ orgId: 'org-2', name: 'Personal' }
]
function configureCloudEnv(): void {
vi.stubEnv('ORCA_CLOUD_API_URL', 'https://orca-cloud.example')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', 'desktop-client')
}
function futureExpiresAt(): number {
return Date.now() + 3_600_000
}
function mockSuccessfulConnect(expiresAt = futureExpiresAt()): void {
beginOrcaCloudPkceFlowMock.mockResolvedValue({
code: 'auth-code',
codeVerifier: 'code-verifier',
nonce: 'nonce',
redirectUri: 'http://127.0.0.1:4100/auth/callback',
state: 'state'
})
exchangeOrcaCloudAuthCodeMock.mockResolvedValue({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt,
cloud: cloudSummary,
organizations,
capabilities
} satisfies OrcaCloudSessionExchangeResponse)
}
describe('Orca cloud profile service', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-service-'))
beginOrcaCloudPkceFlowMock.mockReset()
createOrcaCloudProfileMock.mockReset()
exchangeOrcaCloudAuthCodeMock.mockReset()
revokeOrcaCloudSessionMock.mockReset()
selectOrcaCloudOrgMock.mockReset()
safeStorageMock.decryptString.mockReset()
safeStorageMock.encryptString.mockReset()
safeStorageMock.isEncryptionAvailable.mockReset()
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf-8'))
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value, 'utf-8'))
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
revokeOrcaCloudSessionMock.mockResolvedValue(undefined)
vi.unstubAllEnvs()
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('reports local unconfigured auth without cloud setup', () => {
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
activeProfileId: 'local-default',
configured: false,
state: 'unconfigured',
persistence: 'none'
})
})
it('connects the active local profile without replacing its local profile ID', async () => {
configureCloudEnv()
mockSuccessfulConnect()
const result = await connectCurrentOrcaProfile(userDataPath)
if (result.status !== 'connected') {
throw new Error(`Expected connected result, got ${result.status}`)
}
expect(result.activeProfileId).toBe('local-default')
expect(result.profiles[0]).toMatchObject({
id: 'local-default',
kind: 'cloud-linked',
cloud: cloudSummary
})
expect(exchangeOrcaCloudAuthCodeMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ localProfileId: 'local-default', nonce: 'nonce' })
)
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
state: 'connected',
persistence: 'encrypted',
cloud: cloudSummary,
organizations,
capabilities
})
})
it('treats provider-denied sign-in as a cancelled connect attempt', async () => {
configureCloudEnv()
beginOrcaCloudPkceFlowMock.mockRejectedValue(new Error('orca_cloud_auth_denied'))
const result = await connectCurrentOrcaProfile(userDataPath)
expect(result.status).toBe('cancelled')
expect(exchangeOrcaCloudAuthCodeMock).not.toHaveBeenCalled()
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
state: 'local',
persistence: 'none'
})
})
it('does not report a saved cloud session as connected when cloud config is unavailable', async () => {
configureCloudEnv()
mockSuccessfulConnect()
await connectCurrentOrcaProfile(userDataPath)
vi.stubEnv('ORCA_CLOUD_API_URL', '')
vi.stubEnv('ORCA_CLOUD_CLIENT_ID', '')
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
configured: false,
state: 'unconfigured',
persistence: 'encrypted',
cloud: cloudSummary,
setupMessage: 'Orca Cloud sign-in is not configured for this build.'
})
expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toBeUndefined()
expect(getCurrentOrcaProfileAuthStatus(userDataPath).capabilities).toBeUndefined()
})
it('signs out by removing cloud metadata while keeping the local profile', async () => {
configureCloudEnv()
mockSuccessfulConnect()
await connectCurrentOrcaProfile(userDataPath)
const result = await signOutCurrentOrcaProfile(userDataPath)
expect(result.status).toBe('signed-out')
expect(result.activeProfileId).toBe('local-default')
expect(result.profiles[0]).toMatchObject({ id: 'local-default', kind: 'local' })
expect(result.profiles[0]?.cloud).toBeUndefined()
expect(getCurrentOrcaProfileAuthStatus(userDataPath)).toMatchObject({
state: 'local',
persistence: 'none'
})
expect(revokeOrcaCloudSessionMock).toHaveBeenCalledOnce()
})
it('creates a new empty cloud-linked profile with its own cloud session', async () => {
configureCloudEnv()
mockSuccessfulConnect()
await connectCurrentOrcaProfile(userDataPath)
createOrcaCloudProfileMock.mockResolvedValue({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: 1000,
cloud: {
...cloudSummary,
cloudProfileId: 'cloud-profile-2',
activeOrgId: 'org-1',
activeOrgName: 'Acme'
},
organizations,
capabilities: { flags: { share: true, team: true }, refreshedAt: 13 }
} satisfies OrcaCloudSessionExchangeResponse)
const result = await createCloudLinkedOrcaProfile(userDataPath, {
orgId: 'org-1',
name: 'Acme'
})
if (result.status !== 'created') {
throw new Error(`Expected created result, got ${result.status}`)
}
expect(result.profile).toMatchObject({
id: expect.stringMatching(/^cloud-/),
name: 'Acme',
kind: 'cloud-linked',
cloud: expect.objectContaining({ cloudProfileId: 'cloud-profile-2' })
})
expect(createOrcaCloudProfileMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ accessToken: 'access-token' }),
{ orgId: 'org-1', name: 'Acme' }
)
})
it('selects an organization for a connected profile', async () => {
configureCloudEnv()
mockSuccessfulConnect()
await connectCurrentOrcaProfile(userDataPath)
const orgCloudSummary = {
...cloudSummary,
activeOrgId: 'org-1',
activeOrgName: 'Acme'
}
selectOrcaCloudOrgMock.mockResolvedValue({
cloud: orgCloudSummary,
organizations,
capabilities: { flags: { share: true, sso: true }, refreshedAt: 12 }
})
const result = await selectCurrentOrcaProfileOrg(userDataPath, 'org-1')
expect(result.status).toBe('selected')
expect(selectOrcaCloudOrgMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ accessToken: 'access-token' }),
'org-1'
)
expect(getCurrentOrcaProfileAuthStatus(userDataPath).cloud).toMatchObject({
activeOrgId: 'org-1',
activeOrgName: 'Acme'
})
expect(getCurrentOrcaProfileAuthStatus(userDataPath).organizations).toEqual(organizations)
})
})
@@ -0,0 +1,300 @@
import type {
ConnectCurrentOrcaProfileResult,
CreateCloudLinkedOrcaProfileArgs,
CreateCloudLinkedOrcaProfileResult,
OrcaProfileAuthStatus,
RefreshCurrentOrcaProfileAuthResult,
SelectOrcaProfileOrgResult,
SignOutCurrentOrcaProfileResult
} from '../../shared/orca-profiles'
import { ensureActiveOrcaProfile, getOrcaProfileListState } from './profile-index-store'
import { getOrcaCloudAuthConfig, isOrcaCloudDevAuthEnabled } from './profile-cloud-auth-config'
import {
clearOrcaCloudSession,
readOrcaCloudSession,
saveOrcaCloudSession,
saveOrcaCloudSessionExchange
} from './profile-cloud-session-store'
import {
createOrcaCloudProfile,
exchangeOrcaCloudAuthCode,
refreshOrcaCloudCapabilities,
revokeOrcaCloudSession,
selectOrcaCloudOrg
} from './profile-cloud-client'
import { beginOrcaCloudPkceFlow } from './profile-cloud-pkce'
import {
createCloudLinkedOrcaProfileRecord,
linkOrcaProfileToCloud,
unlinkOrcaProfileFromCloud
} from './profile-cloud-index'
import { runWithFreshOrcaCloudSession } from './profile-cloud-session-refresh'
import {
connectDevOrcaCloudProfile,
createDevCloudLinkedOrcaProfile,
refreshDevOrcaCloudProfile,
selectDevOrcaCloudOrg
} from './profile-cloud-dev-service'
import { getOrcaProfileAuthStatusFromProfile } from './profile-cloud-auth-status'
function isUserCancelledAuthError(message: string): boolean {
return message === 'orca_cloud_auth_timeout' || message === 'orca_cloud_auth_denied'
}
function activeAuth(
active: ReturnType<typeof ensureActiveOrcaProfile>,
userDataPath: string
): OrcaProfileAuthStatus {
return getOrcaProfileAuthStatusFromProfile(active, userDataPath)
}
export function getCurrentOrcaProfileAuthStatus(userDataPath: string): OrcaProfileAuthStatus {
return getOrcaProfileAuthStatusFromProfile(ensureActiveOrcaProfile(userDataPath), userDataPath)
}
export async function connectCurrentOrcaProfile(
userDataPath: string
): Promise<ConnectCurrentOrcaProfileResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
const list = connectDevOrcaCloudProfile(active, userDataPath)
return {
status: 'connected',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return {
status: 'unconfigured',
auth: activeAuth(active, userDataPath)
}
}
try {
const code = await beginOrcaCloudPkceFlow(configState.config, active.profile.id)
const exchange = await exchangeOrcaCloudAuthCode(configState.config, {
...code,
localProfileId: active.profile.id
})
saveOrcaCloudSessionExchange(active.profile.id, userDataPath, exchange)
const list = linkOrcaProfileToCloud(active.profile.id, exchange.cloud, userDataPath)
return {
status: 'connected',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (isUserCancelledAuthError(message)) {
return {
status: 'cancelled',
auth: getCurrentOrcaProfileAuthStatus(userDataPath)
}
}
return {
status: 'failed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
error: message
}
}
}
export async function signOutCurrentOrcaProfile(
userDataPath: string
): Promise<SignOutCurrentOrcaProfileResult> {
const active = ensureActiveOrcaProfile(userDataPath)
const configState = getOrcaCloudAuthConfig()
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (!isOrcaCloudDevAuthEnabled() && configState.configured && session.status === 'found') {
await revokeOrcaCloudSession(configState.config, session.session).catch(() => undefined)
}
clearOrcaCloudSession(active.profile.id, userDataPath)
const list = unlinkOrcaProfileFromCloud(active.profile.id, userDataPath)
return {
status: 'signed-out',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
}
export async function createCloudLinkedOrcaProfile(
userDataPath: string,
args: CreateCloudLinkedOrcaProfileArgs
): Promise<CreateCloudLinkedOrcaProfileResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
const result = createDevCloudLinkedOrcaProfile(active, userDataPath, args)
if (result.status !== 'created') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
return {
status: 'created',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: result.list.activeProfileId,
profiles: result.list.profiles,
profile: result.list.profile
}
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured', auth: activeAuth(active, userDataPath) }
}
try {
const operation = await runWithFreshOrcaCloudSession(
configState.config,
active,
userDataPath,
(session) => createOrcaCloudProfile(configState.config, session, args)
)
if (operation.status !== 'ok') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
const created = operation.value
const list = createCloudLinkedOrcaProfileRecord(
created.cloud,
{ name: args.name },
userDataPath
)
saveOrcaCloudSessionExchange(list.profile.id, userDataPath, created)
return {
status: 'created',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles,
profile: list.profile
}
} catch (error) {
return {
status: 'failed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
error: error instanceof Error ? error.message : String(error)
}
}
}
export async function refreshCurrentOrcaProfileAuth(
userDataPath: string
): Promise<RefreshCurrentOrcaProfileAuthResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (!active.profile.cloud) {
return { status: 'local', auth: activeAuth(active, userDataPath) }
}
if (isOrcaCloudDevAuthEnabled()) {
const result = refreshDevOrcaCloudProfile(active, userDataPath)
if (result.status !== 'updated') {
return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) }
}
return {
status: 'refreshed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: result.list.activeProfileId,
profiles: result.list.profiles
}
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured', auth: activeAuth(active, userDataPath) }
}
try {
const operation = await runWithFreshOrcaCloudSession(
configState.config,
active,
userDataPath,
(session) => refreshOrcaCloudCapabilities(configState.config, session)
)
if (operation.status !== 'ok') {
return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) }
}
const refresh = operation.value
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (session.status !== 'found') {
return { status: 'reconnect-required', auth: getCurrentOrcaProfileAuthStatus(userDataPath) }
}
saveOrcaCloudSession(active.profile.id, userDataPath, {
...session.session,
organizations: refresh.organizations ?? session.session.organizations,
capabilities: refresh.capabilities
})
const list = refresh.cloud
? linkOrcaProfileToCloud(active.profile.id, refresh.cloud, userDataPath)
: getOrcaProfileListState(userDataPath)
return {
status: 'refreshed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
} catch (error) {
return {
status: 'failed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
error: error instanceof Error ? error.message : String(error)
}
}
}
export async function selectCurrentOrcaProfileOrg(
userDataPath: string,
orgId: string
): Promise<SelectOrcaProfileOrgResult> {
const active = ensureActiveOrcaProfile(userDataPath)
if (isOrcaCloudDevAuthEnabled()) {
const result = selectDevOrcaCloudOrg(active, userDataPath, orgId)
if (result.status !== 'updated') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
return {
status: 'selected',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: result.list.activeProfileId,
profiles: result.list.profiles
}
}
const configState = getOrcaCloudAuthConfig()
if (!configState.configured) {
return { status: 'unconfigured', auth: activeAuth(active, userDataPath) }
}
try {
const operation = await runWithFreshOrcaCloudSession(
configState.config,
active,
userDataPath,
(session) => selectOrcaCloudOrg(configState.config, session, orgId)
)
if (operation.status !== 'ok') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
const selected = operation.value
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (session.status !== 'found') {
return { status: 'reconnect-required', auth: activeAuth(active, userDataPath) }
}
saveOrcaCloudSession(active.profile.id, userDataPath, {
...session.session,
organizations: selected.organizations ?? session.session.organizations,
capabilities: selected.capabilities
})
const list = linkOrcaProfileToCloud(active.profile.id, selected.cloud, userDataPath)
return {
status: 'selected',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
activeProfileId: list.activeProfileId,
profiles: list.profiles
}
} catch (error) {
return {
status: 'failed',
auth: getCurrentOrcaProfileAuthStatus(userDataPath),
error: error instanceof Error ? error.message : String(error)
}
}
}
@@ -0,0 +1,14 @@
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaProfileCloudSummary
} from '../../shared/orca-profiles'
export type OrcaCloudSessionExchangeResponse = {
accessToken: string
refreshToken: string
expiresAt: number
cloud: OrcaProfileCloudSummary
organizations?: OrcaCloudOrgSummary[]
capabilities: OrcaCloudCapabilities
}
@@ -0,0 +1,178 @@
import type { OrcaCloudAuthConfig } from './profile-cloud-auth-config'
import type { ActiveOrcaProfileState } from './profile-index-store'
import {
clearOrcaCloudSession,
type OrcaCloudSession,
readOrcaCloudSession,
saveOrcaCloudSession
} from './profile-cloud-session-store'
import { OrcaCloudRequestError, refreshOrcaCloudSession } from './profile-cloud-client'
import { linkOrcaProfileToCloud } from './profile-cloud-index'
const CLOUD_SESSION_REFRESH_SKEW_MS = 60_000
export type FreshCloudSessionResult =
| { status: 'found'; session: OrcaCloudSession }
| { status: 'reconnect-required' }
export type CloudSessionOperationResult<T> =
| { status: 'ok'; value: T }
| { status: 'reconnect-required' }
function shouldRefreshCloudSession(session: OrcaCloudSession, now = Date.now()): boolean {
return session.expiresAt <= now + CLOUD_SESSION_REFRESH_SKEW_MS
}
export function isOrcaCloudAuthFailure(error: unknown): boolean {
return (
error instanceof OrcaCloudRequestError && (error.statusCode === 401 || error.statusCode === 403)
)
}
const inflightCloudSessionRefreshes = new Map<string, Promise<OrcaCloudSession>>()
function cloudSessionRefreshKey(profileId: string, userDataPath: string): string {
return `${userDataPath}\0${profileId}`
}
// Why: with refresh-token rotation, only the session that actually failed may
// clear the store; otherwise a loser of a concurrent refresh race would wipe
// the winner's freshly rotated session.
function clearCloudSessionIfUnchanged(
profileId: string,
userDataPath: string,
failed: OrcaCloudSession
): void {
const current = readOrcaCloudSession(profileId, userDataPath)
if (current.status === 'found' && current.session.refreshToken !== failed.refreshToken) {
return
}
clearOrcaCloudSession(profileId, userDataPath)
}
async function refreshStoredCloudSession(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string,
session: OrcaCloudSession
): Promise<OrcaCloudSession> {
// Why: refresh tokens rotate, so concurrent refreshes must single-flight;
// a second POST with the same refresh token can trip server reuse detection
// and revoke the whole token family.
const key = cloudSessionRefreshKey(active.profile.id, userDataPath)
const inflight = inflightCloudSessionRefreshes.get(key)
if (inflight) {
return inflight
}
const task = (async () => {
const current = readOrcaCloudSession(active.profile.id, userDataPath)
if (current.status === 'found' && current.session.refreshToken !== session.refreshToken) {
// Another caller already rotated this session; reuse its result.
return current.session
}
const refreshed = await refreshOrcaCloudSession(config, session)
const nextSession = {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
expiresAt: refreshed.expiresAt,
organizations: refreshed.organizations,
capabilities: refreshed.capabilities
}
saveOrcaCloudSession(active.profile.id, userDataPath, nextSession)
linkOrcaProfileToCloud(active.profile.id, refreshed.cloud, userDataPath)
return nextSession
})()
inflightCloudSessionRefreshes.set(key, task)
try {
return await task
} finally {
inflightCloudSessionRefreshes.delete(key)
}
}
export async function readFreshOrcaCloudSession(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string
): Promise<FreshCloudSessionResult> {
const session = readOrcaCloudSession(active.profile.id, userDataPath)
if (session.status !== 'found') {
return { status: 'reconnect-required' }
}
if (!shouldRefreshCloudSession(session.session)) {
return { status: 'found', session: session.session }
}
try {
return {
status: 'found',
session: await refreshStoredCloudSession(config, active, userDataPath, session.session)
}
} catch (error) {
if (isOrcaCloudAuthFailure(error)) {
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session.session)
return { status: 'reconnect-required' }
}
throw error
}
}
export async function forceRefreshOrcaCloudSession(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string,
session: OrcaCloudSession
): Promise<FreshCloudSessionResult> {
try {
return {
status: 'found',
session: await refreshStoredCloudSession(config, active, userDataPath, session)
}
} catch (error) {
if (isOrcaCloudAuthFailure(error)) {
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, session)
return { status: 'reconnect-required' }
}
throw error
}
}
export async function runWithFreshOrcaCloudSession<T>(
config: OrcaCloudAuthConfig,
active: ActiveOrcaProfileState,
userDataPath: string,
operation: (session: OrcaCloudSession) => Promise<T>
): Promise<CloudSessionOperationResult<T>> {
const session = await readFreshOrcaCloudSession(config, active, userDataPath)
if (session.status !== 'found') {
return { status: 'reconnect-required' }
}
try {
return { status: 'ok', value: await operation(session.session) }
} catch (error) {
if (!isOrcaCloudAuthFailure(error)) {
throw error
}
const refreshed = await forceRefreshOrcaCloudSession(
config,
active,
userDataPath,
session.session
)
if (refreshed.status !== 'found') {
return { status: 'reconnect-required' }
}
try {
return { status: 'ok', value: await operation(refreshed.session) }
} catch (retryError) {
// Why: a 401 after a successful refresh means the session itself is
// rejected. A 403 is an authorization (permission) failure — signing
// the user out for it would destroy a valid session, so let it surface
// as a failed operation instead.
if (retryError instanceof OrcaCloudRequestError && retryError.statusCode === 401) {
clearCloudSessionIfUnchanged(active.profile.id, userDataPath, refreshed.session)
return { status: 'reconnect-required' }
}
throw retryError
}
}
}
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { OrcaCloudSession } from './profile-cloud-session-store'
const safeStorageMock = vi.hoisted(() => ({
decryptString: vi.fn((value: Buffer) => value.toString('utf-8')),
encryptString: vi.fn((value: string) => Buffer.from(value, 'utf-8')),
isEncryptionAvailable: vi.fn(() => true)
}))
let userDataPath = ''
vi.mock('electron', () => ({
app: {
getPath: () => userDataPath
},
safeStorage: safeStorageMock
}))
async function loadSessionStore() {
vi.resetModules()
return import('./profile-cloud-session-store')
}
function makeSession(): OrcaCloudSession {
return {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresAt: 9_999,
organizations: [
{ orgId: 'org-1', name: 'Acme', role: 'Admin' },
{ orgId: 'org-2', name: 'Personal' }
],
capabilities: {
flags: { share: true },
refreshedAt: 123
}
}
}
function writePlaintextSessionFile(profileId: string, session: OrcaCloudSession): void {
const profileDirectory = join(userDataPath, 'profiles', profileId)
mkdirSync(profileDirectory, { recursive: true })
writeFileSync(
join(profileDirectory, 'account-session.json.enc'),
JSON.stringify(
{
version: 1,
format: 'dev-plaintext-v1',
savedAt: 1,
session
},
null,
2
),
'utf-8'
)
}
describe('Orca cloud session store', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-session-'))
vi.unstubAllEnvs()
safeStorageMock.decryptString.mockClear()
safeStorageMock.encryptString.mockClear()
safeStorageMock.isEncryptionAvailable.mockClear()
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
vi.unstubAllEnvs()
})
it('persists encrypted sessions and reports encrypted persistence from memory and disk', async () => {
const store = await loadSessionStore()
const session = makeSession()
expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('encrypted')
expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'found',
session,
persistence: 'encrypted'
})
const reloaded = await loadSessionStore()
expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'found',
session,
persistence: 'encrypted'
})
})
it('falls back to memory-only when encryption is unavailable and plaintext is not allowed', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
const store = await loadSessionStore()
const session = makeSession()
expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('memory-only')
expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'found',
session,
persistence: 'memory-only'
})
const reloaded = await loadSessionStore()
expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'missing',
persistence: 'none'
})
})
it('scopes memory-only sessions by user-data path and profile ID', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
const otherUserDataPath = mkdtempSync(join(tmpdir(), 'orca-cloud-session-other-'))
const store = await loadSessionStore()
const session = makeSession()
const otherSession = { ...session, accessToken: 'other-access-token' }
try {
store.saveOrcaCloudSession('local-default', userDataPath, session)
store.saveOrcaCloudSession('local-default', otherUserDataPath, otherSession)
expect(store.readOrcaCloudSession('local-default', userDataPath)).toMatchObject({
status: 'found',
session
})
expect(store.readOrcaCloudSession('local-default', otherUserDataPath)).toMatchObject({
status: 'found',
session: otherSession
})
} finally {
rmSync(otherUserDataPath, { recursive: true, force: true })
}
})
it('writes explicit dev plaintext only when the dev escape hatch is enabled', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
vi.stubEnv('ORCA_CLOUD_ALLOW_PLAINTEXT_SESSION', '1')
vi.stubEnv('NODE_ENV', 'development')
const store = await loadSessionStore()
const session = makeSession()
expect(store.saveOrcaCloudSession('profile-1', userDataPath, session)).toBe('dev-plaintext')
const saved = JSON.parse(
readFileSync(store.getOrcaCloudSessionPath('profile-1', userDataPath), 'utf-8')
) as { format: string }
expect(saved.format).toBe('dev-plaintext-v1')
const reloaded = await loadSessionStore()
expect(reloaded.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'found',
session,
persistence: 'dev-plaintext'
})
})
it('rejects dev plaintext files when the escape hatch is disabled', async () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
writePlaintextSessionFile('profile-1', makeSession())
const store = await loadSessionStore()
expect(store.readOrcaCloudSession('profile-1', userDataPath)).toEqual({
status: 'decrypt-failed',
persistence: 'none',
error: 'Unsafe session format.'
})
})
})
@@ -0,0 +1,209 @@
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { safeStorage } from 'electron'
import { writeSecureJsonFile } from '../../shared/secure-file'
import type {
OrcaCloudCapabilities,
OrcaCloudOrgSummary,
OrcaCloudSessionPersistence
} from '../../shared/orca-profiles'
import { getOrcaProfileDirectory } from './profile-storage-paths'
import { allowsPlaintextOrcaCloudSession } from './profile-cloud-auth-config'
import type { OrcaCloudSessionExchangeResponse } from './profile-cloud-session-exchange'
export type OrcaCloudSession = {
accessToken: string
refreshToken: string
expiresAt: number
capabilities: OrcaCloudCapabilities
organizations?: OrcaCloudOrgSummary[]
}
export type OrcaCloudSessionReadResult =
| { status: 'found'; session: OrcaCloudSession; persistence: OrcaCloudSessionPersistence }
| { status: 'missing'; persistence: 'none' }
| { status: 'decrypt-failed'; persistence: 'none'; error: string }
type PersistedEncryptedSession = {
version: 1
format: 'electron-safe-storage-v1'
savedAt: number
ciphertext: string
}
type PersistedPlaintextSession = {
version: 1
format: 'dev-plaintext-v1'
savedAt: number
session: OrcaCloudSession
}
type CachedOrcaCloudSession = {
session: OrcaCloudSession
persistence: Exclude<OrcaCloudSessionPersistence, 'none'>
}
const memorySessions = new Map<string, CachedOrcaCloudSession>()
function sessionCacheKey(profileId: string, userDataPath: string): string {
return `${userDataPath}\0${profileId}`
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isOrcaCloudSession(value: unknown): value is OrcaCloudSession {
if (!isObject(value) || !isObject(value.capabilities) || !isObject(value.capabilities.flags)) {
return false
}
if (value.organizations !== undefined && !isOrcaCloudOrganizations(value.organizations)) {
return false
}
return (
typeof value.accessToken === 'string' &&
value.accessToken.length > 0 &&
typeof value.refreshToken === 'string' &&
value.refreshToken.length > 0 &&
typeof value.expiresAt === 'number' &&
Number.isFinite(value.expiresAt) &&
typeof value.capabilities.refreshedAt === 'number' &&
Number.isFinite(value.capabilities.refreshedAt)
)
}
function isOrcaCloudOrganizations(value: unknown): value is OrcaCloudOrgSummary[] {
if (!Array.isArray(value)) {
return false
}
return value.every((organization) => {
if (!isObject(organization)) {
return false
}
return (
typeof organization.orgId === 'string' &&
organization.orgId.length > 0 &&
typeof organization.name === 'string' &&
organization.name.length > 0 &&
(organization.role === undefined || typeof organization.role === 'string')
)
})
}
export function getOrcaCloudSessionPath(profileId: string, userDataPath: string): string {
return join(getOrcaProfileDirectory(profileId, userDataPath), 'account-session.json.enc')
}
export function saveOrcaCloudSession(
profileId: string,
userDataPath: string,
session: OrcaCloudSession
): OrcaCloudSessionPersistence {
const cacheKey = sessionCacheKey(profileId, userDataPath)
if (safeStorage.isEncryptionAvailable()) {
const encrypted: PersistedEncryptedSession = {
version: 1,
format: 'electron-safe-storage-v1',
savedAt: Date.now(),
ciphertext: safeStorage.encryptString(JSON.stringify(session)).toString('base64')
}
writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), encrypted)
memorySessions.set(cacheKey, { session, persistence: 'encrypted' })
return 'encrypted'
}
if (allowsPlaintextOrcaCloudSession()) {
const plaintext: PersistedPlaintextSession = {
version: 1,
format: 'dev-plaintext-v1',
savedAt: Date.now(),
session
}
writeSecureJsonFile(getOrcaCloudSessionPath(profileId, userDataPath), plaintext)
memorySessions.set(cacheKey, { session, persistence: 'dev-plaintext' })
return 'dev-plaintext'
}
// Why: Orca account refresh tokens must not silently fall back to plaintext
// in production. Memory-only keeps cloud features usable until restart.
memorySessions.set(cacheKey, { session, persistence: 'memory-only' })
return 'memory-only'
}
export function saveOrcaCloudSessionExchange(
profileId: string,
userDataPath: string,
exchange: OrcaCloudSessionExchangeResponse
): OrcaCloudSessionPersistence {
return saveOrcaCloudSession(profileId, userDataPath, {
accessToken: exchange.accessToken,
refreshToken: exchange.refreshToken,
expiresAt: exchange.expiresAt,
organizations: exchange.organizations,
capabilities: exchange.capabilities
})
}
export function readOrcaCloudSession(
profileId: string,
userDataPath: string
): OrcaCloudSessionReadResult {
const cacheKey = sessionCacheKey(profileId, userDataPath)
const memorySession = memorySessions.get(cacheKey)
if (memorySession) {
return {
status: 'found',
session: memorySession.session,
persistence: memorySession.persistence
}
}
const path = getOrcaCloudSessionPath(profileId, userDataPath)
if (!existsSync(path)) {
return { status: 'missing', persistence: 'none' }
}
try {
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as
| PersistedEncryptedSession
| PersistedPlaintextSession
if (parsed.version !== 1) {
return { status: 'decrypt-failed', persistence: 'none', error: 'Unsupported session format.' }
}
if (parsed.format === 'electron-safe-storage-v1') {
if (!safeStorage.isEncryptionAvailable()) {
return {
status: 'decrypt-failed',
persistence: 'none',
error: 'OS-backed encryption is unavailable.'
}
}
const decrypted = safeStorage.decryptString(Buffer.from(parsed.ciphertext, 'base64'))
const session = JSON.parse(decrypted) as OrcaCloudSession
if (!isOrcaCloudSession(session)) {
return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' }
}
memorySessions.set(cacheKey, { session, persistence: 'encrypted' })
return { status: 'found', session, persistence: 'encrypted' }
}
if (parsed.format === 'dev-plaintext-v1' && allowsPlaintextOrcaCloudSession()) {
if (!isOrcaCloudSession(parsed.session)) {
return { status: 'decrypt-failed', persistence: 'none', error: 'Invalid saved session.' }
}
memorySessions.set(cacheKey, { session: parsed.session, persistence: 'dev-plaintext' })
return { status: 'found', session: parsed.session, persistence: 'dev-plaintext' }
}
return { status: 'decrypt-failed', persistence: 'none', error: 'Unsafe session format.' }
} catch {
return {
status: 'decrypt-failed',
persistence: 'none',
error: 'Could not decrypt saved Orca account session.'
}
}
}
export function clearOrcaCloudSession(profileId: string, userDataPath: string): void {
memorySessions.delete(sessionCacheKey(profileId, userDataPath))
rmSync(getOrcaCloudSessionPath(profileId, userDataPath), { force: true })
}
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import {
DEFAULT_LOCAL_ORCA_PROFILE_ID,
DEFAULT_LOCAL_ORCA_PROFILE_NAME,
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
type OrcaProfileIndex
} from '../../shared/orca-profiles'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
}
}))
async function loadProfileIndexStore() {
vi.resetModules()
return import('./profile-index-store')
}
function readJson(path: string): unknown {
return JSON.parse(readFileSync(path, 'utf-8'))
}
describe('profile index store', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-test-'))
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('creates the default local profile and copies legacy state without deleting it', async () => {
const legacyState = { schemaVersion: 1, repos: [{ id: 'repo-1' }] }
const legacyBackup = { schemaVersion: 1, repos: [{ id: 'backup-repo' }] }
const legacyBrowserSessionMeta = {
defaultSource: { browserFamily: 'chrome', importedAt: 1 },
profiles: []
}
writeFileSync(join(testState.dir, 'orca-data.json'), JSON.stringify(legacyState), 'utf-8')
writeFileSync(
join(testState.dir, 'orca-data.json.bak.0'),
JSON.stringify(legacyBackup),
'utf-8'
)
writeFileSync(
join(testState.dir, 'browser-session-meta.json'),
JSON.stringify(legacyBrowserSessionMeta),
'utf-8'
)
const { ensureActiveOrcaProfile, getOrcaProfileIndexPath } = await loadProfileIndexStore()
const activeProfile = ensureActiveOrcaProfile()
expect(activeProfile.profile.id).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID)
expect(activeProfile.profile.name).toBe(DEFAULT_LOCAL_ORCA_PROFILE_NAME)
expect(activeProfile.dataFile).toBe(
join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json')
)
expect(readJson(activeProfile.dataFile)).toEqual(legacyState)
expect(readJson(`${activeProfile.dataFile}.bak.0`)).toEqual(legacyBackup)
expect(
readJson(
join(testState.dir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'browser-session-meta.json')
)
).toEqual(legacyBrowserSessionMeta)
expect(existsSync(join(testState.dir, 'orca-data.json'))).toBe(true)
expect(readJson(getOrcaProfileIndexPath())).toMatchObject({
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: DEFAULT_LOCAL_ORCA_PROFILE_ID,
profiles: [expect.objectContaining({ id: DEFAULT_LOCAL_ORCA_PROFILE_ID, kind: 'local' })]
})
})
it('uses an existing active profile data file without overwriting it from legacy state', async () => {
const profileId = 'work-profile'
const profileDirectory = join(testState.dir, 'profiles', profileId)
const profileData = { schemaVersion: 1, repos: [{ id: 'profile-repo' }] }
mkdirSync(profileDirectory, { recursive: true })
writeFileSync(join(profileDirectory, 'orca-data.json'), JSON.stringify(profileData), 'utf-8')
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({ schemaVersion: 1, repos: [{ id: 'legacy-repo' }] }),
'utf-8'
)
const index: OrcaProfileIndex = {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: profileId,
profiles: [
{
id: profileId,
name: 'Work',
avatar: { kind: 'initials', initials: 'W', color: 'neutral' },
kind: 'local',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}
]
}
writeFileSync(join(testState.dir, 'orca-profile-index.json'), JSON.stringify(index), 'utf-8')
const { ensureActiveOrcaProfile } = await loadProfileIndexStore()
const activeProfile = ensureActiveOrcaProfile()
expect(activeProfile.profile.id).toBe(profileId)
expect(activeProfile.dataFile).toBe(join(profileDirectory, 'orca-data.json'))
expect(readJson(activeProfile.dataFile)).toEqual(profileData)
})
it('creates an empty local profile without copying legacy state into it', async () => {
writeFileSync(
join(testState.dir, 'orca-data.json'),
JSON.stringify({ schemaVersion: 1, repos: [{ id: 'legacy-repo' }] }),
'utf-8'
)
const { createLocalOrcaProfile, getOrcaProfileDataFile, getOrcaProfileListState } =
await loadProfileIndexStore()
const created = createLocalOrcaProfile({ name: ' Work ' })
expect(created.profile.name).toBe('Work')
expect(created.profile.id).toMatch(/^local-/)
expect(created.activeProfileId).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID)
expect(created.profiles.map((profile) => profile.id)).toContain(created.profile.id)
expect(existsSync(getOrcaProfileDataFile(created.profile.id))).toBe(false)
expect(getOrcaProfileListState().profiles.map((profile) => profile.id)).toContain(
created.profile.id
)
})
it('switches the active profile and updates last-opened metadata', async () => {
const { createLocalOrcaProfile, setActiveOrcaProfile } = await loadProfileIndexStore()
const created = createLocalOrcaProfile({ name: 'Work' })
const switched = setActiveOrcaProfile(created.profile.id)
expect(switched.activeProfileId).toBe(created.profile.id)
expect(switched.profiles.find((profile) => profile.id === created.profile.id)).toMatchObject({
id: created.profile.id,
lastOpenedAt: expect.any(Number)
})
})
it('rejects switching to an unknown profile', async () => {
const { setActiveOrcaProfile } = await loadProfileIndexStore()
expect(() => setActiveOrcaProfile('missing-profile')).toThrow('unknown_orca_profile')
})
it('recovers a corrupted profile index from the backup copy', async () => {
const store = await loadProfileIndexStore()
store.ensureActiveOrcaProfile()
const created = store.createLocalOrcaProfile({ name: 'Work' })
// Trigger one more write so the backup captures the two-profile index.
store.setActiveOrcaProfile(created.profile.id)
const indexPath = store.getOrcaProfileIndexPath()
expect(existsSync(`${indexPath}.bak`)).toBe(true)
writeFileSync(indexPath, '{ not json', 'utf-8')
const recovered = store.getOrcaProfileListState()
expect(recovered.profiles.map((profile) => profile.id)).toContain(created.profile.id)
expect(recovered.profiles.length).toBeGreaterThanOrEqual(2)
})
it('rejects profile ids that are not safe path segments', async () => {
const store = await loadProfileIndexStore()
const indexPath = store.getOrcaProfileIndexPath()
const index: OrcaProfileIndex = {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: '../../escape',
profiles: [
{
id: '../../escape',
name: 'Evil',
avatar: { kind: 'initials', initials: 'E', color: 'neutral' },
kind: 'local',
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}
]
}
mkdirSync(testState.dir, { recursive: true })
writeFileSync(indexPath, JSON.stringify(index), 'utf-8')
// The tampered entry is filtered; startup falls back to a fresh default.
const state = store.ensureActiveOrcaProfile()
expect(state.profile.id).toBe(DEFAULT_LOCAL_ORCA_PROFILE_ID)
})
})
@@ -0,0 +1,327 @@
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync
} from 'node:fs'
import { randomUUID } from 'node:crypto'
import { dirname } from 'node:path'
import type { GlobalSettings } from '../../shared/types'
import {
createDefaultLocalOrcaProfile,
DEFAULT_LOCAL_ORCA_PROFILE_ID,
DEFAULT_LOCAL_ORCA_PROFILE_NAME,
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
type CreateLocalOrcaProfileArgs,
type CreateLocalOrcaProfileResult,
type OrcaProfileIndex,
type OrcaProfileListState,
type OrcaProfileSummary
} from '../../shared/orca-profiles'
import {
getOrcaProfileBrowserSessionMetaFile,
getOrcaProfileDataFile,
getOrcaProfileDirectory,
getOrcaProfileIndexPath,
getProfileUserDataPath,
LEGACY_BACKUP_COUNT,
legacyBackupPath,
legacyBrowserSessionMetaPath,
legacyDataFilePath,
profileBackupPath
} from './profile-storage-paths'
export {
getOrcaProfileBrowserSessionMetaFile,
getOrcaProfileDataFile,
getOrcaProfileDirectory,
getOrcaProfileIndexPath,
getOrcaProfilesDirectory,
initOrcaProfilePaths
} from './profile-storage-paths'
export type ActiveOrcaProfileState = {
index: OrcaProfileIndex
profile: OrcaProfileSummary
dataFile: string
profileDirectory: string
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isProfileSummary(value: unknown): value is OrcaProfileSummary {
if (!isObject(value)) {
return false
}
const avatar = value.avatar
const cloud = value.cloud
return (
typeof value.id === 'string' &&
// Why: IDs from the on-disk index become filesystem path segments; a
// tampered index must not be able to escape the profiles directory.
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value.id) &&
typeof value.name === 'string' &&
value.name.length > 0 &&
(value.kind === 'local' || value.kind === 'cloud-linked') &&
typeof value.createdAt === 'number' &&
typeof value.updatedAt === 'number' &&
typeof value.lastOpenedAt === 'number' &&
isObject(avatar) &&
avatar.kind === 'initials' &&
typeof avatar.initials === 'string' &&
avatar.color === 'neutral' &&
(cloud === undefined || isObject(cloud))
)
}
function normalizeProfileIndex(raw: unknown): OrcaProfileIndex | null {
if (!isObject(raw) || !Array.isArray(raw.profiles)) {
return null
}
const profiles = raw.profiles.filter(isProfileSummary)
const activeProfileId =
typeof raw.activeProfileId === 'string' &&
profiles.some((profile) => profile.id === raw.activeProfileId)
? raw.activeProfileId
: profiles[0]?.id
if (!activeProfileId) {
return null
}
return {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId,
profiles
}
}
function sanitizeProfileName(value: unknown): string {
const trimmed = typeof value === 'string' ? value.trim() : ''
return trimmed.length > 0 ? trimmed.slice(0, 80) : 'New Profile'
}
function readProfileIndexFile(indexPath: string): OrcaProfileIndex | null {
try {
return normalizeProfileIndex(JSON.parse(readFileSync(indexPath, 'utf-8')))
} catch {
return null
}
}
export function readProfileIndex(indexPath: string): OrcaProfileIndex | null {
// Why: a torn/corrupt index must not silently reset the app to a single
// default profile — that would orphan every other profile's data directory.
return readProfileIndexFile(indexPath) ?? readProfileIndexFile(`${indexPath}.bak`)
}
export function writeProfileIndex(indexPath: string, index: OrcaProfileIndex): void {
mkdirSync(dirname(indexPath), { recursive: true })
// Why: only a still-parseable current index may refresh the backup;
// copying a corrupt file over the backup would destroy the recovery copy.
if (existsSync(indexPath) && readProfileIndexFile(indexPath)) {
try {
copyFileSync(indexPath, `${indexPath}.bak`)
} catch {
// Best-effort backup; the primary write below still proceeds.
}
}
const tmpPath = `${indexPath}.tmp`
writeFileSync(tmpPath, JSON.stringify(index, null, 2), 'utf-8')
renameSync(tmpPath, indexPath)
}
function copyIfPresent(source: string, target: string): void {
if (!existsSync(source) || existsSync(target)) {
return
}
mkdirSync(dirname(target), { recursive: true })
// Why: tmp+rename so a crash mid-copy cannot leave a truncated target that
// the exists() guard above would then treat as a completed migration.
const tmpTarget = `${target}.tmp`
copyFileSync(source, tmpTarget)
renameSync(tmpTarget, target)
}
function copyLegacyStateToProfile(userDataPath: string, profileId: string): void {
const profileDataFile = getOrcaProfileDataFile(profileId, userDataPath)
copyIfPresent(legacyDataFilePath(userDataPath), profileDataFile)
copyIfPresent(
legacyBrowserSessionMetaPath(userDataPath),
getOrcaProfileBrowserSessionMetaFile(profileId, userDataPath)
)
for (let i = 0; i < LEGACY_BACKUP_COUNT; i++) {
copyIfPresent(legacyBackupPath(userDataPath, i), profileBackupPath(profileDataFile, i))
}
}
// Why: a brand-new profile has no data file, which the telemetry cohort
// migration reads as a fresh install and defaults to opted-in. Copying the
// active profile's consent block keeps an opted-out user opted out (and keeps
// one installId per install) when they create additional profiles.
export function seedNewOrcaProfileTelemetryConsent(
profileId: string,
telemetry: GlobalSettings['telemetry'],
userDataPath = getProfileUserDataPath()
): void {
if (!telemetry) {
return
}
const dataFile = getOrcaProfileDataFile(profileId, userDataPath)
if (existsSync(dataFile)) {
return
}
mkdirSync(dirname(dataFile), { recursive: true })
const tmpPath = `${dataFile}.tmp`
writeFileSync(tmpPath, JSON.stringify({ settings: { telemetry } }, null, 2), 'utf-8')
renameSync(tmpPath, dataFile)
}
function createInitialProfileIndex(now = Date.now()): OrcaProfileIndex {
const profile = createDefaultLocalOrcaProfile(now)
return {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId: profile.id,
profiles: [profile]
}
}
export function loadOrCreateProfileIndex(userDataPath: string): OrcaProfileIndex {
const indexPath = getOrcaProfileIndexPath(userDataPath)
const index = existsSync(indexPath) ? readProfileIndex(indexPath) : null
if (index) {
return index
}
const nextIndex = createInitialProfileIndex()
writeProfileIndex(indexPath, nextIndex)
return nextIndex
}
function getActiveProfile(index: OrcaProfileIndex): OrcaProfileSummary {
return (
index.profiles.find((profile) => profile.id === index.activeProfileId) ??
index.profiles[0] ??
createDefaultLocalOrcaProfile(Date.now())
)
}
export function ensureActiveOrcaProfile(
userDataPath = getProfileUserDataPath()
): ActiveOrcaProfileState {
const indexPath = getOrcaProfileIndexPath(userDataPath)
let index = existsSync(indexPath) ? readProfileIndex(indexPath) : null
let shouldWriteIndex = false
if (!index) {
index = createInitialProfileIndex()
shouldWriteIndex = true
}
const activeProfile = getActiveProfile(index)
if (activeProfile.id !== index.activeProfileId) {
index = { ...index, activeProfileId: activeProfile.id }
shouldWriteIndex = true
}
const profileDirectory = getOrcaProfileDirectory(activeProfile.id, userDataPath)
mkdirSync(profileDirectory, { recursive: true })
if (activeProfile.id === DEFAULT_LOCAL_ORCA_PROFILE_ID) {
copyLegacyStateToProfile(userDataPath, activeProfile.id)
}
if (shouldWriteIndex) {
writeProfileIndex(indexPath, index)
}
return {
index,
profile: activeProfile,
dataFile: getOrcaProfileDataFile(activeProfile.id, userDataPath),
profileDirectory
}
}
export function isDefaultLocalOrcaProfileId(profileId: string): boolean {
return profileId === DEFAULT_LOCAL_ORCA_PROFILE_ID
}
export function getOrcaProfileListState(
userDataPath = getProfileUserDataPath()
): OrcaProfileListState {
const { index } = ensureActiveOrcaProfile(userDataPath)
return {
activeProfileId: index.activeProfileId,
profiles: index.profiles
}
}
export function createLocalOrcaProfile(
args: CreateLocalOrcaProfileArgs = {},
userDataPath = getProfileUserDataPath()
): CreateLocalOrcaProfileResult {
const index = loadOrCreateProfileIndex(userDataPath)
const now = Date.now()
const name = sanitizeProfileName(args.name)
const profile: OrcaProfileSummary = {
id: `local-${randomUUID()}`,
name,
avatar: {
kind: 'initials',
initials: (
name.match(/[A-Za-z0-9]/)?.[0] ?? DEFAULT_LOCAL_ORCA_PROFILE_NAME[0]
).toUpperCase(),
color: 'neutral'
},
kind: 'local',
createdAt: now,
updatedAt: now,
lastOpenedAt: now
}
const nextIndex: OrcaProfileIndex = {
...index,
profiles: [...index.profiles, profile]
}
mkdirSync(getOrcaProfileDirectory(profile.id, userDataPath), { recursive: true })
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles,
profile
}
}
export function setActiveOrcaProfile(
profileId: string,
userDataPath = getProfileUserDataPath()
): OrcaProfileListState {
const index = loadOrCreateProfileIndex(userDataPath)
const now = Date.now()
let found = false
const profiles = index.profiles.map((profile) => {
if (profile.id !== profileId) {
return profile
}
found = true
return {
...profile,
updatedAt: now,
lastOpenedAt: now
}
})
if (!found) {
throw new Error('unknown_orca_profile')
}
const nextIndex: OrcaProfileIndex = {
...index,
activeProfileId: profileId,
profiles
}
mkdirSync(getOrcaProfileDirectory(profileId, userDataPath), { recursive: true })
writeProfileIndex(getOrcaProfileIndexPath(userDataPath), nextIndex)
return {
activeProfileId: nextIndex.activeProfileId,
profiles: nextIndex.profiles
}
}
@@ -0,0 +1,145 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultPersistedState } from '../../shared/constants'
import {
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
type OrcaProfileIndex,
type OrcaProfileKind
} from '../../shared/orca-profiles'
import type { PersistedState, Repo } from '../../shared/types'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
}
}))
async function loadPresenceModule() {
vi.resetModules()
return import('./profile-project-presence')
}
function profile(
id: string,
name: string,
kind: OrcaProfileKind = 'local'
): OrcaProfileIndex['profiles'][number] {
return {
id,
name,
avatar: { kind: 'initials', initials: name[0], color: 'neutral' },
kind,
createdAt: 1,
updatedAt: 1,
lastOpenedAt: 1
}
}
function writeIndex(activeProfileId = 'personal'): void {
const index: OrcaProfileIndex = {
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
activeProfileId,
profiles: [profile('personal', 'Personal'), profile('work', 'Work')]
}
writeFileSync(join(testState.dir, 'orca-profile-index.json'), JSON.stringify(index), 'utf-8')
}
function writeProfileState(profileId: string, repos: Repo[]): void {
const state: PersistedState = {
...getDefaultPersistedState('/Users/tester'),
repos
}
const dataFile = join(testState.dir, 'profiles', profileId, 'orca-data.json')
mkdirSync(dirname(dataFile), { recursive: true })
writeFileSync(dataFile, JSON.stringify(state), 'utf-8')
}
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/workspace/orca',
displayName: 'Orca',
badgeColor: '#33aa99',
addedAt: 100,
kind: 'git',
connectionId: null,
...overrides
}
}
describe('profile project presence', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-presence-'))
writeIndex()
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('finds matching projects in other profiles while excluding the active profile', async () => {
writeProfileState('personal', [
makeRepo({ id: 'personal-repo', path: 'C:\\Code\\Orca', displayName: 'Personal Orca' })
])
writeProfileState('work', [
makeRepo({ id: 'work-repo', path: 'C:\\Code\\Orca', displayName: 'Work Orca' })
])
const { findOrcaProfileProjectsByPath } = await loadPresenceModule()
const result = findOrcaProfileProjectsByPath(
{
path: 'c:/code/orca/',
executionHostId: 'local',
excludeProfileId: 'personal'
},
testState.dir
)
expect(result.projects).toEqual([
{
profileId: 'work',
profileName: 'Work',
profileKind: 'local',
repoId: 'work-repo',
repoName: 'Work Orca'
}
])
})
it('keeps SSH projects separate from local projects with the same path', async () => {
writeProfileState('personal', [
makeRepo({ id: 'local-repo', path: '/srv/orca', displayName: 'Local Orca' })
])
writeProfileState('work', [
makeRepo({
id: 'ssh-repo',
path: '/srv/orca',
displayName: 'SSH Orca',
connectionId: 'builder',
executionHostId: 'ssh:builder'
})
])
const { findOrcaProfileProjectsByPath } = await loadPresenceModule()
const result = findOrcaProfileProjectsByPath(
{
path: '/srv/orca',
connectionId: 'builder',
executionHostId: 'ssh:builder'
},
testState.dir
)
expect(result.projects).toEqual([
expect.objectContaining({
profileId: 'work',
repoId: 'ssh-repo',
repoName: 'SSH Orca'
})
])
})
})

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