mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
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:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 5
|
||||
"versionCode": 6
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user