mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
Merge origin/main into brennanb2025/oom-hidden-pane-parking; default parking ON
Resolve agent-detection.ts split-vs-monolith conflict by porting main's Gemini/Pi title fixes (#5270 masquerade guard, isLegacyPiCompatibleTitle) into the split module agent-title-core.ts; keep the compatibility barrel. Flip terminalHiddenViewParking default OFF->ON (byte mode). The three authority/gate/query flags stay false (no main emitter in this build). Update the byte-mode guard test, docs, reliability gate, and e2e comments. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -883,12 +883,32 @@ jobs:
|
||||
if: matrix.platform == 'win'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Why: Some hosted Windows images start without PSGallery registered.
|
||||
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSRepository -Default -InstallationPolicy Trusted
|
||||
}
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# Why: force TLS 1.2 so gallery downloads work on older hosted images.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
|
||||
# Why: on some hosted Windows images `Register-PSRepository -Default`
|
||||
# fails inside the legacy nuget.exe provider with "Missing option value
|
||||
# for: '-source'", so PSGallery is never registered and the install
|
||||
# below dies with "No repository with the name 'PSGallery'". PSResourceGet
|
||||
# (bundled with PowerShell 7.4+) has PSGallery registered by default and
|
||||
# avoids that code path, so prefer it and fall back to PowerShellGet only
|
||||
# when it is absent.
|
||||
$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
|
||||
}
|
||||
} else {
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null
|
||||
if ($null -eq (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSRepository -Default -InstallationPolicy Trusted
|
||||
}
|
||||
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
|
||||
}
|
||||
|
||||
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
||||
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
|
||||
@@ -917,7 +937,11 @@ jobs:
|
||||
}
|
||||
|
||||
try {
|
||||
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||
if ($useResourceGet) {
|
||||
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
|
||||
break
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
|
||||
<sub><a href="docs/readme/README.es.md">Español</a> · <a href="docs/readme/README.pt.md">Português</a> · <a href="docs/readme/README.zh-CN.md">中文</a> · <a href="docs/readme/README.ja.md">日本語</a> · <a href="docs/readme/README.ko.md">한국어</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/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.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/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.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -256,6 +256,10 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
|
||||
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca contributors" />
|
||||
</a>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
Orca is free and open source under the [MIT License](LICENSE).
|
||||
|
||||
@@ -384,3 +384,27 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
|
||||
}
|
||||
}
|
||||
#endif
|
||||
diff --git a/lib/utils.js b/lib/utils.js
|
||||
--- a/lib/utils.js
|
||||
+++ b/lib/utils.js
|
||||
@@ -19,7 +19,20 @@ function loadNativeModule(name) {
|
||||
var dirs = ['build/Release', 'build/Debug', "prebuilds/" + process.platform + "-" + process.arch];
|
||||
// Check relative to the parent dir for unbundled and then the current dir for bundled
|
||||
var relative = ['..', '.'];
|
||||
var lastError;
|
||||
+ // Why (Orca): the Windows updater force-closes every process whose image
|
||||
+ // lives under the app install dir; this env var relocates the native
|
||||
+ // binaries (conpty.dll spawns OpenConsole.exe from beside itself) so PTY
|
||||
+ // console hosts run outside it and survive updates.
|
||||
+ var overrideDir = process.env.ORCA_NODE_PTY_NATIVE_DIR;
|
||||
+ if (overrideDir) {
|
||||
+ try {
|
||||
+ return { dir: overrideDir, module: require(overrideDir + "/" + name + ".node") };
|
||||
+ }
|
||||
+ catch (e) {
|
||||
+ lastError = e;
|
||||
+ }
|
||||
+ }
|
||||
for (var _i = 0, dirs_1 = dirs; _i < dirs_1.length; _i++) {
|
||||
var d = dirs_1[_i];
|
||||
for (var _a = 0, relative_1 = relative; _a < relative_1.length; _a++) {
|
||||
|
||||
@@ -2226,11 +2226,11 @@
|
||||
"promotionCriteria": [
|
||||
"Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.",
|
||||
"Confirm the memory relief and notification correctness on a real Windows multi-agent workspace with parking opted in.",
|
||||
"Land the three parking e2e specs in the scheduled E2E suite with green history before flipping terminalHiddenViewParking on by default."
|
||||
"Land the three parking e2e specs in the scheduled E2E suite with green history to keep terminalHiddenViewParking on by default."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The three Electron e2e specs are scheduled-suite only, not PR-blocking CI.",
|
||||
"Windows and Linux runs pending; parking itself ships default-off until the soak completes.",
|
||||
"Linux run pending; parking ships default-on (byte mode) with a kill switch.",
|
||||
"Fact mode (main-process side-effect authority) is intentionally absent until PR #7214 lands its emitter; the guard pins byte mode until then."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.5m">
|
||||
<title>downloads: 3.5m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.6m">
|
||||
<title>downloads: 3.6m</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">3.5m</text>
|
||||
<text x="93.5" y="14">3.5m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.6m</text>
|
||||
<text x="93.5" y="14">3.6m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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.21/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.22/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.21/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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.21/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.22/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.21/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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.21/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.22/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.21/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<h1 align="center">
|
||||
<a href="https://onOrca.dev"><img src="../../resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca?label=%E2%98%85" alt="Estrelas no GitHub" /></a>
|
||||
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Total de downloads em todas as versões" /></a>
|
||||
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licença" />
|
||||
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Entre no Discord do Orca" /></a>
|
||||
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Siga o Orca no X" /></a>
|
||||
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatíveis: macOS, Windows e Linux" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.zh-CN.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>O orquestrador de IA para builders 100x.</strong><br/>
|
||||
Rode Codex, ClaudeCode, OpenCode ou Pi lado a lado — cada um em seu próprio worktree, acompanhado em um só lugar.
|
||||
</p>
|
||||
|
||||
<h3 align="center"><a href="https://onorca.dev/download"><ins>Baixar o Orca</ins></a></h3>
|
||||
|
||||
<p align="center">
|
||||
<img src="../assets/readme-hero.jpg" alt="App desktop do Orca executando agentes em worktrees paralelos, com o app companion móvel do Orca no canto" width="960" />
|
||||
</p>
|
||||
|
||||
## Recursos
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Companion móvel
|
||||
|
||||
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.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="../assets/feature-wall/mobile-companion-app-showcase.gif" type="image/gif"><img src="../assets/feature-wall/mobile-companion-app-showcase.jpg" alt="Orca desktop com o app companion móvel" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Worktrees paralelos
|
||||
|
||||
Envie um mesmo prompt para cinco agentes, cada um em seu próprio worktree git isolado — compare os resultados e faça merge do vencedor.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/model/worktrees)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="../assets/feature-wall/parallel-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/parallel-worktrees.jpg" alt="Orquestração de worktrees paralelos" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Terminais divididos
|
||||
|
||||
Terminais no nível do Ghostty com renderização WebGL, divisões infinitas e scrollback que sobrevive a reinicializações.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/terminal)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/terminal"><picture><source srcset="../assets/feature-wall/terminal-splits.gif" type="image/gif"><img src="../assets/feature-wall/terminal-splits.jpg" alt="Terminais divididos" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Modo Design
|
||||
|
||||
Clique em qualquer elemento de UI em uma janela real do Chromium para enviar HTML, CSS e uma captura recortada direto para o prompt do seu agente.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/browser/design-mode)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="../assets/feature-wall/design-mode.gif" type="image/gif"><img src="../assets/feature-wall/design-mode.jpg" alt="Navegador integrado e Modo Design" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### GitHub e Linear nativos
|
||||
|
||||
Navegue por PRs, issues e quadros de projeto dentro do app — abra um worktree a partir de qualquer tarefa e revise sem trocar de contexto.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/review/linear)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="../assets/feature-wall/github-linear.gif" type="image/gif"><img src="../assets/feature-wall/github-linear.jpg" alt="Fluxos de trabalho de tarefas do GitHub e Linear no Orca" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Worktrees por SSH
|
||||
|
||||
Execute agentes em uma máquina remota potente com edição completa de arquivos, git e terminais — com reconexão automática e encaminhamento de portas incluídos.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/ssh)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/ssh"><picture><source srcset="../assets/feature-wall/ssh-worktrees.gif" type="image/gif"><img src="../assets/feature-wall/ssh-worktrees.jpg" alt="Worktrees remotos por SSH" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Anotar diffs de IA
|
||||
|
||||
Deixe comentários em qualquer linha de diff e envie-os de volta ao agente — revise, edite e faça commit sem sair do Orca.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="../assets/feature-wall/annotate-diff.gif" type="image/gif"><img src="../assets/feature-wall/annotate-diff.jpg" alt="Anotar diffs gerados por IA" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Arraste arquivos para agentes
|
||||
|
||||
O editor do VS Code com salvamento automático em todos os lugares — arraste arquivos ou imagens direto para o prompt de um agente.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/editing/file-explorer)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/editing/file-explorer"><picture><source srcset="../assets/feature-wall/file-drag.gif" type="image/gif"><img src="../assets/feature-wall/file-drag.jpg" alt="Arraste arquivos e imagens para o prompt de um agente" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="middle">
|
||||
|
||||
### Orca CLI
|
||||
|
||||
Agentes também controlam o Orca — automatize qualquer fluxo de trabalho com `orca worktree create`, `snapshot`, `click` e `fill`.
|
||||
|
||||
[Docs →](https://www.onorca.dev/docs/cli/overview)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="../assets/feature-wall/orca-cli.gif" type="image/gif"><img src="../assets/feature-wall/orca-cli.jpg" alt="Automatize o Orca pela CLI" width="100%" /></picture></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
**Também incluído:**
|
||||
|
||||
- **[Abertura rápida](https://www.onorca.dev/docs/model/quick-open)** — Pesquise entre worktrees, arquivos, agentes, comandos e contexto do repositório sem sair do seu fluxo.
|
||||
- **[Troca de contas e acompanhamento de uso](https://www.onorca.dev/docs/agents/usage-tracking)** — Veja o uso de Claude e Codex, os reinícios de limites e troque contas instantaneamente sem fazer login de novo.
|
||||
- **[Prévias ricas do repositório](https://www.onorca.dev/docs/editing/markdown)** — Pré-visualize Markdown, imagens, PDFs e documentos do repositório no workspace.
|
||||
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — Deixe agentes operarem apps de desktop e UI visível quando um fluxo de trabalho precisa de interação real.
|
||||
- **[Notificações e estado de não lido](https://www.onorca.dev/docs/notifications)** — Saiba quando um agente termina ou precisa de atenção, depois marque conversas como não lidas para voltar depois.
|
||||
- **E muito, muito mais** — lançamos novidades todos os dias, então esta lista vive atrasada. O [changelog](https://github.com/stablyai/orca/releases) é a lista real de recursos.
|
||||
|
||||
---
|
||||
|
||||
## Agentes compatíveis
|
||||
|
||||
Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca.
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" alt="Logotipo do Claude Code" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" alt="Logotipo do Codex" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Logotipo do Grok" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logotipo do Cursor" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logotipo do GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logotipo do OpenCode" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logotipo do MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logotipo do Amp" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="Logotipo do OpenClaude" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Logotipo do Antigravity" width="16" valign="middle" /> Antigravity</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Logotipo do Pi" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="Logotipo do oh-my-pi" width="16" valign="middle" /> oh-my-pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Logotipo do Hermes Agent" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Logotipo do Devin" width="16" valign="middle" /> Devin</kbd></a>
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Logotipo do Goose" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Logotipo do Auggie" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Logotipo do Autohand Code" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" alt="Logotipo do Charm" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" alt="Logotipo do Cline" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" alt="Logotipo do Codebuff" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://commandcode.ai/docs/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=commandcode.ai&sz=64" alt="Logotipo do Command Code" width="16" valign="middle" /> Command Code</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" alt="Logotipo do Continue" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="../assets/droid-logo.svg" alt="Logotipo do Droid" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" alt="Logotipo do Kilocode" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" alt="Logotipo do Kimi" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" alt="Logotipo do Kiro" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Logotipo do Mistral Vibe" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Logotipo do Qwen Code" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Logotipo do Rovo Dev" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<kbd>+ qualquer agente CLI</kbd>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Instalação
|
||||
|
||||
### Desktop — macOS, Windows, Linux
|
||||
|
||||
- **[Baixe em onOrca.dev](https://onorca.dev/download)**
|
||||
- Ou baixe um build diretamente: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [Todos os builds](https://github.com/stablyai/orca/releases/latest)
|
||||
- Rodando `orca serve` em um servidor Linux headless? Veja o [guia de servidor Linux headless](../reference/headless-linux-server.md).
|
||||
|
||||
_Ou por um gerenciador de pacotes:_
|
||||
|
||||
```bash
|
||||
# macOS (Homebrew)
|
||||
brew install --cask stablyai/orca/orca
|
||||
|
||||
# Arch Linux (AUR) — ou stably-orca-git para compilar a partir do código-fonte
|
||||
yay -S stably-orca-bin
|
||||
```
|
||||
|
||||
### Companion móvel — iOS, Android
|
||||
|
||||
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.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
## Comunidade e suporte
|
||||
|
||||
- **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios.
|
||||
- **WeChat:** Escaneie o QR code para entrar na comunidade.
|
||||
|
||||
<img src="../assets/wechat-qr.png" alt="QR code do WeChat para a comunidade Orca" width="160" />
|
||||
|
||||
- **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues).
|
||||
- **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar.
|
||||
- **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários.
|
||||
|
||||
---
|
||||
|
||||
## Desenvolvimento
|
||||
|
||||
Quer contribuir ou rodar localmente? Veja nosso guia [CONTRIBUTING.md](../../.github/CONTRIBUTING.md).
|
||||
|
||||
<a href="https://github.com/stablyai/orca/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Contribuidores do Orca" />
|
||||
</a>
|
||||
|
||||
## Licença
|
||||
|
||||
Orca é livre e de código aberto sob a [Licença MIT](../../LICENSE).
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
<sub><a href="../../README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt.md">Português</a> · <a href="README.ja.md">日本語</a> · <a href="README.ko.md">한국어</a></sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -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.21/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.22/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.21/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Terminal Hidden View Parking
|
||||
|
||||
Status: Shipped standalone (byte mode only, extracted from PR #7214), kill
|
||||
switch `terminalHiddenViewParking` **default off** — opt-in until the memory
|
||||
win is confirmed in a soak, then a follow-up flips it on. The companion doc
|
||||
switch `terminalHiddenViewParking` **default on** — set it to `false` to
|
||||
disable parking entirely. The companion doc
|
||||
`terminal-model-view-contract.md` (invariants this design extends and the full
|
||||
phase list) ships with PR #7214 together with the fact-mode main emitter; in
|
||||
this build `terminalMainSideEffectAuthority` must stay `false` (see
|
||||
|
||||
@@ -15,6 +15,10 @@ function isConflicting(item: MobilePRAutoMergeAvailabilityInput): boolean {
|
||||
return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY'
|
||||
}
|
||||
|
||||
function isUnstable(item: MobilePRAutoMergeAvailabilityInput): boolean {
|
||||
return item.mergeStateStatus === 'UNSTABLE'
|
||||
}
|
||||
|
||||
function hasReviewRequirement(item: MobilePRAutoMergeAvailabilityInput): boolean {
|
||||
return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED'
|
||||
}
|
||||
@@ -27,7 +31,9 @@ function canMergeImmediately(item: MobilePRAutoMergeAvailabilityInput): boolean
|
||||
}
|
||||
|
||||
function canRequestWhenReady(item: MobilePRAutoMergeAvailabilityInput): boolean {
|
||||
if (item.state !== 'open' || isConflicting(item)) {
|
||||
// Why: GitHub auto-merge waits on unmet requirements; UNSTABLE is rejected
|
||||
// by the mutation rather than becoming a waitable auto-merge request.
|
||||
if (item.state !== 'open' || isConflicting(item) || isUnstable(item)) {
|
||||
return false
|
||||
}
|
||||
if (item.mergeQueueRequired === true) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.122-rc.4",
|
||||
"version": "1.4.124-rc.1",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
Generated
+3
-3
@@ -12,7 +12,7 @@ patchedDependencies:
|
||||
hash: 296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
|
||||
hash: b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88
|
||||
path: config/patches/node-pty@1.1.0.patch
|
||||
|
||||
importers:
|
||||
@@ -54,7 +54,7 @@ importers:
|
||||
version: 3.3.1
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282)
|
||||
version: 1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88)
|
||||
posthog-node:
|
||||
specifier: ^5.33.3
|
||||
version: 5.33.3
|
||||
@@ -11747,7 +11747,7 @@ snapshots:
|
||||
undici: 6.27.0
|
||||
which: 6.0.1
|
||||
|
||||
node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282):
|
||||
node-pty@1.1.0(patch_hash=b354dc2fd021578ac4829e77a2666ff192a517ba3a8428337a70ecea930aea88):
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="36" height="32" viewBox="0 0 36.9143 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="0" y1="15.5" x2="36.9143" y2="15.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E21680"/>
|
||||
<stop offset="1" stop-color="#FF633A"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M17.5802 3.08155C17.5802 2.35036 16.987 1.75702 16.2589 1.75702C15.5309 1.75702 14.9377 2.35171 14.9377 3.08155V25.0348C14.9377 26.7337 13.5586 28.1164 11.8639 28.1164C10.1693 28.1164 8.79008 26.7337 8.79008 25.0348V10.9503C8.79008 10.2191 8.19688 9.62575 7.46886 9.62575C6.74085 9.62575 6.14765 10.2204 6.14765 10.9503V16.9201C6.14765 18.619 4.76846 20.0016 3.07381 20.0016C1.37915 20.0016 0 18.619 0 16.9201V14.7563C0 14.2711 0.392318 13.8778 0.876312 13.8778C1.36031 13.8778 1.75262 14.2711 1.75262 14.7563V16.9201C1.75262 17.6513 2.34579 18.2446 3.07381 18.2446C3.80182 18.2446 4.39502 17.6499 4.39502 16.9201V10.9503C4.39502 9.25138 5.77421 7.86874 7.46886 7.86874C9.16352 7.86874 10.5427 9.25138 10.5427 10.9503V25.0348C10.5427 25.766 11.1359 26.3593 11.8639 26.3593C12.5919 26.3593 13.1851 25.7646 13.1851 25.0348V16.4227V3.08155C13.1851 1.38265 14.5643 0 16.2589 0C17.9536 0 19.3328 1.38265 19.3328 3.08155V21.2599C19.3328 21.7451 18.9405 22.1384 18.4565 22.1384C17.9725 22.1384 17.5802 21.7451 17.5802 21.2599V3.08155ZM33.8405 7.86874C32.1458 7.86874 30.7666 9.25138 30.7666 10.9503V22.7155C30.7666 23.4467 30.1734 24.0401 29.4454 24.0401C28.7174 24.0401 28.1242 23.4454 28.1242 22.7155V3.08155C28.1242 1.38265 26.745 0 25.0504 0C23.3557 0 21.9766 1.38265 21.9766 3.08155V28.0042C21.9766 28.7354 21.3834 29.3287 20.6553 29.3287C19.9273 29.3287 19.3341 28.734 19.3341 28.0042V24.9186C19.3341 24.4334 18.9418 24.0401 18.4578 24.0401C17.9738 24.0401 17.5815 24.4334 17.5815 24.9186V28.0042C17.5815 29.7031 18.9607 31.0857 20.6553 31.0857C22.35 31.0857 23.7292 29.7031 23.7292 28.0042V3.08155C23.7292 2.35036 24.3224 1.75702 25.0504 1.75702C25.7784 1.75702 26.3716 2.35171 26.3716 3.08155V22.7155C26.3716 24.4144 27.7508 25.7971 29.4454 25.7971C31.1401 25.7971 32.5193 24.4144 32.5193 22.7155V10.9503C32.5193 10.2191 33.1125 9.62575 33.8405 9.62575C34.5685 9.62575 35.1617 10.2204 35.1617 10.9503V21.2599C35.1617 21.7451 35.554 22.1384 36.038 22.1384C36.522 22.1384 36.9143 21.7451 36.9143 21.2599V10.9503C36.9143 9.25138 35.5351 7.86874 33.8405 7.86874Z" fill="url(#paint0_linear)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,51 @@
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
|
||||
|
||||
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
|
||||
|
||||
export async function remoteCodexIndexTitles(args: {
|
||||
provider: IFilesystemProvider
|
||||
codexHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
titleCaches: Map<string, Promise<Map<string, string>>>
|
||||
}): Promise<Map<string, string>> {
|
||||
const cached = args.titleCaches.get(args.codexHome)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform)
|
||||
args.titleCaches.set(args.codexHome, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
async function readRemoteCodexIndexTitles(
|
||||
provider: IFilesystemProvider,
|
||||
codexHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): Promise<Map<string, string>> {
|
||||
const titleBySessionId = new Map<string, string>()
|
||||
try {
|
||||
const { content, isBinary } = await provider.readFile(
|
||||
joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE)
|
||||
)
|
||||
if (isBinary) {
|
||||
return titleBySessionId
|
||||
}
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const sessionId = extractString(record.id)
|
||||
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
|
||||
if (sessionId && title) {
|
||||
titleBySessionId.set(sessionId, title)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Codex indexes are opportunistic; raw transcripts remain sufficient.
|
||||
}
|
||||
return titleBySessionId
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
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 {
|
||||
parseCopilotSessionContent,
|
||||
parseCursorSessionContent,
|
||||
parseHermesSessionContent
|
||||
} from './session-scanner-secondary-parsers'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { normalizePiSessionsDir } from './session-scanner-values'
|
||||
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
|
||||
import type {
|
||||
RemoteParserOptions,
|
||||
RemoteScannerContext,
|
||||
RemoteSessionSource
|
||||
} from './remote-session-scanner-types'
|
||||
|
||||
type RemoteContentParser = (
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
) => Promise<AiVaultSession | null> | AiVaultSession | null
|
||||
|
||||
export function remoteSessionSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource[] {
|
||||
return [
|
||||
...remoteCodexSources(remoteHome, hostPlatform),
|
||||
jsonlSource(
|
||||
'claude',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.claude', 'projects'],
|
||||
parseClaudeSessionContent
|
||||
),
|
||||
source(
|
||||
'gemini',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.gemini', 'tmp'],
|
||||
['.json', '.jsonl'],
|
||||
parseGeminiSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'copilot',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.copilot', 'session-state'],
|
||||
parseCopilotSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'cursor',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.cursor', 'projects'],
|
||||
parseCursorSessionContent,
|
||||
(path) => remotePathSegments(path).includes('agent-transcripts')
|
||||
),
|
||||
source(
|
||||
'hermes',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.hermes', 'sessions'],
|
||||
['.json'],
|
||||
parseHermesSessionContent
|
||||
),
|
||||
source(
|
||||
'devin',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.local', 'share', 'devin', 'cli', 'transcripts'],
|
||||
['.json'],
|
||||
parseDevinSessionContent
|
||||
),
|
||||
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
|
||||
jsonlSource(
|
||||
'droid',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.factory', 'sessions'],
|
||||
parseDroidSessionContent
|
||||
),
|
||||
jsonlSource(
|
||||
'droid',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.factory', 'projects'],
|
||||
parseDroidSessionContent
|
||||
),
|
||||
...remoteOpenClawSources(remoteHome, hostPlatform)
|
||||
]
|
||||
}
|
||||
|
||||
function source(
|
||||
agent: AiVaultAgent,
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
segments: readonly string[],
|
||||
extensions: readonly string[],
|
||||
parseContent: RemoteContentParser,
|
||||
filePredicate?: (path: string) => boolean
|
||||
): RemoteSessionSource {
|
||||
return {
|
||||
agent,
|
||||
rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments),
|
||||
extensions,
|
||||
filePredicate,
|
||||
parse: (file, content, context) =>
|
||||
Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context)))
|
||||
}
|
||||
}
|
||||
|
||||
function jsonlSource(
|
||||
agent: AiVaultAgent,
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
segments: readonly string[],
|
||||
parseContent: RemoteContentParser,
|
||||
filePredicate?: (path: string) => boolean
|
||||
): RemoteSessionSource {
|
||||
return source(agent, remoteHome, hostPlatform, segments, ['.jsonl'], parseContent, filePredicate)
|
||||
}
|
||||
|
||||
function remoteCodexSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource[] {
|
||||
return [
|
||||
joinRemotePath(hostPlatform, remoteHome, '.codex'),
|
||||
joinRemotePath(
|
||||
hostPlatform,
|
||||
remoteHome,
|
||||
'.local',
|
||||
'share',
|
||||
'orca',
|
||||
'codex-runtime-home',
|
||||
'home'
|
||||
)
|
||||
].map((codexHome) => ({
|
||||
agent: 'codex',
|
||||
rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'),
|
||||
extensions: ['.jsonl'],
|
||||
parse: (file, content, context) =>
|
||||
parseCodexSessionContent({
|
||||
file,
|
||||
content,
|
||||
platform: context.hostPlatform.os,
|
||||
codexHome,
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os,
|
||||
readIndexedTitle: async (sessionId) =>
|
||||
(
|
||||
await remoteCodexIndexTitles({
|
||||
provider: context.provider,
|
||||
codexHome,
|
||||
hostPlatform,
|
||||
titleCaches: context.titleCaches
|
||||
})
|
||||
).get(sessionId) ?? null
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
function remoteOpenClawSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RemoteSessionSource[] {
|
||||
return ['.openclaw', '.clawdbot'].map((rootName) =>
|
||||
jsonlSource(
|
||||
'openclaw',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
[rootName, 'agents'],
|
||||
openClawParser,
|
||||
(path) => remotePathSegments(path).includes('sessions')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function parserOptions(context: RemoteScannerContext): RemoteParserOptions {
|
||||
return {
|
||||
executionHostId: context.executionHostId,
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
}
|
||||
}
|
||||
|
||||
function piParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options)
|
||||
}
|
||||
|
||||
function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('openclaw', file, content, platform, options)
|
||||
}
|
||||
|
||||
function remotePathSegments(path: string): string[] {
|
||||
return path.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
function remotePiSessionsSegments(): string[] {
|
||||
return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
|
||||
export type RemoteScannerContext = {
|
||||
provider: IFilesystemProvider
|
||||
executionHostId: ExecutionHostId
|
||||
hostPlatform: RemoteHostPlatform
|
||||
titleCaches: Map<string, Promise<Map<string, string>>>
|
||||
}
|
||||
|
||||
export type RemoteParserOptions = {
|
||||
executionHostId: ExecutionHostId
|
||||
executionHostPlatform: NodeJS.Platform
|
||||
}
|
||||
|
||||
export type RemoteSessionSource = {
|
||||
agent: AiVaultAgent
|
||||
rootDir: string
|
||||
extensions: readonly string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
parse: (
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
context: RemoteScannerContext
|
||||
) => Promise<AiVaultSession | null>
|
||||
}
|
||||
|
||||
export type RemoteSessionCandidate = {
|
||||
source: RemoteSessionSource
|
||||
file: FileWithMtime
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DirEntry } from '../../shared/types'
|
||||
import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
|
||||
class MemoryRemoteProvider implements IFilesystemProvider {
|
||||
private readonly files = new Map<string, { content: string; mtimeMs: number }>()
|
||||
|
||||
addFile(path: string, content: string, mtimeMs: number): void {
|
||||
this.files.set(normalize(path), { content, mtimeMs })
|
||||
}
|
||||
|
||||
async readDir(dirPath: string): Promise<DirEntry[]> {
|
||||
const dir = normalize(dirPath)
|
||||
const prefix = dir.endsWith('/') ? dir : `${dir}/`
|
||||
const entries = new Map<string, DirEntry>()
|
||||
for (const path of this.files.keys()) {
|
||||
if (!path.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
const relative = path.slice(prefix.length)
|
||||
if (!relative) {
|
||||
continue
|
||||
}
|
||||
const [name, ...rest] = relative.split('/')
|
||||
if (!name) {
|
||||
continue
|
||||
}
|
||||
entries.set(name, {
|
||||
name,
|
||||
isDirectory: rest.length > 0,
|
||||
isSymlink: false
|
||||
})
|
||||
}
|
||||
return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
async readFile(filePath: string): Promise<FileReadResult> {
|
||||
const file = this.files.get(normalize(filePath))
|
||||
if (!file) {
|
||||
throw new Error(`ENOENT: ${filePath}`)
|
||||
}
|
||||
return { content: file.content, isBinary: false }
|
||||
}
|
||||
|
||||
async stat(filePath: string): Promise<FileStat> {
|
||||
const file = this.files.get(normalize(filePath))
|
||||
if (!file) {
|
||||
throw new Error(`ENOENT: ${filePath}`)
|
||||
}
|
||||
return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs }
|
||||
}
|
||||
|
||||
writeFile = unsupported
|
||||
writeFileBase64 = unsupported
|
||||
writeFileBase64Chunk = unsupported
|
||||
deletePath = unsupported
|
||||
createFile = unsupported
|
||||
createDir = unsupported
|
||||
createDirNoClobber = unsupported
|
||||
rename = unsupported
|
||||
renameNoClobber = unsupported
|
||||
copy = unsupported
|
||||
realpath = async (path: string): Promise<string> => path
|
||||
search = unsupported
|
||||
listFiles = unsupported
|
||||
watch = unsupported
|
||||
}
|
||||
|
||||
async function unsupported(): Promise<never> {
|
||||
throw new Error('unsupported')
|
||||
}
|
||||
|
||||
function normalize(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function jsonLines(records: unknown[]): string {
|
||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
describe('scanRemoteAiVaultSessions', () => {
|
||||
it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/session_index.jsonl',
|
||||
jsonLines([{ id: 'default-session', thread_name: 'Indexed remote title' }]),
|
||||
1
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/2026/07/04/default.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T01:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'default-session', cwd: '/home/ada/repo' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T01:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fallback default title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
10
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.local/share/orca/codex-runtime-home/home/sessions/runtime.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T02:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'runtime-session', cwd: '/home/ada/runtime-repo' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T02:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Managed remote title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
20
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.title)).toEqual([
|
||||
'Managed remote title',
|
||||
'Indexed remote title'
|
||||
])
|
||||
expect(new Set(result.sessions.map((session) => session.id)).size).toBe(2)
|
||||
expect(result.sessions.every((session) => session.executionHostId === 'ssh:dev-box')).toBe(true)
|
||||
expect(result.sessions.every((session) => session.executionHostPlatform === 'linux')).toBe(true)
|
||||
expect(
|
||||
result.sessions.find((session) => session.sessionId === 'default-session')
|
||||
).toMatchObject({
|
||||
codexHome: '/home/ada/.codex',
|
||||
resumeCommand:
|
||||
"cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'default-session'"
|
||||
})
|
||||
expect(
|
||||
result.sessions.find((session) => session.sessionId === 'runtime-session')
|
||||
).toMatchObject({
|
||||
codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home',
|
||||
resumeCommand:
|
||||
"cd '/home/ada/runtime-repo' && CODEX_HOME='/home/ada/.local/share/orca/codex-runtime-home/home' codex resume 'runtime-session'"
|
||||
})
|
||||
})
|
||||
|
||||
it('parses non-Codex transcripts through the same remote scanner', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/claude-session.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-07-04T04:00:00.000Z',
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Summarize the remote branch' }] }
|
||||
},
|
||||
{
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-07-04T04:00:01.000Z',
|
||||
type: 'assistant',
|
||||
message: { model: 'claude-opus-4', content: 'Sure.' }
|
||||
}
|
||||
]),
|
||||
40
|
||||
)
|
||||
|
||||
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({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
agent: 'claude',
|
||||
sessionId: 'claude-session',
|
||||
title: 'Summarize the remote branch',
|
||||
model: 'claude-opus-4',
|
||||
filePath: '/home/ada/.claude/projects/repo/claude-session.jsonl'
|
||||
})
|
||||
})
|
||||
|
||||
it('builds resume commands with the remote host platform', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'C:/Users/Ada/.codex/sessions/win.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-07-04T03:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'win-session', cwd: 'C:/repo/app' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-07-04T03:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Windows remote title' }]
|
||||
}
|
||||
}
|
||||
]),
|
||||
30
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:win-box',
|
||||
remoteHome: 'C:/Users/Ada',
|
||||
hostPlatform: getRemoteHostPlatform('win32-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions[0]?.executionHostPlatform).toBe('win32')
|
||||
expect(result.sessions[0]?.resumeCommand).toBe(
|
||||
'cmd /d /s /c "cd /d ""C:/repo/app"" && set ""CODEX_HOME=C:/Users/Ada/.codex"" && codex resume ""win-session"""'
|
||||
)
|
||||
})
|
||||
|
||||
it('continues past skipped candidates to fill the remote scan limit', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/worker.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'worker-session',
|
||||
title: 'Internal worker',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-07-04T04:00:00.000Z',
|
||||
threadSource: 'agent'
|
||||
}),
|
||||
40
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/user.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'user-session',
|
||||
title: 'Visible user session',
|
||||
cwd: '/home/ada/repo',
|
||||
timestamp: '2026-07-04T03:00:00.000Z'
|
||||
}),
|
||||
30
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual(['user-session'])
|
||||
})
|
||||
|
||||
it('keeps scoped remote sessions even when they are older than the recency cap', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/other.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'other-session',
|
||||
title: 'Other workspace',
|
||||
cwd: '/home/ada/other',
|
||||
timestamp: '2026-07-04T05:00:00.000Z'
|
||||
}),
|
||||
50
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.codex/sessions/scoped.jsonl',
|
||||
codexTranscript({
|
||||
sessionId: 'scoped-session',
|
||||
title: 'Scoped workspace',
|
||||
cwd: '/home/ada/repo/app',
|
||||
timestamp: '2026-07-04T01:00:00.000Z'
|
||||
}),
|
||||
10
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
limit: 1,
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.map((session) => session.sessionId)).toEqual([
|
||||
'other-session',
|
||||
'scoped-session'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function codexTranscript(args: {
|
||||
sessionId: string
|
||||
title: string
|
||||
cwd: string
|
||||
timestamp: string
|
||||
threadSource?: string
|
||||
}): string {
|
||||
return jsonLines([
|
||||
{
|
||||
timestamp: args.timestamp,
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: args.sessionId,
|
||||
cwd: args.cwd,
|
||||
...(args.threadSource ? { thread_source: args.threadSource } : {})
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'),
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: args.title }]
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { extname } from 'node:path'
|
||||
import type {
|
||||
AiVaultAgent,
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
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 type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { remoteSessionSources } from './remote-session-scanner-sources'
|
||||
import type {
|
||||
RemoteScannerContext,
|
||||
RemoteSessionCandidate,
|
||||
RemoteSessionSource
|
||||
} from './remote-session-scanner-types'
|
||||
|
||||
const DEFAULT_REMOTE_SCAN_LIMIT = 1000
|
||||
const REMOTE_SCAN_CONCURRENCY = 8
|
||||
const REMOTE_SCOPE_PARSE_LIMIT = 2000
|
||||
|
||||
export async function scanRemoteAiVaultSessions(args: {
|
||||
provider: IFilesystemProvider
|
||||
executionHostId: ExecutionHostId
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
limit?: number
|
||||
scopePaths?: readonly string[]
|
||||
}): Promise<AiVaultListResult> {
|
||||
const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const context: RemoteScannerContext = {
|
||||
provider: args.provider,
|
||||
executionHostId: args.executionHostId,
|
||||
hostPlatform: args.hostPlatform,
|
||||
titleCaches: new Map()
|
||||
}
|
||||
const candidates = (
|
||||
await mapRemoteScanConcurrently(
|
||||
remoteSessionSources(args.remoteHome, args.hostPlatform),
|
||||
(source) => discoverRemoteSourceCandidates({ source, context, issues })
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs)
|
||||
|
||||
const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit })
|
||||
const cappedSessions = parsed.sessions
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit)
|
||||
const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? [])
|
||||
const parsedScopeSessions = parsed.sessions.filter((session) =>
|
||||
isRemoteSessionInScope(session, scopePaths)
|
||||
)
|
||||
const extraScopeSessions = await scanRemoteInScopeSessions({
|
||||
candidates,
|
||||
context,
|
||||
issues,
|
||||
scopePaths,
|
||||
alreadyParsedFilePaths: parsed.parsedFilePaths
|
||||
})
|
||||
|
||||
return {
|
||||
sessions: mergeRemoteSessions(cappedSessions, [...parsedScopeSessions, ...extraScopeSessions]),
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverRemoteSourceCandidates(args: {
|
||||
source: RemoteSessionSource
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<RemoteSessionCandidate[]> {
|
||||
const paths = await walkRemoteSessionFiles(
|
||||
args.source,
|
||||
args.context.provider,
|
||||
args.context.hostPlatform
|
||||
)
|
||||
const files = await mapRemoteScanConcurrently(paths, (path) =>
|
||||
statRemoteFile(
|
||||
args.context.provider,
|
||||
path,
|
||||
args.source.agent,
|
||||
args.context.executionHostId,
|
||||
args.issues
|
||||
)
|
||||
)
|
||||
return files
|
||||
.filter((file): file is FileWithMtime => Boolean(file))
|
||||
.map((file) => ({ source: args.source, file }))
|
||||
}
|
||||
|
||||
async function walkRemoteSessionFiles(
|
||||
source: RemoteSessionSource,
|
||||
provider: IFilesystemProvider,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
dirPath = source.rootDir
|
||||
): Promise<string[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await provider.readDir(dirPath)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const extensions = new Set(source.extensions)
|
||||
const files: string[] = []
|
||||
for (const entry of entries) {
|
||||
const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name)
|
||||
if (entry.isDirectory && !entry.isSymlink) {
|
||||
files.push(...(await walkRemoteSessionFiles(source, provider, hostPlatform, fullPath)))
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!entry.isSymlink &&
|
||||
extensions.has(extname(entry.name).toLowerCase()) &&
|
||||
(source.filePredicate?.(fullPath) ?? true)
|
||||
) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function parseRemoteSessionCandidates(args: {
|
||||
candidates: readonly RemoteSessionCandidate[]
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
limit: number
|
||||
}): Promise<{ sessions: AiVaultSession[]; parsedFilePaths: Set<string> }> {
|
||||
const sessions: AiVaultSession[] = []
|
||||
const parsedFilePaths = new Set<string>()
|
||||
let index = 0
|
||||
|
||||
while (index < args.candidates.length) {
|
||||
if (canStopParsingRemoteSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
|
||||
break
|
||||
}
|
||||
|
||||
const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
for (const candidate of batch) {
|
||||
parsedFilePaths.add(candidate.file.path)
|
||||
}
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
|
||||
)
|
||||
sessions.push(...results.filter(isAiVaultSession))
|
||||
index += batch.length
|
||||
}
|
||||
|
||||
return { sessions, parsedFilePaths }
|
||||
}
|
||||
|
||||
async function scanRemoteInScopeSessions(args: {
|
||||
candidates: readonly RemoteSessionCandidate[]
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
scopePaths: readonly string[]
|
||||
alreadyParsedFilePaths: ReadonlySet<string>
|
||||
}): Promise<AiVaultSession[]> {
|
||||
if (args.scopePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = args.candidates
|
||||
.filter((candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path))
|
||||
.slice(0, REMOTE_SCOPE_PARSE_LIMIT)
|
||||
const sessions: AiVaultSession[] = []
|
||||
|
||||
for (let index = 0; index < candidates.length; index += REMOTE_SCAN_CONCURRENCY) {
|
||||
const batch = candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues))
|
||||
)
|
||||
sessions.push(
|
||||
...results.filter(
|
||||
(session): session is AiVaultSession =>
|
||||
isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
async function parseRemoteSessionCandidate(
|
||||
candidate: RemoteSessionCandidate,
|
||||
context: RemoteScannerContext,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<AiVaultSession | null> {
|
||||
try {
|
||||
const read = await context.provider.readFile(candidate.file.path)
|
||||
if (read.isBinary) {
|
||||
return null
|
||||
}
|
||||
return candidate.source.parse(candidate.file, read.content, context)
|
||||
} catch (err) {
|
||||
issues.push({
|
||||
executionHostId: context.executionHostId,
|
||||
agent: candidate.source.agent,
|
||||
path: candidate.file.path,
|
||||
message: errorMessage(err)
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRemoteSessions(
|
||||
cappedSessions: AiVaultSession[],
|
||||
scopeSessions: AiVaultSession[]
|
||||
): AiVaultSession[] {
|
||||
if (scopeSessions.length === 0) {
|
||||
return cappedSessions
|
||||
}
|
||||
const byId = new Map<string, AiVaultSession>()
|
||||
for (const session of cappedSessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
for (const session of scopeSessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
}
|
||||
|
||||
function isRemoteSessionInScope(session: AiVaultSession, scopePaths: readonly string[]): boolean {
|
||||
const cwd = session.cwd
|
||||
return Boolean(cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd)))
|
||||
}
|
||||
|
||||
function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] {
|
||||
return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
async function statRemoteFile(
|
||||
provider: IFilesystemProvider,
|
||||
path: string,
|
||||
agent: AiVaultAgent,
|
||||
executionHostId: ExecutionHostId,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<FileWithMtime | null> {
|
||||
try {
|
||||
const stat = await provider.stat(path)
|
||||
const mtimeMs = remoteStatMtimeMs(stat)
|
||||
return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }
|
||||
} catch (err) {
|
||||
issues.push({ executionHostId, agent, path, message: errorMessage(err) })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function remoteStatMtimeMs(stat: FileStat): number {
|
||||
if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) {
|
||||
return stat.mtimeMs
|
||||
}
|
||||
return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000
|
||||
}
|
||||
|
||||
function canStopParsingRemoteSessions(
|
||||
sessions: AiVaultSession[],
|
||||
limit: number,
|
||||
nextCandidateMtimeMs: number | undefined
|
||||
): boolean {
|
||||
if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') {
|
||||
return false
|
||||
}
|
||||
const visibleCutoff = sessions
|
||||
.map(sessionSortTime)
|
||||
.sort((left, right) => right - left)
|
||||
.at(limit - 1)
|
||||
|
||||
// Transcript mtimes bound the remaining candidate order; once the visible
|
||||
// cutoff is newer, older files cannot enter the unscoped top-N result.
|
||||
return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff
|
||||
}
|
||||
|
||||
function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession {
|
||||
return Boolean(session)
|
||||
}
|
||||
|
||||
async function mapRemoteScanConcurrently<T, U>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<U>
|
||||
): Promise<U[]> {
|
||||
const results: U[] = []
|
||||
for (let index = 0; index < items.length; index += REMOTE_SCAN_CONCURRENCY) {
|
||||
const batch = items.slice(index, index + REMOTE_SCAN_CONCURRENCY)
|
||||
results.push(...(await Promise.all(batch.map(mapper))))
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AiVaultSession,
|
||||
type AiVaultSessionPreviewMessage
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
extractPreviewContentText,
|
||||
@@ -43,7 +44,11 @@ export function createAccumulator(args: {
|
||||
export function finalizeSession(
|
||||
accumulator: SessionAccumulator,
|
||||
platform: NodeJS.Platform,
|
||||
options: { codexHome?: string | null } = {}
|
||||
options: {
|
||||
codexHome?: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
} = {}
|
||||
): AiVaultSession | null {
|
||||
const sessionId = accumulator.sessionId.trim()
|
||||
if (!sessionId) {
|
||||
@@ -54,8 +59,14 @@ export function finalizeSession(
|
||||
accumulator.fallbackTitle ||
|
||||
`${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}`
|
||||
|
||||
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
|
||||
return {
|
||||
id: `${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
|
||||
id: `${executionHostId}:${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
|
||||
executionHostId,
|
||||
...(options.executionHostPlatform
|
||||
? { executionHostPlatform: options.executionHostPlatform }
|
||||
: {}),
|
||||
agent: accumulator.agent,
|
||||
sessionId,
|
||||
title,
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { parseDevinSessionFile } from './session-scanner-devin-parser'
|
||||
import { parseDroidSessionFile } from './session-scanner-droid-parser'
|
||||
import { parseGrokSessionFile } from './session-scanner-grok-parser'
|
||||
import {
|
||||
parseDroidSessionFile,
|
||||
parseMessageGraphSessionFile,
|
||||
parseRovoSessionFile
|
||||
} from './session-scanner-graph-parsers'
|
||||
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { stat } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
@@ -35,21 +36,61 @@ const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTit
|
||||
export async function parseCodexSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
codexHome: string | null = null
|
||||
codexHome: string | null = null,
|
||||
executionHostId?: ExecutionHostId
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
return parseCodexSessionLines({
|
||||
file,
|
||||
lines,
|
||||
platform,
|
||||
codexHome,
|
||||
executionHostId,
|
||||
titleReader: (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
export async function parseCodexSessionContent(args: {
|
||||
file: FileWithMtime
|
||||
content: string
|
||||
platform?: NodeJS.Platform
|
||||
codexHome?: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
readIndexedTitle?: (sessionId: string) => Promise<string | null>
|
||||
}): Promise<AiVaultSession | null> {
|
||||
return parseCodexSessionLines({
|
||||
file: args.file,
|
||||
lines: args.content.split(/\r?\n/),
|
||||
platform: args.platform ?? process.platform,
|
||||
codexHome: args.codexHome ?? null,
|
||||
executionHostId: args.executionHostId,
|
||||
executionHostPlatform: args.executionHostPlatform,
|
||||
titleReader: args.readIndexedTitle
|
||||
})
|
||||
}
|
||||
|
||||
async function parseCodexSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
codexHome: string | null
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
titleReader?: (sessionId: string) => Promise<string | null>
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -70,7 +111,7 @@ export async function parseCodexSessionFile(
|
||||
}
|
||||
const indexedTitle =
|
||||
extractCodexSessionMetadataTitle(payload) ??
|
||||
(await readCodexSessionIndexTitle(file.path, codexHome, accumulator.sessionId))
|
||||
(await args.titleReader?.(accumulator.sessionId))
|
||||
if (indexedTitle) {
|
||||
accumulator.title = indexedTitle
|
||||
}
|
||||
@@ -158,7 +199,11 @@ export async function parseCodexSessionFile(
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform, { codexHome })
|
||||
return finalizeSession(accumulator, args.platform, {
|
||||
codexHome: args.codexHome,
|
||||
executionHostId: args.executionHostId,
|
||||
executionHostPlatform: args.executionHostPlatform
|
||||
})
|
||||
}
|
||||
|
||||
function addCodexUsage(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -17,11 +18,25 @@ import {
|
||||
numberValue
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseDevinSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
return parseDevinSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
}
|
||||
|
||||
export function parseDevinSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): AiVaultSession | null {
|
||||
const record = asRecord(JSON.parse(content) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
@@ -69,7 +84,7 @@ export async function parseDevinSessionFile(
|
||||
)
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
function extractDevinStepText(step: Record<string, unknown>): string | null {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewMessage,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import {
|
||||
asRecord,
|
||||
extractMessageText,
|
||||
extractPreviewContentText,
|
||||
extractString,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseDroidSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseDroidSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
export async function parseDroidSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseDroidSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseDroidSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'droid',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, record.timestamp)
|
||||
if (record.type === 'session_start') {
|
||||
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
|
||||
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
continue
|
||||
}
|
||||
if (record.type === 'system') {
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
accumulator.model = extractString(record.model) ?? accumulator.model
|
||||
}
|
||||
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
|
||||
if (streamSessionId) {
|
||||
accumulator.sessionId = streamSessionId
|
||||
}
|
||||
if (record.type === 'message') {
|
||||
consumeDroidMessage(accumulator, record)
|
||||
} else if (record.type === 'completion') {
|
||||
accumulator.messageCount++
|
||||
accumulator.totalTokens += tokenTotal(record.usage)
|
||||
addPreviewMessage(accumulator, {
|
||||
role: 'assistant',
|
||||
text: extractString(record.finalText),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
function consumeDroidMessage(
|
||||
accumulator: SessionAccumulator,
|
||||
record: Record<string, unknown>
|
||||
): void {
|
||||
const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
|
||||
if (role !== 'user' && role !== 'assistant') {
|
||||
return
|
||||
}
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??=
|
||||
normalizeTitleText(extractString(record.text) ?? '') ||
|
||||
extractMessageText(asRecord(record.message))
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role,
|
||||
text:
|
||||
extractString(record.text) ?? extractPreviewContentText(asRecord(record.message)?.content),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -17,15 +18,18 @@ import {
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractMessageText,
|
||||
extractPreviewContentText,
|
||||
extractString,
|
||||
firstString,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
readJsonObjectIfExists,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseRovoSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
@@ -159,17 +163,43 @@ export async function parseMessageGraphSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent,
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseMessageGraphSessionLines({ agent, file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseMessageGraphSessionContent(
|
||||
agent: 'openclaw' | 'pi',
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionLines({
|
||||
agent,
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseMessageGraphSessionLines(args: {
|
||||
agent: 'openclaw' | 'pi'
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: args.agent,
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -204,69 +234,5 @@ export async function parseMessageGraphSessionFile(
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export async function parseDroidSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'droid',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, record.timestamp)
|
||||
if (record.type === 'session_start') {
|
||||
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
|
||||
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
continue
|
||||
}
|
||||
if (record.type === 'system') {
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
accumulator.model = extractString(record.model) ?? accumulator.model
|
||||
}
|
||||
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
|
||||
if (streamSessionId) {
|
||||
accumulator.sessionId = streamSessionId
|
||||
}
|
||||
if (record.type === 'message') {
|
||||
const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??=
|
||||
normalizeTitleText(extractString(record.text) ?? '') ||
|
||||
extractMessageText(asRecord(record.message))
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role,
|
||||
text:
|
||||
extractString(record.text) ??
|
||||
extractPreviewContentText(asRecord(record.message)?.content),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
} else if (record.type === 'completion') {
|
||||
accumulator.messageCount++
|
||||
accumulator.totalTokens += tokenTotal(record.usage)
|
||||
addPreviewMessage(accumulator, {
|
||||
role: 'assistant',
|
||||
text: extractString(record.finalText),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -23,24 +24,51 @@ import {
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseClaudeSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'claude',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let metaTitle: string | null = null
|
||||
let generatedTitle: string | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseClaudeSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseClaudeSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseClaudeSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseClaudeSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'claude',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
let metaTitle: string | null = null
|
||||
let generatedTitle: string | null = null
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -92,7 +120,7 @@ export async function parseClaudeSessionFile(
|
||||
}
|
||||
|
||||
accumulator.fallbackTitle = generatedTitle ?? metaTitle
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionFile(
|
||||
@@ -103,7 +131,33 @@ export async function parseGeminiSessionFile(
|
||||
return parseGeminiJsonlSessionFile(file, platform)
|
||||
}
|
||||
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
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
|
||||
}
|
||||
@@ -117,24 +171,33 @@ export async function parseGeminiSessionFile(
|
||||
for (const message of arrayValue(record.messages)) {
|
||||
consumeGeminiMessage(accumulator, asRecord(message))
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
export async function parseGeminiJsonlSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'gemini',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseGeminiJsonlSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
async function parseGeminiJsonlSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'gemini',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -153,7 +216,7 @@ export async function parseGeminiJsonlSessionFile(
|
||||
consumeGeminiMessage(accumulator, record)
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export function consumeGeminiMessage(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
@@ -29,21 +30,49 @@ import {
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
export async function parseCopilotSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'copilot',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseCopilotSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseCopilotSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseCopilotSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseCopilotSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'copilot',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -94,24 +123,47 @@ export async function parseCopilotSessionFile(
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export async function parseCursorSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'cursor',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseCursorSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
for await (const line of lines) {
|
||||
export async function parseCursorSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseCursorSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
|
||||
async function parseCursorSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'cursor',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
@@ -132,7 +184,7 @@ export async function parseCursorSessionFile(
|
||||
)
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
}
|
||||
|
||||
export async function parseOpenCodeSessionFile(
|
||||
@@ -207,7 +259,16 @@ export async function parseHermesSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
return parseHermesSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
}
|
||||
|
||||
export async function parseHermesSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(content) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
@@ -234,5 +295,5 @@ export async function parseHermesSessionFile(
|
||||
if (accumulator.messageCount === 0) {
|
||||
accumulator.messageCount = numberValue(record.message_count)
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AiVaultSession,
|
||||
AiVaultSessionPreviewMessage
|
||||
} from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
export type AiVaultScanOptions = {
|
||||
claudeProjectsDir?: string
|
||||
@@ -33,6 +34,7 @@ export type AiVaultScanOptions = {
|
||||
// the recency cap (see discoverInScopeClaudeFiles).
|
||||
scopePaths?: readonly string[]
|
||||
platform?: NodeJS.Platform
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type FileWithMtime = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import { parseAgentSessionFile } from './session-scanner-agent-parser'
|
||||
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
|
||||
@@ -41,6 +42,7 @@ export async function scanAiVaultSessions(
|
||||
const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT)
|
||||
const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT)
|
||||
const platform = options.platform ?? process.platform
|
||||
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues })
|
||||
|
||||
@@ -63,6 +65,7 @@ export async function scanAiVaultSessions(
|
||||
candidates,
|
||||
limit,
|
||||
platform,
|
||||
executionHostId,
|
||||
issues
|
||||
})
|
||||
|
||||
@@ -75,12 +78,13 @@ export async function scanAiVaultSessions(
|
||||
scopePaths: options.scopePaths ?? [],
|
||||
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
|
||||
platform,
|
||||
executionHostId,
|
||||
issues
|
||||
})
|
||||
|
||||
return {
|
||||
sessions: mergeSessions(cappedSessions, scopeSessions),
|
||||
issues,
|
||||
issues: issues.map((issue) => ({ executionHostId, ...issue })),
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
@@ -110,6 +114,7 @@ async function scanInScopeSessions(args: {
|
||||
scopePaths: readonly string[]
|
||||
alreadyParsedFilePaths: ReadonlySet<string>
|
||||
platform: NodeJS.Platform
|
||||
executionHostId: ExecutionHostId
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<AiVaultSession[]> {
|
||||
if (args.scopePaths.length === 0) {
|
||||
@@ -136,6 +141,7 @@ async function scanInScopeSessions(args: {
|
||||
candidates,
|
||||
limit: candidates.length,
|
||||
platform: args.platform,
|
||||
executionHostId: args.executionHostId,
|
||||
issues: args.issues
|
||||
})
|
||||
}
|
||||
@@ -144,6 +150,7 @@ async function parseSessionCandidates(args: {
|
||||
candidates: SessionFileCandidate[]
|
||||
limit: number
|
||||
platform: NodeJS.Platform
|
||||
executionHostId: ExecutionHostId
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<AiVaultSession[]> {
|
||||
const sessions: AiVaultSession[] = []
|
||||
@@ -159,7 +166,9 @@ async function parseSessionCandidates(args: {
|
||||
const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining)
|
||||
const batch = args.candidates.slice(index, index + batchSize)
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseSessionCandidate(candidate, args.platform))
|
||||
batch.map((candidate) =>
|
||||
parseSessionCandidate(candidate, args.platform, args.executionHostId)
|
||||
)
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
@@ -179,15 +188,20 @@ async function parseSessionCandidates(args: {
|
||||
|
||||
async function parseSessionCandidate(
|
||||
candidate: SessionFileCandidate,
|
||||
platform: NodeJS.Platform
|
||||
platform: NodeJS.Platform,
|
||||
executionHostId: ExecutionHostId
|
||||
): Promise<SessionParseResult> {
|
||||
try {
|
||||
const session = await parseAgentSessionFile(candidate, platform)
|
||||
return { session, issue: null }
|
||||
return {
|
||||
session: session ? withSessionExecutionHost(session, executionHostId) : null,
|
||||
issue: null
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
session: null,
|
||||
issue: {
|
||||
executionHostId,
|
||||
agent: candidate.agent,
|
||||
path: candidate.file.path,
|
||||
message: errorMessage(err)
|
||||
@@ -196,6 +210,20 @@ async function parseSessionCandidate(
|
||||
}
|
||||
}
|
||||
|
||||
function withSessionExecutionHost(
|
||||
session: AiVaultSession,
|
||||
executionHostId: ExecutionHostId
|
||||
): AiVaultSession {
|
||||
if (session.executionHostId === executionHostId) {
|
||||
return session
|
||||
}
|
||||
return {
|
||||
...session,
|
||||
executionHostId,
|
||||
id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`
|
||||
}
|
||||
}
|
||||
|
||||
function canStopParsingSessions(
|
||||
sessions: AiVaultSession[],
|
||||
limit: number,
|
||||
|
||||
@@ -127,6 +127,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
defaultLinearTeamSelection: null,
|
||||
opencodeSessionCookie: '',
|
||||
opencodeWorkspaceId: '',
|
||||
minimaxGroupId: '',
|
||||
minimaxUsageModels: 'general',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
keepComputerAwakeWhileAgentsRun: false,
|
||||
|
||||
@@ -131,6 +131,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
defaultLinearTeamSelection: null,
|
||||
opencodeSessionCookie: '',
|
||||
opencodeWorkspaceId: '',
|
||||
minimaxGroupId: '',
|
||||
minimaxUsageModels: 'general',
|
||||
geminiCliOAuthEnabled: false,
|
||||
agentCmdOverrides: {},
|
||||
keepComputerAwakeWhileAgentsRun: false,
|
||||
|
||||
@@ -172,6 +172,23 @@ export class HeadlessEmulator {
|
||||
return this.terminal.buffer.active.type === 'alternate'
|
||||
}
|
||||
|
||||
/** Why: PSReadLine's Ctrl+L repaint is only safe at an empty prompt — with
|
||||
* pending input it re-renders at a cached buffer row that ConPTY's fixed
|
||||
* viewport doesn't track, painting the input well below the prompt. The
|
||||
* cursor line counts as an empty prompt when everything before the cursor
|
||||
* ends with a single '>' and nothing follows it ('>>' is PowerShell's
|
||||
* continuation prompt, i.e. a multiline edit in flight). */
|
||||
isCursorOnEmptyPromptLine(): boolean {
|
||||
const buffer = this.terminal.buffer.active
|
||||
const line = buffer.getLine(buffer.baseY + buffer.cursorY)
|
||||
if (!line) {
|
||||
return false
|
||||
}
|
||||
const upToCursor = line.translateToString(true, 0, buffer.cursorX).trimEnd()
|
||||
const fullLine = line.translateToString(true).trimEnd()
|
||||
return fullLine === upToCursor && upToCursor.endsWith('>') && !upToCursor.endsWith('>>')
|
||||
}
|
||||
|
||||
getVisibleLines(): string[] {
|
||||
const buffer = this.terminal.buffer.active
|
||||
const lines: string[] = []
|
||||
|
||||
@@ -527,6 +527,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo\\orca',
|
||||
command: 'codex'
|
||||
})
|
||||
|
||||
@@ -1038,6 +1039,97 @@ describe('createPtySubprocess', () => {
|
||||
expect(shellArg.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('allows an explicitly requested plain daemon shell at POSIX root', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
|
||||
try {
|
||||
createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, cwd: '/' })
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ cwd: '/' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects daemon automatic agent startup without an explicit cwd', () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
spawnMock.mockClear()
|
||||
|
||||
try {
|
||||
expect(() =>
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
command: 'codex'
|
||||
})
|
||||
).toThrow(/requires a non-root workspace/)
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects daemon automatic agent startup at POSIX root', () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
spawnMock.mockClear()
|
||||
|
||||
try {
|
||||
expect(() =>
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/',
|
||||
command: 'claude'
|
||||
})
|
||||
).toThrow(/requires a non-root workspace/)
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing explicit POSIX cwd before node-pty spawn', () => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
spawnMock.mockClear()
|
||||
|
||||
try {
|
||||
expect(() =>
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/definitely-missing-orca-cwd'
|
||||
})
|
||||
).toThrow(/definitely-missing-orca-cwd/)
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes custom env to spawned process', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
@@ -1238,6 +1330,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/repo',
|
||||
command: 'codex',
|
||||
env: { SHELL: '/bin/zsh' }
|
||||
})
|
||||
@@ -1264,6 +1357,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/repo',
|
||||
command: "codex 'linked issue context'",
|
||||
startupCommandDelivery: 'shell-ready',
|
||||
env: { SHELL: '/bin/zsh' }
|
||||
@@ -1291,6 +1385,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/repo',
|
||||
command: "codex --prefill 'linked issue context'",
|
||||
env: { SHELL: '/bin/zsh' }
|
||||
})
|
||||
@@ -1560,6 +1655,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo\\orca',
|
||||
shellOverride: 'powershell.exe',
|
||||
command: "& 'codex' '--no-alt-screen'"
|
||||
})
|
||||
@@ -1589,6 +1685,7 @@ describe('createPtySubprocess', () => {
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: 'C:\\repo\\orca',
|
||||
shellOverride: 'cmd.exe',
|
||||
command: `codex ${'x'.repeat(7000)}`
|
||||
})
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import { isShellProcess } from '../../shared/shell-process-detection'
|
||||
import { parsePtySessionId } from './pty-session-id'
|
||||
import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths'
|
||||
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
@@ -93,20 +94,7 @@ export type PtySubprocessOptions = {
|
||||
* Returns a stable default working directory for daemon-spawned PTYs.
|
||||
*/
|
||||
function getDefaultCwd(): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return process.env.HOME || '/'
|
||||
}
|
||||
|
||||
// Why: HOMEPATH alone is drive-relative (`\\Users\\name`). Pair it with
|
||||
// HOMEDRIVE when USERPROFILE is unavailable so daemon-spawned Windows PTYs
|
||||
// still start in a valid absolute home directory.
|
||||
if (process.env.USERPROFILE) {
|
||||
return process.env.USERPROFILE
|
||||
}
|
||||
if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
|
||||
return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
|
||||
}
|
||||
return 'C:\\'
|
||||
return resolveSafePtyDefaultCwd()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,11 +194,13 @@ function isExistingDirectory(path: string | undefined): path is string {
|
||||
* Moves the daemon process to a stable cwd after its original cwd disappears.
|
||||
*/
|
||||
function repairDaemonCwd(): string | null {
|
||||
const candidates = [
|
||||
process.env.ORCA_USER_DATA_PATH,
|
||||
getDefaultCwd(),
|
||||
process.platform === 'win32' ? 'C:\\' : '/'
|
||||
]
|
||||
const candidates = [process.env.ORCA_USER_DATA_PATH]
|
||||
try {
|
||||
candidates.push(getDefaultCwd())
|
||||
} catch {
|
||||
// Keep daemon cwd repair best-effort even when no user terminal cwd is safe.
|
||||
}
|
||||
candidates.push(process.platform === 'win32' ? 'C:\\' : '/')
|
||||
for (const candidate of candidates) {
|
||||
if (isExistingDirectory(candidate)) {
|
||||
try {
|
||||
@@ -314,6 +304,16 @@ function preflightWindowsPtySpawnEnvironment(args: {
|
||||
validateWorkingDirectory(args.validationCwd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates POSIX spawn cwd before node-pty can fail with an opaque ENOENT.
|
||||
*/
|
||||
function preflightPosixPtySpawnEnvironment(validationCwd: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
validateWorkingDirectory(validationCwd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps native PTY spawn failures with shell and cwd context.
|
||||
*/
|
||||
@@ -589,6 +589,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = []
|
||||
const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command)
|
||||
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
|
||||
if (opts.command && startupAgentRecognition) {
|
||||
assertSafeAgentStartupCwd(opts.cwd, opts.command)
|
||||
}
|
||||
const requestedCwd = opts.cwd || getDefaultCwd()
|
||||
let spawnCwd = requestedCwd
|
||||
let validationCwd = spawnCwd
|
||||
@@ -772,6 +775,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
// runs in a separate forked process with its own code path.
|
||||
ensureNodePtySpawnHelperExecutable()
|
||||
preflightUnixPtySpawnEnvironment()
|
||||
preflightPosixPtySpawnEnvironment(validationCwd)
|
||||
preflightWindowsPtySpawnEnvironment({
|
||||
validationCwd,
|
||||
cwdWasExplicit: opts.cwd !== undefined
|
||||
@@ -1026,6 +1030,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
dead = true
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
if (dead) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
proc.clear()
|
||||
} catch {
|
||||
// Best-effort: a clear on a just-exited PTY must not kill the handle.
|
||||
}
|
||||
},
|
||||
kill: () => {
|
||||
if (dead) {
|
||||
return
|
||||
|
||||
@@ -9,6 +9,7 @@ function createMockSubprocess() {
|
||||
let onData: ((data: string) => void) | null = null
|
||||
let onExit: ((code: number) => void) | null = null
|
||||
let killed = false
|
||||
let clearCalls = 0
|
||||
let pid = 12345
|
||||
|
||||
return {
|
||||
@@ -20,13 +21,20 @@ function createMockSubprocess() {
|
||||
get pid() {
|
||||
return pid
|
||||
},
|
||||
foregroundProcess: null as string | null,
|
||||
getForegroundProcess(): string | null {
|
||||
return null
|
||||
return this.foregroundProcess
|
||||
},
|
||||
write(data: string) {
|
||||
written.push(data)
|
||||
},
|
||||
resize(_cols: number, _rows: number) {},
|
||||
get clearCalls() {
|
||||
return clearCalls
|
||||
},
|
||||
clear() {
|
||||
clearCalls++
|
||||
},
|
||||
kill() {
|
||||
killed = true
|
||||
// Simulate async exit
|
||||
@@ -426,6 +434,98 @@ describe('Session', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearScrollback', () => {
|
||||
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
|
||||
const original = process.platform
|
||||
Object.defineProperty(process, 'platform', { value: platform })
|
||||
try {
|
||||
run()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: original })
|
||||
}
|
||||
}
|
||||
|
||||
it('resyncs the native PTY screen state alongside the emulator clear', () => {
|
||||
createSession()
|
||||
session.clearScrollback()
|
||||
// Why: without the subprocess clear, ConPTY keeps a stale cursor row and
|
||||
// the next prompt repaint lands below a blank gap on Windows.
|
||||
expect(subprocess.clearCalls).toBe(1)
|
||||
const take = session.takePendingOutput(false)
|
||||
expect(take?.records).toContainEqual({ kind: 'clear' })
|
||||
})
|
||||
|
||||
it('nudges a Windows PowerShell prompt to repaint with a form feed', async () => {
|
||||
createSession()
|
||||
subprocess.foregroundProcess = 'powershell.exe'
|
||||
subprocess.simulateData('PS C:\\Users\\me> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
withPlatform('win32', () => session.clearScrollback())
|
||||
// Why: the ConPTY clear cannot reach PSReadLine's cached cursor row;
|
||||
// Ctrl+L makes PSReadLine repaint the prompt at the true origin.
|
||||
expect(subprocess.written).toEqual(['\x0c'])
|
||||
})
|
||||
|
||||
it('does not send a form feed while input is pending at the prompt', async () => {
|
||||
createSession()
|
||||
subprocess.foregroundProcess = 'powershell.exe'
|
||||
subprocess.simulateData('PS C:\\Users\\me> fd')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
// Why: PSReadLine repaints pending input at a stale cached row that
|
||||
// ConPTY's fixed viewport doesn't track, so the nudge must be skipped.
|
||||
withPlatform('win32', () => session.clearScrollback())
|
||||
expect(subprocess.written).toEqual([])
|
||||
})
|
||||
|
||||
it('does not send or queue a form feed before shell-ready', async () => {
|
||||
createSession({ shellReadySupported: true })
|
||||
subprocess.foregroundProcess = 'powershell.exe'
|
||||
subprocess.simulateData('PS C:\\Users\\me> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
// Why: a queued form feed would flush after the startup command at an
|
||||
// arbitrary later moment, when the prompt gates no longer hold.
|
||||
withPlatform('win32', () => session.clearScrollback())
|
||||
expect(subprocess.written).toEqual([])
|
||||
subprocess.simulateData('\x1b]777;orca-shell-ready\x07\r\nPS C:\\Users\\me> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(subprocess.written).toEqual([])
|
||||
})
|
||||
|
||||
it('does not send a form feed at a PowerShell continuation prompt', async () => {
|
||||
createSession()
|
||||
subprocess.foregroundProcess = 'powershell.exe'
|
||||
subprocess.simulateData('PS C:\\Users\\me> {\r\n>> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
withPlatform('win32', () => session.clearScrollback())
|
||||
expect(subprocess.written).toEqual([])
|
||||
})
|
||||
|
||||
it('does not send a form feed while a command owns the foreground', async () => {
|
||||
createSession()
|
||||
subprocess.foregroundProcess = 'node'
|
||||
subprocess.simulateData('PS C:\\Users\\me> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
withPlatform('win32', () => session.clearScrollback())
|
||||
expect(subprocess.written).toEqual([])
|
||||
})
|
||||
|
||||
it('does not send a form feed on POSIX platforms', async () => {
|
||||
createSession()
|
||||
subprocess.foregroundProcess = 'pwsh'
|
||||
subprocess.simulateData('PS C:\\Users\\me> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
withPlatform('linux', () => session.clearScrollback())
|
||||
expect(subprocess.written).toEqual([])
|
||||
})
|
||||
|
||||
it('does not touch the subprocess after dispose', () => {
|
||||
createSession()
|
||||
session.dispose()
|
||||
session.clearScrollback()
|
||||
expect(subprocess.clearCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshot', () => {
|
||||
it('returns a terminal snapshot', async () => {
|
||||
createSession()
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
scanForShellReady,
|
||||
type ShellReadyScanState
|
||||
} from '../shell-ready-marker-scanner'
|
||||
import { isPowerShellProcess } from '../../shared/shell-process-detection'
|
||||
import type {
|
||||
PendingOutputRecord,
|
||||
SessionState,
|
||||
@@ -41,6 +42,10 @@ export type SubprocessHandle = {
|
||||
startupCommandDeliveredInShellArgs?: boolean
|
||||
write(data: string): void
|
||||
resize(cols: number, rows: number): void
|
||||
/** Resync the native PTY's own screen state after a frontend clear.
|
||||
* No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes
|
||||
* the next prompt repaint land below a blank gap. */
|
||||
clear?(): void
|
||||
kill(): void
|
||||
forceKill(): void
|
||||
signal(sig: string): void
|
||||
@@ -285,6 +290,35 @@ export class Session {
|
||||
}
|
||||
this.emulator.clearScrollback()
|
||||
this.recordPendingOutput({ kind: 'clear' })
|
||||
this.subprocess.clear?.()
|
||||
this.#nudgePowerShellPromptRepaint()
|
||||
}
|
||||
|
||||
/** Why: ConPTY's buffer clear cannot reach PSReadLine's cached cursor row,
|
||||
* so PowerShell's first Enter after a clear would still repaint the prompt
|
||||
* at the stale row, leaving a blank gap. A form feed (Ctrl+L) makes
|
||||
* PSReadLine itself repaint at the true origin. Gated to a PowerShell
|
||||
* foreground so a running command or TUI never gets a stray 0x0C, and to
|
||||
* an empty prompt because PSReadLine repaints pending input at a stale
|
||||
* cached row that ConPTY's fixed viewport doesn't track. */
|
||||
#nudgePowerShellPromptRepaint(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
// Why: before shell-ready, write() would queue the form feed behind the
|
||||
// buffered startup command and deliver it at an arbitrary later moment,
|
||||
// when the foreground/prompt gates below no longer hold. The nudge is
|
||||
// cosmetic — skip it rather than defer it.
|
||||
if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) {
|
||||
return
|
||||
}
|
||||
if (!isPowerShellProcess(this.subprocess.getForegroundProcess())) {
|
||||
return
|
||||
}
|
||||
if (!this.emulator.isCursorOnEmptyPromptLine()) {
|
||||
return
|
||||
}
|
||||
this.subprocess.write('\x0c')
|
||||
}
|
||||
|
||||
prepareForFinalSnapshot(): string {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getBranchConflictKind,
|
||||
getRemoteCount,
|
||||
parseAndFilterSearchRefDetails,
|
||||
resolveDefaultBaseRefViaExec,
|
||||
searchBaseRefDetails,
|
||||
searchBaseRefs
|
||||
} from './repo'
|
||||
@@ -493,6 +494,26 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => {
|
||||
expect(result).toBe('origin/main')
|
||||
})
|
||||
|
||||
it('falls through from a stale origin/HEAD target to an existing primary ref', () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
createRemoteRef(tmpDir, 'origin/main', sha)
|
||||
git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master'])
|
||||
|
||||
const result = getDefaultBaseRef(tmpDir)
|
||||
|
||||
expect(result).toBe('origin/main')
|
||||
})
|
||||
|
||||
it('falls through from a stale origin/HEAD primary target to another existing default ref', () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
createRemoteRef(tmpDir, 'origin/master', sha)
|
||||
git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'])
|
||||
|
||||
const result = getDefaultBaseRef(tmpDir)
|
||||
|
||||
expect(result).toBe('origin/master')
|
||||
})
|
||||
|
||||
it('does NOT fall through to upstream/main when origin/* is absent', () => {
|
||||
// Why: this is the explicit design decision — the default probe order
|
||||
// is origin-only. upstream-aware defaulting is deferred work.
|
||||
@@ -508,6 +529,53 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDefaultBaseRefViaExec', () => {
|
||||
it('falls through from a stale origin/HEAD target to the probe list', async () => {
|
||||
const calls: string[][] = []
|
||||
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
|
||||
calls.push(argv)
|
||||
if (argv[0] === 'symbolic-ref') {
|
||||
return { stdout: 'refs/remotes/origin/master\n' }
|
||||
}
|
||||
if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/main') {
|
||||
return { stdout: 'main-sha\n' }
|
||||
}
|
||||
throw new Error('missing ref')
|
||||
}
|
||||
|
||||
await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/main')
|
||||
|
||||
expect(calls).toEqual([
|
||||
['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master'],
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main']
|
||||
])
|
||||
})
|
||||
|
||||
it('verifies origin/HEAD even when it points at origin/main', async () => {
|
||||
const calls: string[][] = []
|
||||
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
|
||||
calls.push(argv)
|
||||
if (argv[0] === 'symbolic-ref') {
|
||||
return { stdout: 'refs/remotes/origin/main\n' }
|
||||
}
|
||||
if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/master') {
|
||||
return { stdout: 'master-sha\n' }
|
||||
}
|
||||
throw new Error('missing ref')
|
||||
}
|
||||
|
||||
await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/master')
|
||||
|
||||
expect(calls).toEqual([
|
||||
['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'],
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'],
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRemoteCount', () => {
|
||||
let tmpDir: string
|
||||
|
||||
|
||||
+47
-27
@@ -467,6 +467,24 @@ function hasGitRef(path: string, ref: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function gitRefToDefaultBaseRef(ref: string): string {
|
||||
return ref.replace(/^refs\/remotes\//, '')
|
||||
}
|
||||
|
||||
function getVerifiedOriginHeadBaseRef(path: string): string | null {
|
||||
try {
|
||||
const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
|
||||
cwd: path
|
||||
}).trim()
|
||||
|
||||
// Why: origin/HEAD may survive a default-branch rename while pointing at a
|
||||
// deleted ref; verify before trusting it over the probe list.
|
||||
return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default base ref for new worktrees.
|
||||
* Prefer the remote primary branch over a potentially stale local branch.
|
||||
@@ -478,16 +496,9 @@ function hasGitRef(path: string, ref: string): boolean {
|
||||
* degrade gracefully for non-creation uses (e.g. hosted URL building).
|
||||
*/
|
||||
export function getDefaultBaseRef(path: string): string | null {
|
||||
try {
|
||||
const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
|
||||
cwd: path
|
||||
}).trim()
|
||||
|
||||
if (ref) {
|
||||
return ref.replace(/^refs\/remotes\//, '')
|
||||
}
|
||||
} catch {
|
||||
// Fall through to explicit remote branch probes.
|
||||
const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path)
|
||||
if (originHeadBaseRef) {
|
||||
return originHeadBaseRef
|
||||
}
|
||||
|
||||
// Why: walk the shared DEFAULT_BASE_REF_PROBES list so the sync path and the
|
||||
@@ -595,6 +606,28 @@ export async function getRemoteCount(path: string): Promise<number> {
|
||||
/** Callback shape for a git exec function that yields stdout. */
|
||||
export type GitExec = (argv: string[]) => Promise<{ stdout: string }>
|
||||
|
||||
async function hasGitRefViaExec(exec: GitExec, ref: string): Promise<boolean> {
|
||||
try {
|
||||
await exec(['rev-parse', '--verify', '--quiet', ref])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])
|
||||
const ref = stdout.trim()
|
||||
if (!ref || !(await hasGitRefViaExec(exec, ref))) {
|
||||
return null
|
||||
}
|
||||
return gitRefToDefaultBaseRef(ref)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default base ref given a git exec callback. Prefers
|
||||
* origin/HEAD's symbolic-ref target; falls back to DEFAULT_BASE_REF_PROBES.
|
||||
@@ -609,24 +642,11 @@ export type GitExec = (argv: string[]) => Promise<{ stdout: string }>
|
||||
* from a genuine transport failure.
|
||||
*/
|
||||
export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])
|
||||
const ref = stdout.trim()
|
||||
if (ref) {
|
||||
return ref.replace(/^refs\/remotes\//, '')
|
||||
}
|
||||
} catch {
|
||||
// symbolic-ref returns non-zero when origin/HEAD is unset — expected.
|
||||
// Fall through to probes.
|
||||
const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec)
|
||||
if (originHeadBaseRef) {
|
||||
return originHeadBaseRef
|
||||
}
|
||||
return resolveDefaultBaseRefFromProbes(async (ref) => {
|
||||
try {
|
||||
await exec(['rev-parse', '--verify', '--quiet', ref])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref))
|
||||
}
|
||||
|
||||
async function getDefaultBaseRefAsync(
|
||||
|
||||
@@ -10,11 +10,14 @@ export async function hasWorktreeBaseCommitRef(
|
||||
options: GitExecOptions = {}
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await gitExecFileAsync(['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`], {
|
||||
cwd: repoPath,
|
||||
...options
|
||||
})
|
||||
return true
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`],
|
||||
{
|
||||
cwd: repoPath,
|
||||
...options
|
||||
}
|
||||
)
|
||||
return stdout.trim().length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
|
||||
import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store'
|
||||
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store'
|
||||
import { killAllPty } from './ipc/pty'
|
||||
import { installRelocatedNodePtyNativeRuntime } from './pty/node-pty-runtime-relocation'
|
||||
import { initDaemonPtyProvider, disconnectDaemon, shutdownDaemon } from './daemon/daemon-init'
|
||||
import { closeAllWatchers } from './ipc/filesystem-watcher'
|
||||
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
|
||||
@@ -99,6 +100,7 @@ import {
|
||||
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
|
||||
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
|
||||
import { RateLimitService } from './rate-limits/service'
|
||||
import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store'
|
||||
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
|
||||
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
|
||||
import {
|
||||
@@ -574,6 +576,9 @@ if (hasSingleInstanceLock) {
|
||||
initClaudeUsagePath()
|
||||
initCodexUsagePath()
|
||||
initOpenCodeUsagePath()
|
||||
// Why: must run before anything loads node-pty (first PTY spawn) so main
|
||||
// and the daemon it forks both pick up ORCA_NODE_PTY_NATIVE_DIR.
|
||||
installRelocatedNodePtyNativeRuntime()
|
||||
crashReports = CrashReportStore.fromUserData()
|
||||
recordCrashBreadcrumb('app_started', {
|
||||
packaged: app.isPackaged,
|
||||
@@ -1685,6 +1690,14 @@ app.whenReady().then(async () => {
|
||||
workspaceIdOverride: settings.opencodeWorkspaceId
|
||||
}
|
||||
})
|
||||
rateLimits.setMiniMaxConfigResolver(() => {
|
||||
const settings = store!.getSettings()
|
||||
return {
|
||||
sessionCookie: readMiniMaxSessionCookie() ?? '',
|
||||
groupId: settings.minimaxGroupId,
|
||||
models: settings.minimaxUsageModels
|
||||
}
|
||||
})
|
||||
rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled)
|
||||
rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings())
|
||||
keybindings = new KeybindingService({
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
scanAiVaultSessions: vi.fn(),
|
||||
scanRemoteAiVaultSessions: vi.fn(),
|
||||
getAiVaultWslHomeDirs: vi.fn(),
|
||||
getSshFilesystemProvider: vi.fn(),
|
||||
getActiveSshAiVaultHostInfo: vi.fn(),
|
||||
getActiveSshAiVaultHostInfos: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { on: vi.fn() },
|
||||
ipcMain: { handle: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../ai-vault/session-scanner', () => ({
|
||||
scanAiVaultSessions: mocks.scanAiVaultSessions
|
||||
}))
|
||||
|
||||
vi.mock('../ai-vault/remote-session-scanner', () => ({
|
||||
scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', () => ({
|
||||
getWslHomeAsync: mocks.getAiVaultWslHomeDirs,
|
||||
listWslDistrosAsync: vi.fn().mockResolvedValue([])
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE:
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.',
|
||||
getSshFilesystemProvider: mocks.getSshFilesystemProvider
|
||||
}))
|
||||
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
|
||||
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos
|
||||
}))
|
||||
|
||||
const { _internals } = await import('./ai-vault')
|
||||
|
||||
const provider = {} as IFilesystemProvider
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_internals.resetAiVaultCacheForTests()
|
||||
mocks.scanAiVaultSessions.mockResolvedValue(result([session('local', 'local-session')]))
|
||||
mocks.scanRemoteAiVaultSessions.mockResolvedValue(
|
||||
result([session('ssh:dev-box', 'remote-session')])
|
||||
)
|
||||
mocks.getSshFilesystemProvider.mockReturnValue(provider)
|
||||
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box'))
|
||||
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')])
|
||||
})
|
||||
|
||||
describe('listAiVaultSessions host routing', () => {
|
||||
it('routes local scope to the local scanner', async () => {
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scopePaths: ['/repo'],
|
||||
executionHostId: 'local'
|
||||
})
|
||||
)
|
||||
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes SSH scope to only that SSH target', async () => {
|
||||
await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:dev-box',
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
|
||||
expect(mocks.scanAiVaultSessions).not.toHaveBeenCalled()
|
||||
expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box')
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
scopePaths: ['/home/ada/repo']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('merges local plus connected SSH targets for all hosts', async () => {
|
||||
const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
|
||||
})
|
||||
|
||||
it('returns a scan issue for a disconnected SSH target', async () => {
|
||||
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null)
|
||||
mocks.getSshFilesystemProvider.mockReturnValue(undefined)
|
||||
|
||||
const result = await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'ssh:disconnected'
|
||||
})
|
||||
|
||||
expect(result.sessions).toEqual([])
|
||||
expect(result.issues).toMatchObject([
|
||||
{
|
||||
executionHostId: 'ssh:disconnected',
|
||||
agent: 'codex',
|
||||
path: 'disconnected'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps host scope in the cache key', async () => {
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'local' })
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' })
|
||||
|
||||
expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
function hostInfo(targetId: string) {
|
||||
return {
|
||||
targetId,
|
||||
executionHostId: `ssh:${targetId}` as const,
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
}
|
||||
}
|
||||
|
||||
function result(sessions: AiVaultSession[]): AiVaultListResult {
|
||||
return { sessions, issues: [], scannedAt: new Date().toISOString() }
|
||||
}
|
||||
|
||||
function session(
|
||||
executionHostId: AiVaultSession['executionHostId'],
|
||||
sessionId: string
|
||||
): AiVaultSession {
|
||||
return {
|
||||
id: `${executionHostId}:codex:${sessionId}:/tmp/${sessionId}.jsonl`,
|
||||
executionHostId,
|
||||
agent: 'codex',
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
cwd: '/repo',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: `/tmp/${sessionId}.jsonl`,
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt:
|
||||
sessionId === 'remote-session' ? '2026-07-04T02:00:00.000Z' : '2026-07-04T01:00:00.000Z',
|
||||
modifiedAt: '2026-07-04T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
resumeCommand: `codex resume ${sessionId}`
|
||||
}
|
||||
}
|
||||
+160
-14
@@ -1,8 +1,26 @@
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner'
|
||||
import { scanAiVaultSessions } from '../ai-vault/session-scanner'
|
||||
import { sessionSortTime } from '../ai-vault/session-scanner-accumulator'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultListArgs,
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue
|
||||
} from '../../shared/ai-vault-types'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostScope,
|
||||
parseExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostScope
|
||||
} from '../../shared/execution-host'
|
||||
import {
|
||||
getSshFilesystemProvider,
|
||||
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh'
|
||||
|
||||
const AI_VAULT_CACHE_TTL_MS = 15_000
|
||||
|
||||
@@ -22,10 +40,14 @@ let inflightKey: string | null = null
|
||||
let handlerOptions: AiVaultHandlerOptions = {}
|
||||
|
||||
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
const executionHostScope = normalizeExecutionHostScope(
|
||||
args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
// Scope paths change the result set, so they must be part of the cache key.
|
||||
const key = JSON.stringify({
|
||||
limit: args?.limit ?? 'default',
|
||||
scopePaths: args?.scopePaths ?? []
|
||||
scopePaths: args?.scopePaths ?? [],
|
||||
executionHostScope
|
||||
})
|
||||
const now = Date.now()
|
||||
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
|
||||
@@ -38,16 +60,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
|
||||
}
|
||||
|
||||
inflightKey = key
|
||||
const additionalCodexSessionsDirs =
|
||||
handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ??
|
||||
[]
|
||||
inflightList = (async () =>
|
||||
scanAiVaultSessions({
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths,
|
||||
additionalCodexSessionsDirs,
|
||||
wslHomeDirs: await getAiVaultWslHomeDirs()
|
||||
}))()
|
||||
inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope)
|
||||
.then((result) => {
|
||||
cachedList = {
|
||||
key,
|
||||
@@ -57,12 +70,133 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
|
||||
return result
|
||||
})
|
||||
.finally(() => {
|
||||
inflightKey = null
|
||||
inflightList = null
|
||||
// Only clear tracking if it still refers to this request: a concurrent
|
||||
// different-scope scan may have replaced it and must stay dedupable.
|
||||
if (inflightKey === key) {
|
||||
inflightKey = null
|
||||
inflightList = null
|
||||
}
|
||||
})
|
||||
return inflightList
|
||||
}
|
||||
|
||||
async function scanAiVaultSessionsByHostScope(
|
||||
args: AiVaultListArgs | undefined,
|
||||
executionHostScope: ExecutionHostScope
|
||||
): Promise<AiVaultListResult> {
|
||||
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
|
||||
return scanLocalAiVaultSessions(args)
|
||||
}
|
||||
|
||||
if (executionHostScope === 'all') {
|
||||
return mergeAiVaultListResults(
|
||||
await Promise.all([
|
||||
scanLocalAiVaultSessions(args),
|
||||
...getActiveSshAiVaultHostInfos().map((hostInfo) =>
|
||||
scanSshAiVaultSessions(hostInfo.targetId, args)
|
||||
)
|
||||
]),
|
||||
args?.limit
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = parseExecutionHostId(executionHostScope)
|
||||
if (parsed?.kind === 'ssh') {
|
||||
return scanSshAiVaultSessions(parsed.targetId, args)
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
{
|
||||
executionHostId: executionHostScope,
|
||||
agent: 'codex',
|
||||
path: executionHostScope,
|
||||
message: 'Agent Session History is not available for this execution host.'
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
|
||||
const additionalCodexSessionsDirs =
|
||||
handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ??
|
||||
[]
|
||||
return scanAiVaultSessions({
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths,
|
||||
additionalCodexSessionsDirs,
|
||||
wslHomeDirs: await getAiVaultWslHomeDirs(),
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID
|
||||
})
|
||||
}
|
||||
|
||||
async function scanSshAiVaultSessions(
|
||||
targetId: string,
|
||||
args?: AiVaultListArgs
|
||||
): Promise<AiVaultListResult> {
|
||||
const executionHostId = toSshExecutionHostId(targetId)
|
||||
const hostInfo = getActiveSshAiVaultHostInfo(targetId)
|
||||
const provider = getSshFilesystemProvider(targetId)
|
||||
if (!hostInfo || !provider) {
|
||||
return sshScanIssueResult({
|
||||
executionHostId,
|
||||
targetId,
|
||||
message: SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
})
|
||||
}
|
||||
return scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: hostInfo.executionHostId,
|
||||
remoteHome: hostInfo.remoteHome,
|
||||
hostPlatform: hostInfo.hostPlatform,
|
||||
limit: args?.limit,
|
||||
scopePaths: args?.scopePaths
|
||||
})
|
||||
}
|
||||
|
||||
function sshScanIssueResult(args: {
|
||||
executionHostId: `ssh:${string}`
|
||||
targetId: string
|
||||
message: string
|
||||
}): AiVaultListResult {
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
{
|
||||
executionHostId: args.executionHostId,
|
||||
agent: 'codex',
|
||||
path: args.targetId,
|
||||
message: args.message
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
function mergeAiVaultListResults(
|
||||
results: readonly AiVaultListResult[],
|
||||
rawLimit: number | undefined
|
||||
): AiVaultListResult {
|
||||
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
|
||||
const byId = new Map<string, AiVaultListResult['sessions'][number]>()
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
for (const result of results) {
|
||||
for (const session of result.sessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
issues.push(...result.issues)
|
||||
}
|
||||
return {
|
||||
sessions: [...byId.values()]
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit),
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
|
||||
handlerOptions = options
|
||||
ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) =>
|
||||
@@ -77,6 +211,18 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
|
||||
})
|
||||
}
|
||||
|
||||
function resetAiVaultCacheForTests(): void {
|
||||
cachedList = null
|
||||
inflightList = null
|
||||
inflightKey = null
|
||||
handlerOptions = {}
|
||||
}
|
||||
|
||||
export const _internals = {
|
||||
listAiVaultSessions,
|
||||
resetAiVaultCacheForTests
|
||||
}
|
||||
|
||||
async function getAiVaultWslHomeDirs(): Promise<string[]> {
|
||||
if (process.platform !== 'win32') {
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const ipcState = vi.hoisted(() => ({
|
||||
handleHandlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
ipcState.handleHandlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const saveMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
|
||||
const clearMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn())
|
||||
const hasMiniMaxSessionCookieMock = vi.hoisted(() => vi.fn(() => false))
|
||||
const clearMiniMaxSessionCookieJarMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
|
||||
vi.mock('../minimax/minimax-cookie-store', () => ({
|
||||
saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock,
|
||||
clearMiniMaxSessionCookie: clearMiniMaxSessionCookieMock,
|
||||
hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock
|
||||
}))
|
||||
|
||||
vi.mock('../rate-limits/minimax-request-context', () => ({
|
||||
clearMiniMaxSessionCookieJar: clearMiniMaxSessionCookieJarMock
|
||||
}))
|
||||
|
||||
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
|
||||
function makeRefreshMock(): {
|
||||
refresh: ReturnType<typeof vi.fn>
|
||||
invalidateMiniMaxCredentialState: ReturnType<typeof vi.fn>
|
||||
service: Pick<RateLimitService, 'refresh' | 'invalidateMiniMaxCredentialState'>
|
||||
} {
|
||||
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
|
||||
const invalidateMiniMaxCredentialState = vi.fn()
|
||||
return {
|
||||
refresh,
|
||||
invalidateMiniMaxCredentialState,
|
||||
service: { refresh, invalidateMiniMaxCredentialState }
|
||||
}
|
||||
}
|
||||
|
||||
async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
|
||||
const handler = ipcState.handleHandlers.get(channel)
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for ${channel}`)
|
||||
}
|
||||
return (await handler({}, ...args)) as T
|
||||
}
|
||||
|
||||
describe('registerMiniMaxCredentialsHandlers', () => {
|
||||
beforeEach(() => {
|
||||
ipcState.handleHandlers.clear()
|
||||
saveMiniMaxSessionCookieMock.mockReset()
|
||||
clearMiniMaxSessionCookieMock.mockReset()
|
||||
clearMiniMaxSessionCookieJarMock.mockReset()
|
||||
clearMiniMaxSessionCookieJarMock.mockResolvedValue(undefined)
|
||||
hasMiniMaxSessionCookieMock.mockReset()
|
||||
hasMiniMaxSessionCookieMock.mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('registers the three MiniMax credential channels', () => {
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
expect(ipcState.handleHandlers.has('minimaxCredentials:getStatus')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('minimaxCredentials:saveCookie')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('minimaxCredentials:clearCookie')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the configured state on getStatus from the cookie store', async () => {
|
||||
hasMiniMaxSessionCookieMock.mockReturnValue(true)
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
const status = await invoke<{ configured: boolean }>('minimaxCredentials:getStatus')
|
||||
expect(status).toEqual({ configured: true })
|
||||
})
|
||||
|
||||
it('persists the cookie and reports configured after saveCookie', async () => {
|
||||
hasMiniMaxSessionCookieMock.mockReturnValueOnce(true)
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
const status = await invoke<{ configured: boolean }>(
|
||||
'minimaxCredentials:saveCookie',
|
||||
'_token=abc; minimax_group_id_v2=42'
|
||||
)
|
||||
expect(saveMiniMaxSessionCookieMock).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
|
||||
expect(status).toEqual({ configured: true })
|
||||
})
|
||||
|
||||
it('triggers a rate-limit refresh after saveCookie when a service is provided', async () => {
|
||||
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
|
||||
registerMiniMaxCredentialsHandlers(service as RateLimitService)
|
||||
await invoke('minimaxCredentials:saveCookie', '_token=abc')
|
||||
// Why: the save handler is fire-and-forget — wait a microtask cycle so
|
||||
// the queued `void rateLimits?.refresh()` resolves before we assert.
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not throw when saveCookie runs without a rate-limit service', async () => {
|
||||
registerMiniMaxCredentialsHandlers(null)
|
||||
await expect(invoke('minimaxCredentials:saveCookie', '_token=abc')).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('clears the cookie and triggers a refresh on clearCookie', async () => {
|
||||
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
|
||||
hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
|
||||
registerMiniMaxCredentialsHandlers(service as RateLimitService)
|
||||
const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
|
||||
expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
|
||||
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
|
||||
expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1)
|
||||
expect(status).toEqual({ configured: false })
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still refreshes and reports cleared when session jar cleanup rejects', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const { refresh, invalidateMiniMaxCredentialState, service } = makeRefreshMock()
|
||||
clearMiniMaxSessionCookieJarMock.mockRejectedValueOnce(new Error('jar boom'))
|
||||
hasMiniMaxSessionCookieMock.mockReturnValueOnce(false)
|
||||
registerMiniMaxCredentialsHandlers(service as RateLimitService)
|
||||
|
||||
const status = await invoke<{ configured: boolean }>('minimaxCredentials:clearCookie')
|
||||
|
||||
expect(clearMiniMaxSessionCookieMock).toHaveBeenCalledTimes(1)
|
||||
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
|
||||
expect(clearMiniMaxSessionCookieJarMock).toHaveBeenCalledTimes(1)
|
||||
expect(status).toEqual({ configured: false })
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed to clear session cookie jar after credential clear'),
|
||||
expect.any(Error)
|
||||
)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('logs but does not throw when the post-save rate-limit refresh rejects', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const refresh = vi.fn(() => Promise.reject(new Error('refresh boom')))
|
||||
const invalidateMiniMaxCredentialState = vi.fn()
|
||||
registerMiniMaxCredentialsHandlers({
|
||||
refresh,
|
||||
invalidateMiniMaxCredentialState
|
||||
} as Pick<RateLimitService, 'refresh' | 'invalidateMiniMaxCredentialState'> as RateLimitService)
|
||||
await invoke('minimaxCredentials:saveCookie', '_token=abc')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(invalidateMiniMaxCredentialState).toHaveBeenCalledTimes(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed to trigger rate-limit refresh after save'),
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import {
|
||||
clearMiniMaxSessionCookie,
|
||||
hasMiniMaxSessionCookie,
|
||||
saveMiniMaxSessionCookie
|
||||
} from '../minimax/minimax-cookie-store'
|
||||
import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax-request-context'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
|
||||
export type MiniMaxCredentialsStatus = {
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus {
|
||||
return { configured: hasMiniMaxSessionCookie() }
|
||||
}
|
||||
|
||||
// Why: fire-and-forget — callers get the persisted cookie status immediately;
|
||||
// the rate-limit refresh runs in the background and only logs on failure.
|
||||
function refreshAfterMiniMaxCredentialChange(
|
||||
rateLimits: RateLimitService | null,
|
||||
action: 'save' | 'clear'
|
||||
): void {
|
||||
rateLimits?.invalidateMiniMaxCredentialState()
|
||||
void rateLimits?.refresh().catch((error: unknown) => {
|
||||
console.error(`[minimax] failed to trigger rate-limit refresh after ${action}:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
export function registerMiniMaxCredentialsHandlers(rateLimits: RateLimitService | null): void {
|
||||
ipcMain.handle('minimaxCredentials:getStatus', () => getMiniMaxCredentialsStatus())
|
||||
ipcMain.handle('minimaxCredentials:saveCookie', (_event, cookie: string) => {
|
||||
// Validate the IPC argument in the main process; the renderer-declared type
|
||||
// is compile-time only and the value arrives as unknown over IPC.
|
||||
if (typeof cookie !== 'string') {
|
||||
throw new Error('MiniMax session cookie must be a string')
|
||||
}
|
||||
saveMiniMaxSessionCookie(cookie)
|
||||
refreshAfterMiniMaxCredentialChange(rateLimits, 'save')
|
||||
return getMiniMaxCredentialsStatus()
|
||||
})
|
||||
ipcMain.handle('minimaxCredentials:clearCookie', async () => {
|
||||
clearMiniMaxSessionCookie()
|
||||
try {
|
||||
await clearMiniMaxSessionCookieJar()
|
||||
} catch (error) {
|
||||
console.error('[minimax] failed to clear session cookie jar after credential clear:', error)
|
||||
}
|
||||
refreshAfterMiniMaxCredentialChange(rateLimits, 'clear')
|
||||
return getMiniMaxCredentialsStatus()
|
||||
})
|
||||
}
|
||||
@@ -3509,6 +3509,17 @@ export function registerPtyHandlers(
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
ipcMain.removeAllListeners('pty:clearBuffer')
|
||||
ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => {
|
||||
// Why: the renderer already cleared its own xterm buffer. This clears the
|
||||
// PTY-side state (ConPTY screen buffer, daemon emulator, SSH host buffer)
|
||||
// so the next prompt repaint doesn't land at a stale cursor row.
|
||||
tryGetProviderForPty(args.id)
|
||||
?.clearBuffer(args.id)
|
||||
.catch(() => {})
|
||||
runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {})
|
||||
})
|
||||
|
||||
ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => {
|
||||
const ownedConnectionId = ptyOwnership.get(args.id)
|
||||
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const ipcState = vi.hoisted(() => ({
|
||||
handleHandlers: new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
|
||||
ipcState.handleHandlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { registerRateLimitHandlers } from './rate-limits'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
|
||||
function makeService(): { service: RateLimitService; refresh: ReturnType<typeof vi.fn> } {
|
||||
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
|
||||
const service = {
|
||||
getState: vi.fn(() => ({}) as RateLimitState),
|
||||
refresh,
|
||||
refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
|
||||
refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
|
||||
consumeCodexRateLimitResetCredit: vi.fn(() =>
|
||||
Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState })
|
||||
),
|
||||
setPollingInterval: vi.fn(() => Promise.resolve()),
|
||||
fetchInactiveClaudeAccountsOnOpen: vi.fn(() => Promise.resolve()),
|
||||
fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve())
|
||||
}
|
||||
return { service: service as unknown as RateLimitService, refresh }
|
||||
}
|
||||
|
||||
describe('registerRateLimitHandlers', () => {
|
||||
beforeEach(() => {
|
||||
ipcState.handleHandlers.clear()
|
||||
})
|
||||
|
||||
it('registers a refreshMiniMax channel that delegates to refresh()', async () => {
|
||||
const { service, refresh } = makeService()
|
||||
registerRateLimitHandlers(service)
|
||||
const handler = ipcState.handleHandlers.get('rateLimits:refreshMiniMax')
|
||||
expect(handler).toBeDefined()
|
||||
await handler!({})
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the existing rate-limit channels registered', () => {
|
||||
const { service } = makeService()
|
||||
registerRateLimitHandlers(service)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -23,4 +23,5 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void {
|
||||
ipcMain.handle('rateLimits:fetchInactiveCodexAccounts', () =>
|
||||
rateLimits.fetchInactiveCodexAccountsOnOpen()
|
||||
)
|
||||
ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh())
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
registerAgentHookHandlersMock,
|
||||
registerAgentTrustHandlersMock,
|
||||
registerClaudeAccountHandlersMock,
|
||||
registerMiniMaxCredentialsHandlersMock,
|
||||
registerClipboardHandlersMock,
|
||||
setTrustedClipboardRendererWebContentsIdMock,
|
||||
registerUpdaterHandlersMock,
|
||||
@@ -89,6 +90,7 @@ const {
|
||||
registerAgentHookHandlersMock: vi.fn(),
|
||||
registerAgentTrustHandlersMock: vi.fn(),
|
||||
registerClaudeAccountHandlersMock: vi.fn(),
|
||||
registerMiniMaxCredentialsHandlersMock: vi.fn(),
|
||||
registerClipboardHandlersMock: vi.fn(),
|
||||
setTrustedClipboardRendererWebContentsIdMock: vi.fn(),
|
||||
registerUpdaterHandlersMock: vi.fn(),
|
||||
@@ -279,6 +281,10 @@ vi.mock('./claude-accounts', () => ({
|
||||
registerClaudeAccountHandlers: registerClaudeAccountHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./minimax-credentials', () => ({
|
||||
registerMiniMaxCredentialsHandlers: registerMiniMaxCredentialsHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('../window/attach-main-window-services', () => ({
|
||||
registerUpdaterHandlers: registerUpdaterHandlersMock
|
||||
}))
|
||||
@@ -353,6 +359,7 @@ describe('registerCoreHandlers', () => {
|
||||
registerAgentHookHandlersMock.mockReset()
|
||||
registerAgentTrustHandlersMock.mockReset()
|
||||
registerClaudeAccountHandlersMock.mockReset()
|
||||
registerMiniMaxCredentialsHandlersMock.mockReset()
|
||||
registerClipboardHandlersMock.mockReset()
|
||||
setTrustedClipboardRendererWebContentsIdMock.mockReset()
|
||||
registerUpdaterHandlersMock.mockReset()
|
||||
@@ -418,6 +425,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerPetHandlersMock).toHaveBeenCalled()
|
||||
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
|
||||
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
|
||||
expect(registerLinearHandlersMock).toHaveBeenCalled()
|
||||
|
||||
@@ -53,6 +53,7 @@ import { registerCodexAccountHandlers } from './codex-accounts'
|
||||
import { registerAgentHookHandlers } from './agent-hooks'
|
||||
import { registerAgentTrustHandlers } from './agent-trust'
|
||||
import { registerClaudeAccountHandlers } from './claude-accounts'
|
||||
import { registerMiniMaxCredentialsHandlers } from './minimax-credentials'
|
||||
import { registerUpdaterHandlers } from '../window/attach-main-window-services'
|
||||
import {
|
||||
registerClipboardHandlers,
|
||||
@@ -117,6 +118,7 @@ export function registerCoreHandlers(
|
||||
registerAgentHookHandlers(runtime)
|
||||
registerAgentTrustHandlers()
|
||||
registerClaudeAccountHandlers(claudeAccounts)
|
||||
registerMiniMaxCredentialsHandlers(rateLimits)
|
||||
registerRateLimitHandlers(rateLimits)
|
||||
registerGitHubHandlers(store, stats)
|
||||
registerGitLabHandlers(store)
|
||||
|
||||
@@ -2413,13 +2413,13 @@ describe('repos:getBaseRefDefault envelope', () => {
|
||||
// independent of which Promise in the Promise.all resolves first.
|
||||
type ExecResponse = { stdout: string; stderr: string }
|
||||
type ExecRule = {
|
||||
match: (argv: string[]) => boolean
|
||||
matches: (argv: string[]) => boolean
|
||||
respond: () => Promise<ExecResponse>
|
||||
}
|
||||
const dispatchExec = (rules: ExecRule[]): ((argv: string[]) => Promise<ExecResponse>) => {
|
||||
return (argv: string[]) => {
|
||||
for (const rule of rules) {
|
||||
if (rule.match(argv)) {
|
||||
if (rule.matches(argv)) {
|
||||
return rule.respond()
|
||||
}
|
||||
}
|
||||
@@ -2438,11 +2438,17 @@ describe('repos:getBaseRefDefault envelope', () => {
|
||||
mockGitProvider.exec = vi.fn().mockImplementation(
|
||||
dispatchExec([
|
||||
{
|
||||
match: isSymbolicRef,
|
||||
matches: isSymbolicRef,
|
||||
respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' })
|
||||
},
|
||||
// The origin/HEAD target is verified before it is trusted, so the
|
||||
// symbolic-ref result must also resolve via rev-parse.
|
||||
{
|
||||
match: isRemoteList,
|
||||
matches: isRevParseFor('refs/remotes/origin/main'),
|
||||
respond: () => Promise.resolve({ stdout: '', stderr: '' })
|
||||
},
|
||||
{
|
||||
matches: isRemoteList,
|
||||
respond: () => Promise.resolve({ stdout: 'origin\nupstream\n', stderr: '' })
|
||||
}
|
||||
])
|
||||
@@ -2468,11 +2474,17 @@ describe('repos:getBaseRefDefault envelope', () => {
|
||||
mockGitProvider.exec = vi.fn().mockImplementation(
|
||||
dispatchExec([
|
||||
{
|
||||
match: isSymbolicRef,
|
||||
matches: isSymbolicRef,
|
||||
respond: () => Promise.resolve({ stdout: 'refs/remotes/origin/main\n', stderr: '' })
|
||||
},
|
||||
// The origin/HEAD target is verified before it is trusted, so the
|
||||
// symbolic-ref result must also resolve via rev-parse.
|
||||
{
|
||||
match: isRemoteList,
|
||||
matches: isRevParseFor('refs/remotes/origin/main'),
|
||||
respond: () => Promise.resolve({ stdout: '', stderr: '' })
|
||||
},
|
||||
{
|
||||
matches: isRemoteList,
|
||||
respond: () => Promise.reject(new Error('relay exec failed'))
|
||||
}
|
||||
])
|
||||
@@ -2500,18 +2512,21 @@ describe('repos:getBaseRefDefault envelope', () => {
|
||||
mockGitProvider.exec = vi.fn().mockImplementation(
|
||||
dispatchExec([
|
||||
// symbolic-ref rejects (no origin/HEAD on the remote)
|
||||
{ match: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) },
|
||||
{ matches: isSymbolicRef, respond: () => Promise.reject(new Error('no symbolic-ref')) },
|
||||
// probe 1: refs/remotes/origin/main — rejects
|
||||
{
|
||||
match: isRevParseFor('refs/remotes/origin/main'),
|
||||
matches: isRevParseFor('refs/remotes/origin/main'),
|
||||
respond: () => Promise.reject(new Error('missing'))
|
||||
},
|
||||
// probe 2: refs/remotes/origin/master — succeeds
|
||||
{
|
||||
match: isRevParseFor('refs/remotes/origin/master'),
|
||||
matches: isRevParseFor('refs/remotes/origin/master'),
|
||||
respond: () => Promise.resolve({ stdout: 'abc123\n', stderr: '' })
|
||||
},
|
||||
{ match: isRemoteList, respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' }) }
|
||||
{
|
||||
matches: isRemoteList,
|
||||
respond: () => Promise.resolve({ stdout: 'origin\n', stderr: '' })
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
|
||||
const SSH_CONNECTION_UNAVAILABLE_MESSAGE =
|
||||
'SSH connection is not available. Please reconnect and try again.'
|
||||
|
||||
type SshRootRegistrationMux = {
|
||||
request: (method: 'session.registerRoot', payload: { rootPath: string }) => Promise<unknown>
|
||||
notify: (method: 'session.registerRoot', payload: { rootPath: string }) => void
|
||||
}
|
||||
|
||||
async function registerSshWorktreeCreateRoots(
|
||||
mux: SshRootRegistrationMux,
|
||||
rootPaths: string[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
rootPaths.map((rootPath) => mux.request('session.registerRoot', { rootPath }))
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('Method not found')) {
|
||||
for (const rootPath of rootPaths) {
|
||||
mux.notify('session.registerRoot', { rootPath })
|
||||
}
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerOptionalSshWorktreeCreateRoots(
|
||||
connectionId: string,
|
||||
rootPaths: string[]
|
||||
): Promise<void> {
|
||||
const mux = getActiveMultiplexer(connectionId)
|
||||
if (!mux) {
|
||||
return
|
||||
}
|
||||
await registerSshWorktreeCreateRoots(mux, rootPaths)
|
||||
}
|
||||
|
||||
export async function registerRequiredSshWorktreeCreateRoots(
|
||||
connectionId: string,
|
||||
rootPaths: string[]
|
||||
): Promise<void> {
|
||||
const mux = getActiveMultiplexer(connectionId)
|
||||
if (!mux) {
|
||||
throw new Error(SSH_CONNECTION_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
await registerSshWorktreeCreateRoots(mux, rootPaths)
|
||||
}
|
||||
+18
-1
@@ -5,7 +5,7 @@ import type { Store } from '../persistence'
|
||||
import { SshConnectionStore } from '../ssh/ssh-connection-store'
|
||||
import { SshConnectionManager, type SshConnectionCallbacks } from '../ssh/ssh-connection'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { SshRelaySession } from '../ssh/ssh-relay-session'
|
||||
import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session'
|
||||
import { SshPortForwardManager } from '../ssh/ssh-port-forward'
|
||||
import type {
|
||||
DetectedPort,
|
||||
@@ -123,6 +123,23 @@ export async function removeRegisteredSshTarget(targetId: string): Promise<void>
|
||||
// scattered Maps/Sets that previously tracked this state independently.
|
||||
const activeSessions = new Map<string, SshRelaySession>()
|
||||
|
||||
export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHostInfo | null {
|
||||
if (isRuntimeOwnedSshTargetId(targetId)) {
|
||||
return null
|
||||
}
|
||||
return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null
|
||||
}
|
||||
|
||||
export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] {
|
||||
return [...activeSessions.values()].flatMap((session) => {
|
||||
if (isRuntimeOwnedSshTargetId(session.targetId)) {
|
||||
return []
|
||||
}
|
||||
const info = session.getAiVaultHostInfo()
|
||||
return info ? [info] : []
|
||||
})
|
||||
}
|
||||
|
||||
async function detachActiveSshSession(targetId: string): Promise<void> {
|
||||
await teardownActiveSshSession(targetId, (session) => session.detach())
|
||||
}
|
||||
|
||||
+158
-67
@@ -30,6 +30,8 @@ import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree'
|
||||
import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo'
|
||||
import { resolveLocalGitUsername } from '../git/git-username'
|
||||
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
|
||||
import { resolveWorktreeCreateBase } from '../worktree-create-base'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
|
||||
import { getHostedReviewForBranch } from '../source-control/hosted-review'
|
||||
import type { ForgeProviderId } from '../source-control/forge-provider'
|
||||
import { validateGitPushTarget } from '../git/push-target-validation'
|
||||
@@ -53,12 +55,15 @@ import {
|
||||
} from '../hooks'
|
||||
import { requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { getSshGitUsername } from '../git/git-username'
|
||||
import { runWorktreeChangeInvalidators } from './worktree-change-invalidators'
|
||||
import {
|
||||
registerOptionalSshWorktreeCreateRoots,
|
||||
registerRequiredSshWorktreeCreateRoots
|
||||
} from './ssh-worktree-create-root-registration'
|
||||
|
||||
type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
|
||||
automationProvenance?: AutomationWorkspaceProvenance
|
||||
@@ -608,6 +613,35 @@ function hasLocalCommitObjectWithOptions(
|
||||
)
|
||||
}
|
||||
|
||||
async function hasLocalWorktreeBaseRefWithOptions(
|
||||
repoPath: string,
|
||||
baseRef: string,
|
||||
gitOptions: { wslDistro?: string }
|
||||
): Promise<boolean> {
|
||||
const refExists = async (qualifiedRef: string) => {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['rev-parse', '--verify', '--quiet', `${qualifiedRef}^{commit}`],
|
||||
{
|
||||
cwd: repoPath,
|
||||
...gitOptions
|
||||
}
|
||||
)
|
||||
return stdout.trim().length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
|
||||
if (resolvedBaseRef !== baseRef) {
|
||||
return true
|
||||
}
|
||||
if (baseRef.startsWith('refs/')) {
|
||||
return refExists(baseRef)
|
||||
}
|
||||
return hasLocalCommitObjectWithOptions(repoPath, baseRef, gitOptions)
|
||||
}
|
||||
|
||||
function getLocalGitHubPrForBranch(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
@@ -626,6 +660,23 @@ function hasRemoteCommitObject(
|
||||
return hasCommitObjectViaGitExec((gitArgs) => provider.exec(gitArgs, repoPath), ref)
|
||||
}
|
||||
|
||||
async function hasRemoteWorktreeBaseRef(
|
||||
provider: SshGitProvider,
|
||||
repoPath: string,
|
||||
baseRef: string
|
||||
): Promise<boolean> {
|
||||
const refExists = (qualifiedRef: string) =>
|
||||
hasRemoteTrackingRefSsh(provider, repoPath, qualifiedRef)
|
||||
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
|
||||
if (resolvedBaseRef !== baseRef) {
|
||||
return true
|
||||
}
|
||||
if (baseRef.startsWith('refs/')) {
|
||||
return refExists(baseRef)
|
||||
}
|
||||
return hasRemoteCommitObject(provider, repoPath, baseRef)
|
||||
}
|
||||
|
||||
// Why: hasRemoteCommitObject only resolves full SHAs; a remote-tracking base is
|
||||
// a symbolic ref (refs/remotes/origin/main), so detect its presence directly so
|
||||
// SSH creates can fall back to an existing local base ref when the refresh
|
||||
@@ -1218,33 +1269,31 @@ async function resolveRemoteTrackingBaseSsh(
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRemoteWorktreeCreateBase(
|
||||
provider: SshGitProvider,
|
||||
repo: Repo,
|
||||
requestedBaseBranch: string | undefined
|
||||
): Promise<string | null> {
|
||||
let baseBranch = requestedBaseBranch || repo.worktreeBaseRef
|
||||
if (baseBranch) {
|
||||
return baseBranch
|
||||
}
|
||||
try {
|
||||
const { stdout } = await provider.exec(
|
||||
['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'],
|
||||
repo.path
|
||||
)
|
||||
baseBranch = stdout.trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return baseBranch || null
|
||||
}
|
||||
|
||||
async function resolveRemoteWorktreeCreateBasePlan(
|
||||
provider: SshGitProvider,
|
||||
repo: Repo,
|
||||
requestedBaseBranch: string | undefined
|
||||
): Promise<RemoteWorktreeCreateBasePlan | null> {
|
||||
const baseBranch = await resolveRemoteWorktreeCreateBase(provider, repo, requestedBaseBranch)
|
||||
const baseBranch = await resolveWorktreeCreateBase({
|
||||
requestedBaseBranch,
|
||||
repoWorktreeBaseRef: repo.worktreeBaseRef,
|
||||
resolveDefaultBaseRef: () =>
|
||||
resolveDefaultBaseRefViaExec((argv) => provider.exec(argv, repo.path)),
|
||||
isBaseUsable: async (baseBranchCandidate) => {
|
||||
const remoteTrackingBase = await resolveRemoteTrackingBaseSsh(
|
||||
provider,
|
||||
repo.path,
|
||||
baseBranchCandidate
|
||||
)
|
||||
if (remoteTrackingBase) {
|
||||
if (await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref)) {
|
||||
return true
|
||||
}
|
||||
return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate)
|
||||
}
|
||||
return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate)
|
||||
}
|
||||
})
|
||||
if (!baseBranch) {
|
||||
return null
|
||||
}
|
||||
@@ -1280,15 +1329,23 @@ export async function prefetchRemoteWorktreeCreateBase(
|
||||
repo: Repo,
|
||||
args: { baseBranch?: string }
|
||||
): Promise<void> {
|
||||
// Why: the shared base-plan probes use generic git.exec, and some relays
|
||||
// require the repo root to be registered before those probes can see refs.
|
||||
await registerOptionalSshWorktreeCreateRoots(repo.connectionId!, [repo.path])
|
||||
const basePlan = await getOrStartRemoteWorktreeCreateBasePlan(provider, repo, args.baseBranch)
|
||||
if (!basePlan) {
|
||||
return
|
||||
}
|
||||
if (basePlan.remoteTrackingBase) {
|
||||
await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase)
|
||||
return
|
||||
if (
|
||||
(await hasRemoteTrackingRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) ||
|
||||
!(await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch))
|
||||
) {
|
||||
await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (await hasRemoteCommitObject(provider, repo.path, basePlan.baseBranch)) {
|
||||
if (await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch)) {
|
||||
// Why: PR/MR resolvers already fetched verified SHA start points. A broad
|
||||
// remote fetch only updates unrelated refs when the commit object exists.
|
||||
return
|
||||
@@ -1471,6 +1528,10 @@ export async function createRemoteWorktree(
|
||||
? sanitizeWorktreeDisplayName(args.displayName)
|
||||
: undefined
|
||||
|
||||
// Why: resolving the create base can probe repo refs through generic git.exec.
|
||||
// Register the repo root first so relays do not report a valid base as stale.
|
||||
await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [repo.path])
|
||||
|
||||
// Why: SSH targets cannot use the local `gh` account, and git email/name are
|
||||
// commit author identity rather than hosted-account usernames.
|
||||
const username = await getSshGitUsername(provider, repo.path)
|
||||
@@ -1488,7 +1549,8 @@ export async function createRemoteWorktree(
|
||||
'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.'
|
||||
)
|
||||
}
|
||||
const { baseBranch, remoteTrackingBase } = basePlan
|
||||
const { baseBranch } = basePlan
|
||||
let { remoteTrackingBase } = basePlan
|
||||
|
||||
let branchName = ''
|
||||
let checkoutExistingBranch = false
|
||||
@@ -1594,25 +1656,20 @@ export async function createRemoteWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
const mux = getActiveMultiplexer(repo.connectionId!)
|
||||
if (!mux) {
|
||||
throw new Error('SSH connection is not available. Please reconnect and try again.')
|
||||
}
|
||||
// Why: register before any generic git.exec probe (base-ref existence, the
|
||||
// local-base advisory) as well as addWorktree. Fresh/older relays may gate
|
||||
// generic git.exec on registered roots; probing first would falsely report a
|
||||
// ref missing (and defeat the offline fallback below) even though create works.
|
||||
try {
|
||||
await Promise.all([
|
||||
mux.request('session.registerRoot', { rootPath: repo.path }),
|
||||
mux.request('session.registerRoot', { rootPath: remotePath })
|
||||
])
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('Method not found')) {
|
||||
mux.notify('session.registerRoot', { rootPath: repo.path })
|
||||
mux.notify('session.registerRoot', { rootPath: remotePath })
|
||||
} else {
|
||||
throw err
|
||||
// Why: addWorktree and setup probes run inside the new worktree path; older
|
||||
// relays need that root registered before accepting git/fs operations there.
|
||||
await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [remotePath])
|
||||
|
||||
if (remoteTrackingBase) {
|
||||
const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh(
|
||||
provider,
|
||||
repo.path,
|
||||
remoteTrackingBase.ref
|
||||
)
|
||||
const hasLocalBaseRef =
|
||||
hasRemoteTrackingBaseRef || (await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch))
|
||||
if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) {
|
||||
remoteTrackingBase = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1633,7 +1690,7 @@ export async function createRemoteWorktree(
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (!(await hasRemoteCommitObject(provider, repo.path, baseBranch))) {
|
||||
} else if (!(await hasRemoteWorktreeBaseRef(provider, repo.path, baseBranch))) {
|
||||
// Why: local or otherwise non-remote-tracking bases preserve legacy
|
||||
// best-effort fetch behavior. Verified PR/MR SHA bases already have the
|
||||
// commit object locally, so a broad remote fetch only updates unrelated refs.
|
||||
@@ -1914,15 +1971,40 @@ export async function createLocalWorktree(
|
||||
? sanitizeWorktreeDisplayName(args.displayName)
|
||||
: undefined
|
||||
|
||||
// Why: resolve the base before branch/path selection so remote-tracking bases
|
||||
// can be refreshed before `git worktree add`. Creating first and repairing
|
||||
// later races setup scripts, agents, and user edits.
|
||||
const baseBranch =
|
||||
args.baseBranch ||
|
||||
repo.worktreeBaseRef ||
|
||||
(await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)))
|
||||
const baseBranch = await resolveWorktreeCreateBase({
|
||||
requestedBaseBranch: args.baseBranch,
|
||||
repoWorktreeBaseRef: repo.worktreeBaseRef,
|
||||
resolveDefaultBaseRef: () =>
|
||||
resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)),
|
||||
isBaseUsable: async (baseBranchCandidate) => {
|
||||
if (runtime) {
|
||||
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
|
||||
repo.path,
|
||||
baseBranchCandidate,
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
if (remoteTrackingBase) {
|
||||
if (
|
||||
await runtime.hasRemoteTrackingRef(
|
||||
repo.path,
|
||||
remoteTrackingBase,
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return hasLocalWorktreeBaseRefWithOptions(
|
||||
repo.path,
|
||||
baseBranchCandidate,
|
||||
localGitExecOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
return hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranchCandidate, localGitExecOptions)
|
||||
}
|
||||
})
|
||||
if (!baseBranch) {
|
||||
// Why: getDefaultBaseRef may return null when none of origin/HEAD,
|
||||
// Why: resolveDefaultBaseRefViaExec may return null when none of origin/HEAD,
|
||||
// origin/main, origin/master, local main, or local master exist. Don't
|
||||
// fall back to a hardcoded 'origin/main' — passing a non-existent ref to
|
||||
// `git worktree add` produces an opaque error. Fail here with a clear
|
||||
@@ -1947,23 +2029,30 @@ export async function createLocalWorktree(
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
if (remoteTrackingBase) {
|
||||
const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(
|
||||
const hasRemoteTrackingBaseRef = await runtime.hasRemoteTrackingRef(
|
||||
repo.path,
|
||||
remoteTrackingBase,
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
|
||||
remoteTrackingRefresh = {
|
||||
base: remoteTrackingBase,
|
||||
hadLocalBaseRef: hasLocalBaseRef,
|
||||
promise: runtime.getOrStartRemoteTrackingBaseRefresh(
|
||||
repo.path,
|
||||
remoteTrackingBase,
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
const hasLocalBaseRef =
|
||||
hasRemoteTrackingBaseRef ||
|
||||
(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localGitExecOptions))
|
||||
if (!hasRemoteTrackingBaseRef && hasLocalBaseRef) {
|
||||
remoteTrackingBase = null
|
||||
} else {
|
||||
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
|
||||
remoteTrackingRefresh = {
|
||||
base: remoteTrackingBase,
|
||||
hadLocalBaseRef: hasRemoteTrackingBaseRef,
|
||||
promise: runtime.getOrStartRemoteTrackingBaseRefresh(
|
||||
repo.path,
|
||||
remoteTrackingBase,
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
!(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
|
||||
!(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
|
||||
) {
|
||||
// Why: when the base branch does not match a configured remote prefix
|
||||
// (e.g. plain `main`, `master`, or any local branch), the legacy path
|
||||
@@ -1976,7 +2065,9 @@ export async function createLocalWorktree(
|
||||
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
|
||||
}
|
||||
} else {
|
||||
if (!(await hasLocalCommitObjectWithOptions(repo.path, baseBranch, localWorktreeGitOptions))) {
|
||||
if (
|
||||
!(await hasLocalWorktreeBaseRefWithOptions(repo.path, baseBranch, localWorktreeGitOptions))
|
||||
) {
|
||||
legacyFetchPromise = gitExecFileAsync(['fetch', 'origin'], {
|
||||
...localGitExecOptions,
|
||||
timeout: CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS
|
||||
|
||||
+538
-45
@@ -493,16 +493,32 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
|
||||
it('prefetches the local default create base through the runtime refresh cache', async () => {
|
||||
const repo = {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
worktreeBaseRef: 'origin/master'
|
||||
}
|
||||
const remoteBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
|
||||
baseBranch === 'origin/main' ? remoteBase : null
|
||||
)
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||||
|
||||
await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'origin/master'
|
||||
)
|
||||
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'origin/main'
|
||||
@@ -3668,7 +3684,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not create an SSH worktree when the refresh fails and no local base ref exists', async () => {
|
||||
it('keeps an explicit SSH base strict when refresh fails and no local base ref exists', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
@@ -3679,7 +3695,6 @@ describe('registerWorktreeHandlers', () => {
|
||||
worktreeBaseRef: 'origin/main'
|
||||
}
|
||||
const provider = {
|
||||
// Empty rev-parse stdout -> no local remote-tracking base ref to fall back on.
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
@@ -3705,24 +3720,26 @@ describe('registerWorktreeHandlers', () => {
|
||||
await expect(
|
||||
handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'improve-dashboard'
|
||||
name: 'improve-dashboard',
|
||||
baseBranch: 'origin/master'
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
|
||||
'Could not refresh base ref "origin/master" from "origin". Check your network and try again.'
|
||||
)
|
||||
|
||||
expect(provider.addWorktree).not.toHaveBeenCalled()
|
||||
expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled()
|
||||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'main',
|
||||
'refs/remotes/origin/main'
|
||||
'master',
|
||||
'refs/remotes/origin/master'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates an SSH worktree from an existing local base ref when the refresh fails', async () => {
|
||||
// Regression: a failed SSH refresh must fall back to an existing local
|
||||
// remote-tracking base ref instead of blocking creation.
|
||||
it('creates an SSH worktree from the detected default base when the persisted base is stale', async () => {
|
||||
// Regression: a stale persisted repo base must fall back to the detected
|
||||
// primary default instead of blocking creation.
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
@@ -3730,20 +3747,25 @@ describe('registerWorktreeHandlers', () => {
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: 'origin/main'
|
||||
worktreeBaseRef: 'origin/master'
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
// Local remote-tracking base ref resolves -> usable fallback exists.
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
|
||||
return { stdout: `${'a'.repeat(40)}\n`, stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockRejectedValue(new Error('network unavailable')),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi
|
||||
.fn()
|
||||
@@ -3773,15 +3795,175 @@ describe('registerWorktreeHandlers', () => {
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
|
||||
// The refresh must be ATTEMPTED (and fail) before falling back — guards
|
||||
// against a regression that skips the refresh whenever a local ref exists.
|
||||
expect(resolveDefaultBaseRefViaExecMock).toHaveBeenCalled()
|
||||
expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'main',
|
||||
'refs/remotes/origin/main'
|
||||
)
|
||||
expect(provider.addWorktree).toHaveBeenCalled()
|
||||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'improve-dashboard',
|
||||
'/remote/improve-dashboard',
|
||||
{
|
||||
base: 'origin/main'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a usable SSH persisted local branch base after registering the repo root', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: 'develop'
|
||||
}
|
||||
let repoRootRegistered = false
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'config') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) {
|
||||
return { stdout: repoRootRegistered ? 'develop-sha\n' : '', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/local-branch-base',
|
||||
head: 'develop-sha',
|
||||
branch: 'refs/heads/local-branch-base',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}
|
||||
const mux = {
|
||||
request: vi
|
||||
.fn()
|
||||
.mockImplementation(async (_method: string, payload: { rootPath: string }) => {
|
||||
if (payload.rootPath === repo.path) {
|
||||
repoRootRegistered = true
|
||||
}
|
||||
}),
|
||||
notify: vi.fn()
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'local-branch-base'
|
||||
})
|
||||
|
||||
expect(mux.request).toHaveBeenCalledWith('session.registerRoot', { rootPath: repo.path })
|
||||
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalled()
|
||||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'local-branch-base',
|
||||
'/remote/local-branch-base',
|
||||
{
|
||||
base: 'develop'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a usable SSH slash-named local branch base that matches a remote prefix', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: 'team/feature'
|
||||
}
|
||||
let repoRootRegistered = false
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'config') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'team\norigin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
|
||||
return { stdout: repoRootRegistered ? 'team-feature-sha\n' : '', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/slash-local-base',
|
||||
head: 'team-feature-sha',
|
||||
branch: 'refs/heads/slash-local-base',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}
|
||||
const mux = {
|
||||
request: vi
|
||||
.fn()
|
||||
.mockImplementation(async (_method: string, payload: { rootPath: string }) => {
|
||||
if (payload.rootPath === repo.path) {
|
||||
repoRootRegistered = true
|
||||
}
|
||||
}),
|
||||
notify: vi.fn()
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'slash-local-base'
|
||||
})
|
||||
|
||||
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'team',
|
||||
'feature',
|
||||
'refs/remotes/team/feature'
|
||||
)
|
||||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'slash-local-base',
|
||||
'/remote/slash-local-base',
|
||||
{
|
||||
base: 'team/feature'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => {
|
||||
@@ -3986,6 +4168,160 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(provider.addWorktree).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('registers the SSH repo root before create-base prefetch probes refs', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: 'origin/master'
|
||||
}
|
||||
const registeredRoots = new Set<string>()
|
||||
const events: string[] = []
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
events.push(`exec:${args[0]}:${registeredRoots.has('/remote/repo')}`)
|
||||
if (!registeredRoots.has('/remote/repo')) {
|
||||
throw new Error('root not registered')
|
||||
}
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) {
|
||||
throw new Error('missing stale base')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/prefetched-worktree',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/prefetched-worktree',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}
|
||||
const mux = {
|
||||
request: vi
|
||||
.fn()
|
||||
.mockImplementation(async (_method: string, payload: { rootPath: string }) => {
|
||||
events.push(`register:${payload.rootPath}`)
|
||||
registeredRoots.add(payload.rootPath)
|
||||
}),
|
||||
notify: vi.fn()
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
await handlers['worktrees:prefetchCreateBase'](null, {
|
||||
repoId: 'repo-ssh'
|
||||
})
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'prefetched-worktree'
|
||||
})
|
||||
|
||||
expect(events[0]).toBe('register:/remote/repo')
|
||||
expect(events).not.toContain('exec:symbolic-ref:false')
|
||||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'prefetched-worktree',
|
||||
'/remote/prefetched-worktree',
|
||||
{
|
||||
base: 'origin/main'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not let SSH prefetch turn a persisted slash-named local branch into a remote-tracking base', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: 'team/feature'
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'team\norigin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main')) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
|
||||
throw new Error('missing remote-tracking ref')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
|
||||
return { stdout: 'team-feature-sha\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/slash-local-base',
|
||||
head: 'team-feature-sha',
|
||||
branch: 'refs/heads/slash-local-base',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}
|
||||
const mux = {
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
notify: vi.fn()
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
await handlers['worktrees:prefetchCreateBase'](null, {
|
||||
repoId: 'repo-ssh'
|
||||
})
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'slash-local-base'
|
||||
})
|
||||
|
||||
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'team',
|
||||
'feature',
|
||||
'refs/remotes/team/feature'
|
||||
)
|
||||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'slash-local-base',
|
||||
'/remote/slash-local-base',
|
||||
{
|
||||
base: 'team/feature'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('shares in-flight SSH create-base resolution with create', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
@@ -4241,7 +4577,16 @@ describe('registerWorktreeHandlers', () => {
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (
|
||||
args[0] === 'rev-parse' &&
|
||||
(args.includes('refs/remotes/origin/master^{commit}') ||
|
||||
args.includes('refs/heads/origin/master^{commit}'))
|
||||
) {
|
||||
throw new Error('missing ref')
|
||||
}
|
||||
return { stdout: 'created-sha\n', stderr: '' }
|
||||
})
|
||||
|
||||
const createPromise = handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
@@ -4270,17 +4615,82 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('creates from an existing local base ref when the pre-create refresh fails', async () => {
|
||||
// Regression: a failed refresh must not block creation when a usable local
|
||||
// remote-tracking base ref already exists.
|
||||
it('creates from the detected default base when the persisted base is stale', async () => {
|
||||
// Regression: a stale persisted repo base must fall back to the detected
|
||||
// primary default instead of blocking creation.
|
||||
const remoteBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
worktreeBaseRef: 'origin/master'
|
||||
})
|
||||
runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
|
||||
baseBranch === 'origin/main' ? remoteBase : null
|
||||
)
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||||
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
|
||||
ok: true,
|
||||
errorKind: 'git_error'
|
||||
})
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'created-sha',
|
||||
branch: 'improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (
|
||||
args[0] === 'rev-parse' &&
|
||||
(args.includes('refs/remotes/origin/master^{commit}') ||
|
||||
args.includes('refs/heads/origin/master^{commit}'))
|
||||
) {
|
||||
throw new Error('missing ref')
|
||||
}
|
||||
return { stdout: 'created-sha\n', stderr: '' }
|
||||
})
|
||||
|
||||
const result = (await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})) as CreateWorktreeResult
|
||||
|
||||
expect(addWorktreeMock).toHaveBeenCalled()
|
||||
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'origin/master'
|
||||
)
|
||||
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'origin/main'
|
||||
)
|
||||
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
remoteBase
|
||||
)
|
||||
expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard')
|
||||
})
|
||||
|
||||
it('keeps a usable persisted local branch base when a detected default exists', async () => {
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
worktreeBaseRef: 'develop'
|
||||
})
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(null)
|
||||
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
|
||||
ok: false,
|
||||
errorKind: 'git_error'
|
||||
@@ -4294,29 +4704,14 @@ describe('registerWorktreeHandlers', () => {
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
|
||||
|
||||
const result = (await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})) as CreateWorktreeResult
|
||||
|
||||
expect(addWorktreeMock).toHaveBeenCalled()
|
||||
expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard')
|
||||
})
|
||||
|
||||
it('does not create when the pre-create refresh fails and no local base ref exists', async () => {
|
||||
const remoteBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
|
||||
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
|
||||
ok: false,
|
||||
errorKind: 'git_error'
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) {
|
||||
return { stdout: 'develop-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
throw new Error('network unavailable')
|
||||
}
|
||||
return { stdout: 'created-sha\n', stderr: '' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
@@ -4324,11 +4719,109 @@ describe('registerWorktreeHandlers', () => {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) }))
|
||||
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/improve-dashboard',
|
||||
'improve-dashboard',
|
||||
'develop',
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a usable persisted slash-named local branch base that matches a remote prefix', async () => {
|
||||
const remoteBase = {
|
||||
remote: 'team',
|
||||
branch: 'feature',
|
||||
ref: 'refs/remotes/team/feature',
|
||||
base: 'team/feature'
|
||||
}
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
worktreeBaseRef: 'team/feature'
|
||||
})
|
||||
runtimeStub.resolveRemoteTrackingBase.mockImplementation(async (_repoPath, baseBranch) =>
|
||||
baseBranch === 'team/feature' ? remoteBase : null
|
||||
)
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/slash-local-base',
|
||||
head: 'created-sha',
|
||||
branch: 'slash-local-base',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
|
||||
throw new Error('missing remote-tracking ref')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
|
||||
return { stdout: 'team-feature-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
throw new Error('network unavailable')
|
||||
}
|
||||
return { stdout: 'created-sha\n', stderr: '' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'slash-local-base'
|
||||
})
|
||||
).resolves.toEqual(expect.objectContaining({ worktree: expect.any(Object) }))
|
||||
|
||||
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).not.toHaveBeenCalled()
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/slash-local-base',
|
||||
'slash-local-base',
|
||||
'team/feature',
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an explicit base strict when the pre-create refresh fails', async () => {
|
||||
const remoteBase = {
|
||||
remote: 'origin',
|
||||
branch: 'master',
|
||||
ref: 'refs/remotes/origin/master',
|
||||
base: 'origin/master'
|
||||
}
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false)
|
||||
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
|
||||
ok: false,
|
||||
errorKind: 'git_error'
|
||||
})
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
worktreeBaseRef: 'origin/main'
|
||||
})
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
baseBranch: 'origin/master'
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
|
||||
'Could not refresh base ref "origin/master" from "origin". Check your network and try again.'
|
||||
)
|
||||
|
||||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(resolveDefaultBaseRefViaExecMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates remote-tracking base freshness to the runtime before create', async () => {
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('registerAppMenu', () => {
|
||||
expect(options.onBeforeReload).toHaveBeenCalledWith({ ignoreCache: true, webContentsId: 102 })
|
||||
})
|
||||
|
||||
it('includes prereleases when Check for Updates is clicked with shift held', () => {
|
||||
it('routes Check for Updates modifier clicks to prerelease and perf checks', () => {
|
||||
const options = buildMenuOptions()
|
||||
registerAppMenu(options)
|
||||
|
||||
@@ -152,19 +152,42 @@ describe('registerAppMenu', () => {
|
||||
)
|
||||
|
||||
item?.click?.({} as never, undefined as never, { shiftKey: true } as Electron.KeyboardEvent)
|
||||
item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
|
||||
item?.click?.(
|
||||
{} as never,
|
||||
undefined as never,
|
||||
{ metaKey: true, shiftKey: true } as Electron.KeyboardEvent
|
||||
{
|
||||
shiftKey: true,
|
||||
...(isMac ? { metaKey: true } : { ctrlKey: true })
|
||||
} as Electron.KeyboardEvent
|
||||
)
|
||||
item?.click?.(
|
||||
{} as never,
|
||||
undefined as never,
|
||||
(isMac ? { metaKey: true } : { ctrlKey: true }) as Electron.KeyboardEvent
|
||||
)
|
||||
item?.click?.(
|
||||
{} as never,
|
||||
undefined as never,
|
||||
(isMac ? { ctrlKey: true } : { metaKey: true }) as Electron.KeyboardEvent
|
||||
)
|
||||
item?.click?.(
|
||||
{} as never,
|
||||
undefined as never,
|
||||
{
|
||||
triggeredByAccelerator: true,
|
||||
shiftKey: true,
|
||||
...(isMac ? { metaKey: true } : { ctrlKey: true })
|
||||
} as Electron.KeyboardEvent
|
||||
)
|
||||
item?.click?.({} as never, undefined as never, {} as Electron.KeyboardEvent)
|
||||
item?.click?.({} as never, undefined as never, { metaKey: true } as Electron.KeyboardEvent)
|
||||
|
||||
expect(options.onCheckForUpdates.mock.calls).toEqual([
|
||||
[{ includePrerelease: true }],
|
||||
[{ includePrerelease: true }],
|
||||
[{ includePrerelease: false }],
|
||||
[{ includePrerelease: false }]
|
||||
[{ includePrerelease: true, includePerfPrerelease: false }],
|
||||
[{ includePrerelease: false, includePerfPrerelease: false }],
|
||||
[{ includePrerelease: true, includePerfPrerelease: true }],
|
||||
[{ includePrerelease: false, includePerfPrerelease: true }],
|
||||
[{ includePrerelease: false, includePerfPrerelease: false }],
|
||||
[{ includePrerelease: false, includePerfPrerelease: false }]
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type KeybindingActionId,
|
||||
type KeybindingOverrides
|
||||
} from '../../shared/keybindings'
|
||||
import type { UpdateCheckOptions } from '../../shared/types'
|
||||
import { translateMain } from '../i18n/main-i18n'
|
||||
|
||||
export type AppearanceMenuState = {
|
||||
@@ -26,7 +27,7 @@ type RegisterAppMenuOptions = {
|
||||
onOpenSetupGuide: (window?: Electron.BaseWindow | null) => void
|
||||
onOpenFeatureTour: (window?: Electron.BaseWindow | null) => void
|
||||
onOpenCrashReport: (window?: Electron.BaseWindow | null) => void
|
||||
onCheckForUpdates: (options: { includePrerelease: boolean }) => void
|
||||
onCheckForUpdates: (options: UpdateCheckOptions) => void
|
||||
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
@@ -83,16 +84,19 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void {
|
||||
webContents.reload()
|
||||
}
|
||||
|
||||
// Why: holding Shift while clicking Check for Updates opts this check into
|
||||
// the release-candidate channel. Extracted so both the macOS app-menu entry
|
||||
// and the Windows/Linux Help-menu entry share the exact same behavior.
|
||||
// Why: modifier-click update checks are hidden power-user affordances.
|
||||
// Extracted so the macOS app-menu entry and Windows/Linux Help entry share
|
||||
// identical RC/perf channel routing.
|
||||
const checkForUpdatesClick: Electron.MenuItemConstructorOptions['click'] = (
|
||||
_menuItem,
|
||||
_window,
|
||||
event
|
||||
) => {
|
||||
const includePrerelease = !event.triggeredByAccelerator && event.shiftKey === true
|
||||
onCheckForUpdates({ includePrerelease })
|
||||
const modifierClick = !event.triggeredByAccelerator
|
||||
const includePerfPrerelease =
|
||||
modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true)
|
||||
const includePrerelease = modifierClick && event.shiftKey === true
|
||||
onCheckForUpdates({ includePrerelease, includePerfPrerelease })
|
||||
}
|
||||
|
||||
const checkForUpdatesItem: Electron.MenuItemConstructorOptions = {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as MiniMaxCookieStore from './minimax-cookie-store'
|
||||
|
||||
const safeStorageMock = vi.hoisted(() => ({
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
encryptString: vi.fn((value: string) => Buffer.from(value)),
|
||||
decryptString: vi.fn((value: Buffer) => value.toString('utf8'))
|
||||
}))
|
||||
|
||||
const electronMock = vi.hoisted(() => ({
|
||||
safeStorage: safeStorageMock
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => electronMock)
|
||||
|
||||
const existsSyncMock = vi.fn()
|
||||
const readFileSyncMock = vi.fn()
|
||||
const rmSyncMock = vi.fn()
|
||||
const hardenExistingSecureFileMock = vi.fn()
|
||||
const writeSecureFileMock = vi.fn()
|
||||
const homedirMock = vi.fn(() => '/home/test')
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
rmSync: rmSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: homedirMock
|
||||
}))
|
||||
|
||||
vi.mock('node:path', () => ({
|
||||
join: (...parts: string[]) => parts.join('/')
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/secure-file', () => ({
|
||||
hardenExistingSecureFile: hardenExistingSecureFileMock,
|
||||
writeSecureFile: writeSecureFileMock
|
||||
}))
|
||||
|
||||
const storePath = '/home/test/.orca/minimax-session-cookie.enc'
|
||||
const envelope = (kind: 'encrypted' | 'plaintext', value: string): string =>
|
||||
`orca-minimax-cookie:v1:${kind}:${Buffer.from(value, 'utf8').toString('base64')}`
|
||||
|
||||
async function loadStore(): Promise<typeof MiniMaxCookieStore> {
|
||||
return await import('./minimax-cookie-store')
|
||||
}
|
||||
|
||||
describe('minimax-cookie-store', () => {
|
||||
beforeEach(() => {
|
||||
existsSyncMock.mockReset()
|
||||
readFileSyncMock.mockReset()
|
||||
rmSyncMock.mockReset()
|
||||
hardenExistingSecureFileMock.mockReset()
|
||||
writeSecureFileMock.mockReset()
|
||||
safeStorageMock.isEncryptionAvailable.mockReset()
|
||||
safeStorageMock.encryptString.mockReset()
|
||||
safeStorageMock.decryptString.mockReset()
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
|
||||
safeStorageMock.encryptString.mockImplementation((value: string) => Buffer.from(value))
|
||||
safeStorageMock.decryptString.mockImplementation((value: Buffer) => value.toString('utf8'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('returns false when no file exists yet', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
expect(store.hasMiniMaxSessionCookie()).toBe(false)
|
||||
expect(hardenExistingSecureFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hardens the cookie file when checking status for an existing cookie', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
const store = await loadStore()
|
||||
expect(store.hasMiniMaxSessionCookie()).toBe(true)
|
||||
expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
|
||||
})
|
||||
|
||||
it('still reports an existing cookie when status-path hardening fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
hardenExistingSecureFileMock.mockImplementation(() => {
|
||||
throw new Error('permission denied')
|
||||
})
|
||||
const store = await loadStore()
|
||||
expect(store.hasMiniMaxSessionCookie()).toBe(true)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to harden MiniMax cookie file'),
|
||||
expect.any(Error)
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('writes the cookie using safeStorage when encryption is available', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
store.saveMiniMaxSessionCookie('_token=abc; minimax_group_id_v2=42')
|
||||
expect(safeStorageMock.encryptString).toHaveBeenCalledWith('_token=abc; minimax_group_id_v2=42')
|
||||
expect(writeSecureFileMock).toHaveBeenCalledWith(
|
||||
storePath,
|
||||
envelope('encrypted', '_token=abc; minimax_group_id_v2=42')
|
||||
)
|
||||
})
|
||||
|
||||
it('warns and writes plaintext when safeStorage is unavailable', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
store.saveMiniMaxSessionCookie('_token=abc')
|
||||
expect(writeSecureFileMock).toHaveBeenCalledWith(storePath, envelope('plaintext', '_token=abc'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('safeStorage encryption unavailable'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('refuses empty cookies', async () => {
|
||||
const store = await loadStore()
|
||||
expect(() => store.saveMiniMaxSessionCookie(' ')).toThrow(/required/)
|
||||
})
|
||||
|
||||
it('reads decrypted cookie from disk and caches it', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
safeStorageMock.decryptString.mockReturnValue('_token=cached; minimax_group_id_v2=9')
|
||||
const store = await loadStore()
|
||||
const first = store.readMiniMaxSessionCookie()
|
||||
const second = store.readMiniMaxSessionCookie()
|
||||
expect(first).toBe('_token=cached; minimax_group_id_v2=9')
|
||||
expect(second).toBe(first)
|
||||
expect(hardenExistingSecureFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(hardenExistingSecureFileMock).toHaveBeenCalledWith(storePath)
|
||||
expect(safeStorageMock.decryptString).toHaveBeenCalledTimes(1)
|
||||
expect(safeStorageMock.decryptString).toHaveBeenCalledWith(Buffer.from('encrypted-payload'))
|
||||
})
|
||||
|
||||
it('returns null when no file exists', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns enveloped plaintext when safeStorage is unavailable and reads succeed', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', '_token=plaintext')))
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=plaintext')
|
||||
})
|
||||
|
||||
it('reads legacy plaintext cookies when decrypting is unavailable', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy')
|
||||
})
|
||||
|
||||
it('reads legacy plaintext cookies when decrypting fails', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('_token=legacy'))
|
||||
safeStorageMock.decryptString.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=legacy')
|
||||
})
|
||||
|
||||
it('does not treat encrypted legacy bytes as plaintext when safeStorage is unavailable', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from('encrypted-payload'))
|
||||
const store = await loadStore()
|
||||
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
|
||||
})
|
||||
|
||||
it('throws for encrypted envelopes when safeStorage is unavailable', async () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
const store = await loadStore()
|
||||
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
|
||||
})
|
||||
|
||||
it('throws when decryption fails', async () => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
safeStorageMock.decryptString.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const store = await loadStore()
|
||||
expect(() => store.readMiniMaxSessionCookie()).toThrow(/could not be decrypted/)
|
||||
})
|
||||
|
||||
it('clears the cached cookie and removes the file', async () => {
|
||||
existsSyncMock.mockReturnValueOnce(true)
|
||||
readFileSyncMock.mockReturnValueOnce(Buffer.from(envelope('encrypted', 'encrypted-payload')))
|
||||
safeStorageMock.decryptString.mockReturnValueOnce('_token=preclear')
|
||||
const store = await loadStore()
|
||||
expect(store.readMiniMaxSessionCookie()).toBe('_token=preclear')
|
||||
store.clearMiniMaxSessionCookie()
|
||||
expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true })
|
||||
expect(store.readMiniMaxSessionCookie()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { safeStorage } from 'electron'
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file'
|
||||
|
||||
const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc'
|
||||
const COOKIE_ENVELOPE_PREFIX = 'orca-minimax-cookie:v1:'
|
||||
let cachedMiniMaxCookie: string | null = null
|
||||
let warnedMiniMaxCookieStatusHardenFailure = false
|
||||
|
||||
type MiniMaxCookieEnvelope = {
|
||||
kind: 'encrypted' | 'plaintext'
|
||||
payload: Buffer
|
||||
}
|
||||
|
||||
function getOrcaDir(): string {
|
||||
return join(homedir(), '.orca')
|
||||
}
|
||||
|
||||
function getMiniMaxCookiePath(): string {
|
||||
return join(getOrcaDir(), MINIMAX_COOKIE_FILE)
|
||||
}
|
||||
|
||||
function encodeCookieEnvelope(kind: MiniMaxCookieEnvelope['kind'], payload: Buffer): string {
|
||||
return `${COOKIE_ENVELOPE_PREFIX}${kind}:${payload.toString('base64')}`
|
||||
}
|
||||
|
||||
function decodeCookieEnvelope(raw: Buffer): MiniMaxCookieEnvelope | null {
|
||||
const text = raw.toString('utf8')
|
||||
if (!text.startsWith(COOKIE_ENVELOPE_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
const rest = text.slice(COOKIE_ENVELOPE_PREFIX.length)
|
||||
const separator = rest.indexOf(':')
|
||||
if (separator < 0) {
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
const kind = rest.slice(0, separator)
|
||||
if (kind !== 'encrypted' && kind !== 'plaintext') {
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
payload: Buffer.from(rest.slice(separator + 1), 'base64')
|
||||
}
|
||||
}
|
||||
|
||||
// Why: migrates cookies saved before the envelope format existed. Older files
|
||||
// hold raw bytes (safeStorage-encrypted or plaintext), so we sniff the content
|
||||
// to tell the two apart rather than removing this as seemingly dead code.
|
||||
function looksLikeCookieHeader(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < trimmed.length; index += 1) {
|
||||
const code = trimmed.charCodeAt(index)
|
||||
if (code < 32 || code === 127) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return (
|
||||
/^Cookie:\s*\S+/i.test(trimmed) ||
|
||||
/(?:^|;\s*)[A-Za-z0-9_.-]+\s*=/.test(trimmed) ||
|
||||
/(?:^|[;\s])[A-Za-z0-9_.-]+\s*:\s*["'][^"']+["']/.test(trimmed)
|
||||
)
|
||||
}
|
||||
|
||||
function readEnvelope(envelope: MiniMaxCookieEnvelope): string {
|
||||
if (envelope.kind === 'plaintext') {
|
||||
return envelope.payload.toString('utf8')
|
||||
}
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
return safeStorage.decryptString(envelope.payload)
|
||||
}
|
||||
|
||||
function readLegacyCookie(raw: Buffer): string {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
return safeStorage.decryptString(raw)
|
||||
} catch {
|
||||
const plaintext = raw.toString('utf8')
|
||||
if (looksLikeCookieHeader(plaintext)) {
|
||||
return plaintext
|
||||
}
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
}
|
||||
const plaintext = raw.toString('utf8')
|
||||
if (looksLikeCookieHeader(plaintext)) {
|
||||
return plaintext
|
||||
}
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
|
||||
export function hasMiniMaxSessionCookie(): boolean {
|
||||
const keyPath = getMiniMaxCookiePath()
|
||||
if (!existsSync(keyPath)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
hardenExistingSecureFile(keyPath)
|
||||
} catch (error) {
|
||||
if (!warnedMiniMaxCookieStatusHardenFailure) {
|
||||
warnedMiniMaxCookieStatusHardenFailure = true
|
||||
console.warn('[minimax] Failed to harden MiniMax cookie file while checking status', error)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function saveMiniMaxSessionCookie(cookie: string): void {
|
||||
const trimmed = cookie.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error('MiniMax session cookie is required')
|
||||
}
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
writeSecureFile(
|
||||
getMiniMaxCookiePath(),
|
||||
encodeCookieEnvelope('encrypted', safeStorage.encryptString(trimmed))
|
||||
)
|
||||
cachedMiniMaxCookie = trimmed
|
||||
return
|
||||
}
|
||||
console.warn('[minimax] safeStorage encryption unavailable — storing MiniMax cookie in plaintext')
|
||||
writeSecureFile(
|
||||
getMiniMaxCookiePath(),
|
||||
encodeCookieEnvelope('plaintext', Buffer.from(trimmed, 'utf8'))
|
||||
)
|
||||
cachedMiniMaxCookie = trimmed
|
||||
}
|
||||
|
||||
export function readMiniMaxSessionCookie(): string | null {
|
||||
if (cachedMiniMaxCookie !== null) {
|
||||
return cachedMiniMaxCookie
|
||||
}
|
||||
const keyPath = getMiniMaxCookiePath()
|
||||
if (!existsSync(keyPath)) {
|
||||
return null
|
||||
}
|
||||
// Why: keep hardening out of the decode/decrypt try below so a chmod/ACL
|
||||
// failure isn't misreported as a decrypt failure (matches hasMiniMaxSessionCookie).
|
||||
try {
|
||||
hardenExistingSecureFile(keyPath)
|
||||
} catch (error) {
|
||||
console.warn('[minimax] Failed to harden MiniMax cookie file while reading', error)
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(keyPath)
|
||||
const envelope = decodeCookieEnvelope(raw)
|
||||
cachedMiniMaxCookie = envelope ? readEnvelope(envelope) : readLegacyCookie(raw)
|
||||
return cachedMiniMaxCookie
|
||||
} catch (error) {
|
||||
console.error('[minimax] failed to decode/decrypt session cookie', error)
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMiniMaxSessionCookie(): void {
|
||||
cachedMiniMaxCookie = null
|
||||
rmSync(getMiniMaxCookiePath(), { force: true })
|
||||
}
|
||||
@@ -10,6 +10,7 @@ vi.mock('child_process', () => ({
|
||||
|
||||
import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot'
|
||||
import { resolveAgentForegroundProcess } from './agent-foreground-process'
|
||||
import { resetWindowsProcessRowsSnapshotForTests } from './windows-foreground-process-rows'
|
||||
|
||||
// Why: the module wraps execFile with promisify, so the mock must honor the
|
||||
// Node callback contract — invoke the last arg with (err, { stdout, stderr }).
|
||||
@@ -73,6 +74,9 @@ describe('resolveAgentForegroundProcess', () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
resetProcessTableSnapshotForTests()
|
||||
// Why: the Windows rows reader caches across calls (500ms TTL), so each
|
||||
// case's execFile mock must not be answered by the previous case's rows.
|
||||
resetWindowsProcessRowsSnapshotForTests()
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
})
|
||||
|
||||
@@ -82,6 +82,7 @@ vi.mock('../wsl', () => ({
|
||||
}))
|
||||
|
||||
import { LocalPtyProvider } from './local-pty-provider'
|
||||
import { isRootLikePath } from './pty-path-safety'
|
||||
import { POWERLEVEL10K_WIZARD_DISABLE_ENV } from '../pty/powerlevel10k-wizard-env'
|
||||
|
||||
describe('LocalPtyProvider', () => {
|
||||
@@ -218,6 +219,52 @@ describe('LocalPtyProvider', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('allows an explicitly requested plain shell at POSIX root', async () => {
|
||||
await provider.spawn({ cols: 80, rows: 24, cwd: '/' })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ cwd: '/' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the safe default cwd for automatic agent startup without an explicit cwd', async () => {
|
||||
spawnMock.mockClear()
|
||||
const origHome = process.env.HOME
|
||||
// Pin HOME so we assert the exact resolved candidate, not just non-root-ness —
|
||||
// catches regressions where resolveSafePtyDefaultCwd picks an unintended home.
|
||||
process.env.HOME = '/home/testuser'
|
||||
|
||||
try {
|
||||
// Why: an omitted cwd resolves to a guaranteed-safe default home (the
|
||||
// guard only rejects root-like paths), so the agent must still launch.
|
||||
await expect(
|
||||
provider.spawn({ cols: 80, rows: 24, command: 'codex' })
|
||||
).resolves.toBeDefined()
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
expect(spawnCall[2].cwd).toBe('/home/testuser')
|
||||
expect(isRootLikePath(spawnCall[2].cwd)).toBe(false)
|
||||
} finally {
|
||||
if (origHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = origHome
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects automatic agent startup at POSIX root', async () => {
|
||||
spawnMock.mockClear()
|
||||
|
||||
await expect(
|
||||
provider.spawn({ cols: 80, rows: 24, cwd: '/', command: 'claude' })
|
||||
).rejects.toThrow(/requires a non-root workspace/)
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes onSpawned callback', async () => {
|
||||
const onSpawned = vi.fn()
|
||||
provider.configure({ onSpawned })
|
||||
|
||||
@@ -55,6 +55,7 @@ import { resolveAgentForegroundProcess } from './agent-foreground-process'
|
||||
import { getAgentForegroundContextPaths } from './agent-foreground-context-paths'
|
||||
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
|
||||
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
@@ -88,20 +89,7 @@ const exitListeners = new Set<ExitCallback>()
|
||||
* Returns a stable default cwd for locally spawned PTYs.
|
||||
*/
|
||||
function getDefaultCwd(): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return process.env.HOME || '/'
|
||||
}
|
||||
|
||||
// Why: USERPROFILE is not guaranteed in all Windows launch contexts.
|
||||
// Falling back to bare HOMEPATH yields a drive-relative path, so combine
|
||||
// HOMEDRIVE + HOMEPATH to keep spawned PTYs anchored to the intended home.
|
||||
if (process.env.USERPROFILE) {
|
||||
return process.env.USERPROFILE
|
||||
}
|
||||
if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
|
||||
return `${process.env.HOMEDRIVE}${process.env.HOMEPATH}`
|
||||
}
|
||||
return 'C:\\'
|
||||
return resolveSafePtyDefaultCwd()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,8 +340,18 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
}
|
||||
const id = allocatePtyId(reattachId ?? undefined)
|
||||
|
||||
const startupAgentRecognition = args.command
|
||||
? recognizeAgentProcessFromCommandLine(args.command)
|
||||
: null
|
||||
|
||||
const defaultCwd = getDefaultCwd()
|
||||
const cwd = args.cwd || defaultCwd
|
||||
// Why: gate on the effective cwd (post default-cwd fallback), not the raw
|
||||
// args.cwd — an omitted cwd resolves to a safe default and must not be
|
||||
// rejected as if it were a root-like path.
|
||||
if (args.command && startupAgentRecognition) {
|
||||
assertSafeAgentStartupCwd(cwd, args.command)
|
||||
}
|
||||
const wslInfo = process.platform === 'win32' ? parseWslPath(cwd) : null
|
||||
const worktreeWslContext =
|
||||
process.platform === 'win32' ? getWslContextFromWorktreeId(args.worktreeId) : undefined
|
||||
@@ -594,8 +592,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
finalEnv.ORCA_OMP_STATUS_EXTENSION ||
|
||||
finalEnv.ORCA_CODEX_HOME ||
|
||||
finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR
|
||||
const isCodexStartupCommand =
|
||||
recognizeAgentProcessFromCommandLine(args.command)?.agent === 'codex'
|
||||
const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex'
|
||||
let shellLaunch: ReturnType<typeof getShellReadyLaunchConfig> | null = null
|
||||
if (args.command && isCodexStartupCommand) {
|
||||
const shouldWaitForShellReady = shouldUseShellReadyStartupDelivery({
|
||||
@@ -901,8 +898,19 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
async getInitialCwd(_id: string): Promise<string> {
|
||||
return ''
|
||||
}
|
||||
async clearBuffer(_id: string): Promise<void> {
|
||||
/* handled client-side in xterm.js */
|
||||
async clearBuffer(id: string): Promise<void> {
|
||||
// Why: xterm.js clear() only resets the renderer. ConPTY keeps its own
|
||||
// screen buffer, so without this its stale cursor row makes the next
|
||||
// prompt repaint land below a blank gap. No-op on POSIX.
|
||||
//
|
||||
// Unlike the daemon session, no PSReadLine form-feed nudge here: it is
|
||||
// only safe at an empty prompt, and without a headless emulator this
|
||||
// provider cannot tell whether input is pending.
|
||||
try {
|
||||
ptyProcesses.get(id)?.clear()
|
||||
} catch {
|
||||
/* PTY may have just exited */
|
||||
}
|
||||
}
|
||||
acknowledgeDataEvent(_id: string, _charCount: number): void {
|
||||
/* no flow control for local */
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: homedirMock
|
||||
}))
|
||||
|
||||
import {
|
||||
assertSafeAgentStartupCwd,
|
||||
isSafeImplicitPtyCwd,
|
||||
resolveSafePtyDefaultCwd
|
||||
} from './pty-default-cwd'
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: platform
|
||||
})
|
||||
}
|
||||
|
||||
describe('pty default cwd safety', () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
beforeEach(() => {
|
||||
setPlatform(originalPlatform)
|
||||
homedirMock.mockReset()
|
||||
homedirMock.mockReturnValue('/home/orca')
|
||||
})
|
||||
|
||||
it('rejects root-like implicit cwd values', () => {
|
||||
expect(isSafeImplicitPtyCwd(undefined)).toBe(false)
|
||||
expect(isSafeImplicitPtyCwd('')).toBe(false)
|
||||
expect(isSafeImplicitPtyCwd('/')).toBe(false)
|
||||
expect(isSafeImplicitPtyCwd('C:\\')).toBe(false)
|
||||
expect(isSafeImplicitPtyCwd('\\\\server\\share')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses a non-root POSIX home and skips root env values', () => {
|
||||
setPlatform('linux')
|
||||
|
||||
expect(resolveSafePtyDefaultCwd({ HOME: '/home/alice' })).toBe('/home/alice')
|
||||
expect(resolveSafePtyDefaultCwd({ HOME: '/' })).toBe('/home/orca')
|
||||
})
|
||||
|
||||
it('throws instead of falling back to POSIX root', () => {
|
||||
setPlatform('darwin')
|
||||
homedirMock.mockReturnValue('/')
|
||||
|
||||
expect(() => resolveSafePtyDefaultCwd({ HOME: '/' })).toThrow(
|
||||
/No safe default working directory/
|
||||
)
|
||||
})
|
||||
|
||||
it('uses Windows home candidates without accepting drive roots', () => {
|
||||
setPlatform('win32')
|
||||
homedirMock.mockReturnValue('C:\\Users\\os-home')
|
||||
|
||||
expect(
|
||||
resolveSafePtyDefaultCwd({
|
||||
USERPROFILE: 'C:\\',
|
||||
HOMEDRIVE: 'D:',
|
||||
HOMEPATH: '\\Users\\alice'
|
||||
})
|
||||
).toBe('D:\\Users\\alice')
|
||||
})
|
||||
|
||||
it('requires automatic agent startup commands to provide a non-root cwd', () => {
|
||||
expect(() => assertSafeAgentStartupCwd(undefined, 'codex')).toThrow(
|
||||
/requires a non-root workspace/
|
||||
)
|
||||
expect(() => assertSafeAgentStartupCwd('/', 'claude')).toThrow(/requires a non-root workspace/)
|
||||
expect(() => assertSafeAgentStartupCwd('/repo', 'codex')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
import { isRootLikePath } from './pty-path-safety'
|
||||
|
||||
function homeDrivePath(env: NodeJS.ProcessEnv): string | null {
|
||||
if (!env.HOMEDRIVE || !env.HOMEPATH) {
|
||||
return null
|
||||
}
|
||||
return `${env.HOMEDRIVE}${env.HOMEPATH}`
|
||||
}
|
||||
|
||||
export function isSafeImplicitPtyCwd(path: string | null | undefined): path is string {
|
||||
return !isRootLikePath(path)
|
||||
}
|
||||
|
||||
export function resolveSafePtyDefaultCwd(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const candidates =
|
||||
process.platform === 'win32'
|
||||
? [env.USERPROFILE, homeDrivePath(env), homedir()]
|
||||
: [env.HOME, homedir()]
|
||||
const selected = candidates.find(isSafeImplicitPtyCwd)
|
||||
// Why: silently falling back to "/" or a drive root is the exact runaway-CPU
|
||||
// bug this module prevents (see runaway-cpu-hidden-usage-pty-design.md) — fail loud.
|
||||
if (!selected) {
|
||||
// Log the rejected candidates so the rare "no safe home at all" environment
|
||||
// (e.g. HOME=/ or unset in a minimal container) is diagnosable, not opaque.
|
||||
console.error(
|
||||
'[pty-default-cwd] No safe default working directory for terminal launch; every home candidate resolved to a root-like path.',
|
||||
{ platform: process.platform, candidates }
|
||||
)
|
||||
throw new Error('No safe default working directory is available for terminal launch.')
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
export function assertSafeAgentStartupCwd(cwd: string | undefined, command: string): void {
|
||||
if (isSafeImplicitPtyCwd(cwd)) {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
`Automatic agent startup command "${command}" requires a non-root workspace working directory.`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { posix, win32 } from 'node:path'
|
||||
|
||||
// Why: both PTY spawn paths (default terminal cwd + hidden usage probes) gate a
|
||||
// security boundary here — an empty, "/", drive-root, or network-share-root cwd
|
||||
// triggers the unbounded file discovery this guard exists to prevent. Keep the
|
||||
// detection in one place so a fix to one enforcement path can't skip the other.
|
||||
export function isRootLikePath(path: string | null | undefined): boolean {
|
||||
const trimmed = path?.trim() ?? ''
|
||||
if (!trimmed) {
|
||||
return true
|
||||
}
|
||||
// A filesystem root is its own parent. Check both POSIX and Windows semantics
|
||||
// so a Windows drive/UNC root is still rejected when running on POSIX (and vice
|
||||
// versa) — path.dirname alone only understands the host platform's separators.
|
||||
const normalized = posix.normalize(trimmed)
|
||||
return posix.dirname(normalized) === normalized || win32.dirname(trimmed) === trimmed
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Regression guard: bound the volume of full-process-table PowerShell/CIM scans
|
||||
// driven by Windows agent foreground-process inspection — the Windows analogue of
|
||||
// issue #6288 (POSIX `ps`).
|
||||
//
|
||||
// Drives queryWindowsProcessDescendants across several concurrently-inspecting
|
||||
// agent panes on the agent-completion cadence (ACTIVE_POLL_INTERVAL_MS = 750ms)
|
||||
// and counts how many powershell.exe process-table scans actually spawn. Pre-fix
|
||||
// the call site forked one powershell.exe per pane per tick; with the shared
|
||||
// snapshot cache the scans collapse to ~one per tick regardless of pane count,
|
||||
// while each pane still resolves the same descendant set.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock, powershellScanCount } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
powershellScanCount: { value: 0 }
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({ execFile: execFileMock }))
|
||||
|
||||
import {
|
||||
queryWindowsProcessDescendants,
|
||||
resetWindowsProcessRowsSnapshotForTests
|
||||
} from './windows-foreground-process-rows'
|
||||
|
||||
const ACTIVE_POLL_INTERVAL_MS = 750
|
||||
const PANE_COUNT = 6
|
||||
const WINDOW_SECONDS = 30
|
||||
const TICKS = Math.floor((WINDOW_SECONDS * 1000) / ACTIVE_POLL_INTERVAL_MS)
|
||||
|
||||
const shellPid = (pane: number): number => 100 + pane * 1000
|
||||
|
||||
// A real CIM query returns the whole system, so one shared snapshot must contain
|
||||
// every pane's shell + foreground node/codex child. Each pane resolves its own
|
||||
// descendant from the single scan.
|
||||
const PROCESS_TABLE_JSON = JSON.stringify(
|
||||
Array.from({ length: PANE_COUNT }, (_, pane) => {
|
||||
const shell = shellPid(pane)
|
||||
return [
|
||||
{
|
||||
ProcessId: shell,
|
||||
ParentProcessId: 99,
|
||||
Name: 'cmd.exe',
|
||||
CommandLine: 'cmd.exe',
|
||||
ExecutablePath: 'C:/Windows/System32/cmd.exe'
|
||||
},
|
||||
{
|
||||
ProcessId: shell + 1,
|
||||
ParentProcessId: shell,
|
||||
Name: 'node.exe',
|
||||
CommandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js',
|
||||
ExecutablePath: 'C:/Program Files/nodejs/node.exe'
|
||||
}
|
||||
]
|
||||
}).flat()
|
||||
)
|
||||
|
||||
function installCountingPowerShellMock(): void {
|
||||
execFileMock.mockImplementation((cmd: string, _args: unknown, _opts: unknown, cb: unknown) => {
|
||||
const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void
|
||||
if (cmd === 'powershell.exe') {
|
||||
powershellScanCount.value += 1
|
||||
}
|
||||
callback(null, { stdout: PROCESS_TABLE_JSON, stderr: '' })
|
||||
})
|
||||
}
|
||||
|
||||
describe('windows agent foreground inspection powershell-scan volume', () => {
|
||||
let platform: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
resetWindowsProcessRowsSnapshotForTests()
|
||||
powershellScanCount.value = 0
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds powershell scans by poll ticks, not by pane count, while resolving every pane', async () => {
|
||||
installCountingPowerShellMock()
|
||||
|
||||
for (let tick = 0; tick < TICKS; tick++) {
|
||||
vi.setSystemTime(tick * ACTIVE_POLL_INTERVAL_MS)
|
||||
// All panes inspect concurrently within the tick (worst case).
|
||||
const resolved = await Promise.all(
|
||||
Array.from({ length: PANE_COUNT }, (_, pane) =>
|
||||
queryWindowsProcessDescendants(shellPid(pane))
|
||||
)
|
||||
)
|
||||
// Caching must not change the answer: every pane still finds its foreground
|
||||
// node child as the sole descendant of its shell.
|
||||
for (let pane = 0; pane < PANE_COUNT; pane++) {
|
||||
const candidates = resolved[pane]
|
||||
expect(candidates).not.toBeNull()
|
||||
expect(candidates).toHaveLength(1)
|
||||
expect(candidates?.[0]?.pid).toBe(shellPid(pane) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const totalInspections = PANE_COUNT * TICKS
|
||||
// Pre-fix this equals totalInspections (one powershell.exe per inspection).
|
||||
// With the shared cache, concurrent panes within a tick share one scan and
|
||||
// the 500ms TTL forces a fresh scan each new 750ms tick -> ~one per tick.
|
||||
expect(powershellScanCount.value).toBeLessThanOrEqual(TICKS + 1)
|
||||
expect(powershellScanCount.value).toBeLessThan(totalInspections / 2)
|
||||
})
|
||||
|
||||
it('collapses a burst of concurrent panes into a single scan', async () => {
|
||||
installCountingPowerShellMock()
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: PANE_COUNT }, (_, pane) =>
|
||||
queryWindowsProcessDescendants(shellPid(pane))
|
||||
)
|
||||
)
|
||||
|
||||
expect(powershellScanCount.value).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { createProcessTableSnapshotReader } from '../../shared/process-table-snapshot'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const WINDOWS_PROCESS_QUERY_TIMEOUT_MS = 3_000
|
||||
@@ -22,12 +23,48 @@ export type WindowsProcessRow = {
|
||||
|
||||
export type WindowsProcessCandidate = WindowsProcessRow & { depth: number }
|
||||
|
||||
// Why: agent foreground inspection forks a whole-process-table PowerShell/CIM
|
||||
// scan per pane on the same 750ms/2000ms cadence as the POSIX `ps` path. Without
|
||||
// dedup, K concurrent agent panes fork K powershell.exe cold-starts, each ~10-40x
|
||||
// heavier than `ps` — the Windows analogue of the idle-CPU churn #6288/#6667 fixed
|
||||
// for POSIX. Reuse the same TTL + single-in-flight reader, caching parsed rows so
|
||||
// a burst of panes collapses to ~2 scans/sec; every caller runs its own descendant
|
||||
// walk over the shared snapshot.
|
||||
async function runWindowsProcessRows(): Promise<WindowsProcessRow[]> {
|
||||
const rows =
|
||||
(await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
|
||||
if (!rows) {
|
||||
// Reject so the reader does not cache the miss; callers fall through to
|
||||
// node-pty's process name (the prior null-return contract is preserved by
|
||||
// queryWindowsProcessDescendants catching this).
|
||||
throw new Error('windows process enumeration unavailable')
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const windowsProcessRowsReader = createProcessTableSnapshotReader<WindowsProcessRow[]>({
|
||||
runPs: runWindowsProcessRows,
|
||||
now: () => Date.now()
|
||||
})
|
||||
|
||||
export async function queryWindowsProcessDescendants(
|
||||
rootPid: number
|
||||
): Promise<WindowsProcessCandidate[] | null> {
|
||||
const rows =
|
||||
(await queryWindowsProcessesWithPowerShell()) ?? (await queryWindowsProcessesWithWmic())
|
||||
return rows ? collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth) : null
|
||||
let rows: WindowsProcessRow[]
|
||||
try {
|
||||
rows = await windowsProcessRowsReader.getSnapshot()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: clear the shared Windows process-table snapshot so suites that mock
|
||||
* execFile between cases don't get one case's rows served to the next within TTL.
|
||||
*/
|
||||
export function resetWindowsProcessRowsSnapshotForTests(): void {
|
||||
windowsProcessRowsReader.reset()
|
||||
}
|
||||
|
||||
function parseWindowsProcessValueRows(stdout: string): WindowsProcessRow[] {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import os from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn(() => '/unused'),
|
||||
getVersion: vi.fn(() => '0.0.0-test')
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
ensureRelocatedNodePtyNativeRuntime,
|
||||
resolveNodePtyNativeSourceDir
|
||||
} from './node-pty-runtime-relocation'
|
||||
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(os.tmpdir(), 'node-pty-relocation-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Why: the loader-override test dlopens conpty.node from tempDir, and a
|
||||
// loaded native module stays image-locked until the process exits.
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {}
|
||||
})
|
||||
|
||||
function seedSourceDir(dir: string): void {
|
||||
mkdirSync(join(dir, 'conpty'), { recursive: true })
|
||||
writeFileSync(join(dir, 'conpty.node'), 'binding')
|
||||
writeFileSync(join(dir, 'conpty_console_list.node'), 'listing')
|
||||
writeFileSync(join(dir, 'pty.node'), 'winpty-binding')
|
||||
writeFileSync(join(dir, 'winpty-agent.exe'), 'agent')
|
||||
writeFileSync(join(dir, 'conpty.pdb'), 'symbols')
|
||||
writeFileSync(join(dir, 'conpty', 'conpty.dll'), 'dll')
|
||||
writeFileSync(join(dir, 'conpty', 'OpenConsole.exe'), 'console-host')
|
||||
}
|
||||
|
||||
describe('resolveNodePtyNativeSourceDir', () => {
|
||||
it('prefers the rebuilt build/Release binding over prebuilds', () => {
|
||||
const pkg = join(tempDir, 'node-pty')
|
||||
mkdirSync(join(pkg, 'build', 'Release'), { recursive: true })
|
||||
mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true })
|
||||
writeFileSync(join(pkg, 'build', 'Release', 'conpty.node'), 'x')
|
||||
writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x')
|
||||
expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'build', 'Release'))
|
||||
})
|
||||
|
||||
it('falls back to the platform prebuild dir', () => {
|
||||
const pkg = join(tempDir, 'node-pty')
|
||||
mkdirSync(join(pkg, 'prebuilds', `win32-${process.arch}`), { recursive: true })
|
||||
writeFileSync(join(pkg, 'prebuilds', `win32-${process.arch}`, 'conpty.node'), 'x')
|
||||
expect(resolveNodePtyNativeSourceDir(pkg)).toBe(join(pkg, 'prebuilds', `win32-${process.arch}`))
|
||||
})
|
||||
|
||||
it('returns null when no conpty binding exists', () => {
|
||||
const pkg = join(tempDir, 'node-pty')
|
||||
mkdirSync(join(pkg, 'build', 'Release'), { recursive: true })
|
||||
expect(resolveNodePtyNativeSourceDir(pkg)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureRelocatedNodePtyNativeRuntime', () => {
|
||||
it('copies the runtime tree (without symbols) and returns the version dir', () => {
|
||||
const sourceDir = join(tempDir, 'source')
|
||||
seedSourceDir(sourceDir)
|
||||
const destRoot = join(tempDir, 'dest')
|
||||
|
||||
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
|
||||
|
||||
expect(destDir).toBe(join(destRoot, '1.2.3'))
|
||||
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
|
||||
expect(readFileSync(join(destDir!, 'conpty', 'OpenConsole.exe'), 'utf8')).toBe('console-host')
|
||||
expect(readFileSync(join(destDir!, 'conpty', 'conpty.dll'), 'utf8')).toBe('dll')
|
||||
expect(existsSync(join(destDir!, 'conpty.pdb'))).toBe(false)
|
||||
})
|
||||
|
||||
it('skips recopying once the completion marker exists', () => {
|
||||
const sourceDir = join(tempDir, 'source')
|
||||
seedSourceDir(sourceDir)
|
||||
const destRoot = join(tempDir, 'dest')
|
||||
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
|
||||
|
||||
writeFileSync(join(sourceDir, 'conpty.node'), 'changed-after-first-copy')
|
||||
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
|
||||
|
||||
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
|
||||
})
|
||||
|
||||
it('redoes an interrupted copy that has no completion marker', () => {
|
||||
const sourceDir = join(tempDir, 'source')
|
||||
seedSourceDir(sourceDir)
|
||||
const destRoot = join(tempDir, 'dest')
|
||||
mkdirSync(join(destRoot, '1.2.3'), { recursive: true })
|
||||
writeFileSync(join(destRoot, '1.2.3', 'conpty.node'), 'torn partial copy')
|
||||
|
||||
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.2.3' })
|
||||
|
||||
expect(readFileSync(join(destDir!, 'conpty.node'), 'utf8')).toBe('binding')
|
||||
})
|
||||
|
||||
it('removes stale version dirs but keeps the current one', () => {
|
||||
const sourceDir = join(tempDir, 'source')
|
||||
seedSourceDir(sourceDir)
|
||||
const destRoot = join(tempDir, 'dest')
|
||||
ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '1.0.0' })
|
||||
|
||||
const destDir = ensureRelocatedNodePtyNativeRuntime({ sourceDir, destRoot, version: '2.0.0' })
|
||||
|
||||
expect(destDir).toBe(join(destRoot, '2.0.0'))
|
||||
expect(existsSync(join(destRoot, '1.0.0'))).toBe(false)
|
||||
expect(existsSync(join(destRoot, '2.0.0', 'conpty.node'))).toBe(true)
|
||||
})
|
||||
|
||||
it('fails open when the source dir is missing', () => {
|
||||
expect(
|
||||
ensureRelocatedNodePtyNativeRuntime({
|
||||
sourceDir: join(tempDir, 'does-not-exist'),
|
||||
destRoot: join(tempDir, 'dest'),
|
||||
version: '1.2.3'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Loads the real win32 conpty binding, so it cannot run on other platforms.
|
||||
describe.runIf(process.platform === 'win32')('patched node-pty loader override', () => {
|
||||
it('loads the conpty binding from ORCA_NODE_PTY_NATIVE_DIR', () => {
|
||||
const requireFromHere = createRequire(import.meta.url)
|
||||
const nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json'))
|
||||
const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir)
|
||||
expect(sourceDir).not.toBeNull()
|
||||
|
||||
const destDir = ensureRelocatedNodePtyNativeRuntime({
|
||||
sourceDir: sourceDir!,
|
||||
destRoot: join(tempDir, 'relocated-runtime'),
|
||||
version: 'loader-test'
|
||||
})
|
||||
expect(destDir).not.toBeNull()
|
||||
|
||||
const previousNativeDir = process.env.ORCA_NODE_PTY_NATIVE_DIR
|
||||
process.env.ORCA_NODE_PTY_NATIVE_DIR = destDir!
|
||||
try {
|
||||
const utils = requireFromHere('node-pty/lib/utils.js')
|
||||
const loaded = utils.loadNativeModule('conpty')
|
||||
expect(loaded.dir).toBe(destDir)
|
||||
expect(typeof loaded.module.startProcess).toBe('function')
|
||||
} finally {
|
||||
if (previousNativeDir === undefined) {
|
||||
delete process.env.ORCA_NODE_PTY_NATIVE_DIR
|
||||
} else {
|
||||
process.env.ORCA_NODE_PTY_NATIVE_DIR = previousNativeDir
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
|
||||
/**
|
||||
* Relocates node-pty's Windows native runtime (conpty.node, conpty.dll,
|
||||
* OpenConsole.exe, winpty binaries) from the install directory to userData.
|
||||
*
|
||||
* Why: the NSIS update installer force-closes every process whose image path
|
||||
* is under the install directory before replacing files. conpty.dll spawns
|
||||
* OpenConsole.exe from beside itself, so while these binaries live in the
|
||||
* install dir every live terminal's console host is killed mid-update.
|
||||
* Loading them from userData (via the ORCA_NODE_PTY_NATIVE_DIR override
|
||||
* patched into node-pty's loader) takes them out of the installer's kill
|
||||
* zone, and the detached daemon inherits the env var so its PTYs are covered.
|
||||
*/
|
||||
export const NODE_PTY_NATIVE_DIR_ENV_VAR = 'ORCA_NODE_PTY_NATIVE_DIR'
|
||||
|
||||
const RELOCATION_COMPLETE_MARKER = '.relocation-complete'
|
||||
|
||||
export function resolveNodePtyNativeSourceDir(nodePtyPackageDir: string): string | null {
|
||||
// Packaged builds carry the rebuilt binding in build/Release; dev installs
|
||||
// (and the forced-relocation test path) load from prebuilds.
|
||||
const candidates = [
|
||||
join(nodePtyPackageDir, 'build', 'Release'),
|
||||
join(nodePtyPackageDir, 'prebuilds', `win32-${process.arch}`)
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(join(candidate, 'conpty.node'))) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function copyRuntimeTree(sourceDir: string, destDir: string): void {
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
const sourcePath = join(sourceDir, entry.name)
|
||||
const destPath = join(destDir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
copyRuntimeTree(sourcePath, destPath)
|
||||
} else if (entry.isFile() && !/\.pdb$/i.test(entry.name)) {
|
||||
copyFileSync(sourcePath, destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeStaleRuntimeVersions(destRoot: string, keepVersion: string): void {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(destRoot, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === keepVersion) {
|
||||
continue
|
||||
}
|
||||
// Why: an adopted daemon from a previous version may still run (or later
|
||||
// respawn) binaries out of its own version dir. Renaming a directory
|
||||
// fails on Windows while anything inside is open, so a successful rename
|
||||
// proves the dir is unused and safe to delete.
|
||||
const doomedPath = join(destRoot, `${entry.name}.stale`)
|
||||
try {
|
||||
renameSync(join(destRoot, entry.name), doomedPath)
|
||||
rmSync(doomedPath, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Still in use (or already being cleaned) — retry on a future launch.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureRelocatedNodePtyNativeRuntime(options: {
|
||||
sourceDir: string
|
||||
destRoot: string
|
||||
version: string
|
||||
}): string | null {
|
||||
const { sourceDir, destRoot, version } = options
|
||||
const destDir = join(destRoot, version)
|
||||
try {
|
||||
// Why: the marker is written only after a full copy, so a crash mid-copy
|
||||
// leaves no marker and the next launch redoes the copy from scratch.
|
||||
if (!existsSync(join(destDir, RELOCATION_COMPLETE_MARKER))) {
|
||||
rmSync(destDir, { recursive: true, force: true })
|
||||
copyRuntimeTree(sourceDir, destDir)
|
||||
writeFileSync(join(destDir, RELOCATION_COMPLETE_MARKER), '')
|
||||
}
|
||||
removeStaleRuntimeVersions(destRoot, version)
|
||||
return destDir
|
||||
} catch {
|
||||
// Fail open: node-pty keeps loading from the install dir, which is the
|
||||
// pre-relocation behavior (sessions then don't survive updates).
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function installRelocatedNodePtyNativeRuntime(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
if (!app.isPackaged && process.env.ORCA_FORCE_CONPTY_RELOCATION !== '1') {
|
||||
return
|
||||
}
|
||||
// Note: a pre-set env var is deliberately NOT honored here. The update
|
||||
// relaunch chain (old app -> installer -> new app) inherits the previous
|
||||
// version's value, which would pin the new process to stale binaries and
|
||||
// skip copying the current version.
|
||||
let nodePtyPackageDir: string
|
||||
try {
|
||||
const requireFromHere = createRequire(import.meta.url)
|
||||
nodePtyPackageDir = dirname(requireFromHere.resolve('node-pty/package.json'))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const sourceDir = resolveNodePtyNativeSourceDir(nodePtyPackageDir)
|
||||
if (!sourceDir) {
|
||||
return
|
||||
}
|
||||
const destDir = ensureRelocatedNodePtyNativeRuntime({
|
||||
sourceDir,
|
||||
destRoot: join(app.getPath('userData'), 'node-pty-runtime'),
|
||||
version: app.getVersion()
|
||||
})
|
||||
if (destDir) {
|
||||
process.env[NODE_PTY_NATIVE_DIR_ENV_VAR] = destDir
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,17 @@ type ClaudeUsageAttemptState = {
|
||||
attemptedSources: UsageRateLimitSource[]
|
||||
}
|
||||
|
||||
function abortedClaudeRateLimitResult(): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: 'Rate-limit fetch aborted',
|
||||
status: 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function parseResetTimestamp(value: string | number | undefined): number | null {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
@@ -389,11 +400,21 @@ function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
|
||||
async function fetchViaOAuth(token: string, signal?: AbortSignal): Promise<ProviderRateLimits> {
|
||||
if (signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
await ensureProxyFromEnv()
|
||||
if (signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
|
||||
const abortFromExternalSignal = (): void => {
|
||||
controller.abort()
|
||||
}
|
||||
signal?.addEventListener('abort', abortFromExternalSignal, { once: true })
|
||||
|
||||
try {
|
||||
// Why: net.fetch uses Chromium's networking stack which respects OS proxy
|
||||
@@ -414,6 +435,9 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
|
||||
}
|
||||
|
||||
const data = (await res.json()) as OAuthUsageResponse
|
||||
if (signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'claude',
|
||||
@@ -424,8 +448,14 @@ async function fetchViaOAuth(token: string): Promise<ProviderRateLimits> {
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
signal?.removeEventListener('abort', abortFromExternalSignal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,11 +537,13 @@ async function fetchClaudeUsageViaCli(input: {
|
||||
oauthCredentials: OAuthCredentialReadResult
|
||||
attempts: ClaudeUsageAttemptState
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}): Promise<ProviderRateLimits> {
|
||||
recordAttempt(input.attempts, 'cli')
|
||||
const limits = await fetchViaPty({
|
||||
authPreparation: input.authPreparation,
|
||||
networkProxySettings: input.networkProxySettings
|
||||
networkProxySettings: input.networkProxySettings,
|
||||
signal: input.signal
|
||||
})
|
||||
return withClaudeUsageMetadata(
|
||||
limits,
|
||||
@@ -567,8 +599,9 @@ async function supplementOAuthUsageFromCli(input: {
|
||||
attempts: ClaudeUsageAttemptState
|
||||
allowUsagePanelSupplement: boolean
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}): Promise<ProviderRateLimits> {
|
||||
if (!canSupplementOAuthUsageFromCli(input)) {
|
||||
if (input.signal?.aborted || !canSupplementOAuthUsageFromCli(input)) {
|
||||
return input.oauthLimits
|
||||
}
|
||||
try {
|
||||
@@ -576,7 +609,8 @@ async function supplementOAuthUsageFromCli(input: {
|
||||
authPreparation: input.authPreparation,
|
||||
oauthCredentials: input.oauthCredentials,
|
||||
attempts: input.attempts,
|
||||
networkProxySettings: input.networkProxySettings
|
||||
networkProxySettings: input.networkProxySettings,
|
||||
signal: input.signal
|
||||
})
|
||||
return mergeClaudeUsageWindows(input.oauthLimits, cliLimits)
|
||||
} catch (err) {
|
||||
@@ -637,25 +671,40 @@ async function attemptCliRepairThenRetryOAuth(input: {
|
||||
attempts: ClaudeUsageAttemptState
|
||||
oauthCredentials: OAuthCredentialReadResult
|
||||
}): Promise<ProviderRateLimits | null> {
|
||||
if (input.options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
let cliResult: ProviderRateLimits | null = null
|
||||
try {
|
||||
cliResult = await fetchClaudeUsageViaCli({
|
||||
authPreparation: input.options?.authPreparation,
|
||||
oauthCredentials: input.oauthCredentials,
|
||||
attempts: input.attempts,
|
||||
networkProxySettings: input.options?.networkProxySettings
|
||||
networkProxySettings: input.options?.networkProxySettings,
|
||||
signal: input.options?.signal
|
||||
})
|
||||
} catch (err) {
|
||||
warnClaudeUsageFetchFailure(input.options?.authPreparation, input.oauthCredentials, err)
|
||||
}
|
||||
|
||||
// Why: bail before credential I/O if the fetch cycle was stopped mid-CLI-repair.
|
||||
if (input.options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
|
||||
const refreshedCredentials = await readOAuthCredentials(
|
||||
resolveOAuthCredentialReadOptions(input.options?.authPreparation)
|
||||
)
|
||||
if (input.options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
if (refreshedCredentials.token) {
|
||||
recordAttempt(input.attempts, 'oauth')
|
||||
try {
|
||||
const oauthRetry = await fetchViaOAuth(refreshedCredentials.token)
|
||||
const oauthRetry = await fetchViaOAuth(refreshedCredentials.token, input.options?.signal)
|
||||
if (input.options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
const supplemented = mergeClaudeUsageWindows(oauthRetry, cliResult)
|
||||
return withClaudeUsageMetadata(
|
||||
supplemented,
|
||||
@@ -683,16 +732,21 @@ export type FetchClaudeRateLimitsOptions = {
|
||||
allowPtyFallback?: boolean
|
||||
allowUsagePanelSupplement?: boolean
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type FetchManagedAccountUsageOptions = {
|
||||
allowUsagePanelSupplement?: boolean
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export async function fetchClaudeRateLimits(
|
||||
options?: FetchClaudeRateLimitsOptions
|
||||
): Promise<ProviderRateLimits> {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
const attempts: ClaudeUsageAttemptState = { attemptedSources: [] }
|
||||
const allowCliFallback = options?.allowPtyFallback !== false
|
||||
const plan = resolveClaudeUsageRefreshPlan({
|
||||
@@ -715,11 +769,17 @@ export async function fetchClaudeRateLimits(
|
||||
const oauthCredentials = await readOAuthCredentials(
|
||||
resolveOAuthCredentialReadOptions(options?.authPreparation)
|
||||
)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
|
||||
if (plan.steps.some((step) => step.source === 'oauth') && oauthCredentials.token) {
|
||||
recordAttempt(attempts, 'oauth')
|
||||
try {
|
||||
const oauthLimits = await fetchViaOAuth(oauthCredentials.token)
|
||||
const oauthLimits = await fetchViaOAuth(oauthCredentials.token, options?.signal)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
const limits = await supplementOAuthUsageFromCli({
|
||||
oauthLimits,
|
||||
authPreparation: options?.authPreparation,
|
||||
@@ -727,8 +787,12 @@ export async function fetchClaudeRateLimits(
|
||||
attempts,
|
||||
networkProxySettings: options?.networkProxySettings,
|
||||
allowUsagePanelSupplement:
|
||||
options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation)
|
||||
options?.allowUsagePanelSupplement ?? isManagedClaudeAuth(options?.authPreparation),
|
||||
signal: options?.signal
|
||||
})
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
return withClaudeUsageMetadata(
|
||||
limits,
|
||||
metadataForAttempt({
|
||||
@@ -767,7 +831,8 @@ export async function fetchClaudeRateLimits(
|
||||
authPreparation: options?.authPreparation,
|
||||
oauthCredentials,
|
||||
attempts,
|
||||
networkProxySettings: options?.networkProxySettings
|
||||
networkProxySettings: options?.networkProxySettings,
|
||||
signal: options?.signal
|
||||
})
|
||||
} catch (ptyError) {
|
||||
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, ptyError)
|
||||
@@ -825,7 +890,8 @@ export async function fetchClaudeRateLimits(
|
||||
authPreparation: options?.authPreparation,
|
||||
oauthCredentials,
|
||||
attempts,
|
||||
networkProxySettings: options?.networkProxySettings
|
||||
networkProxySettings: options?.networkProxySettings,
|
||||
signal: options?.signal
|
||||
})
|
||||
} catch (err) {
|
||||
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
|
||||
@@ -871,7 +937,8 @@ export async function fetchClaudeRateLimits(
|
||||
authPreparation: options?.authPreparation,
|
||||
oauthCredentials,
|
||||
attempts,
|
||||
networkProxySettings: options?.networkProxySettings
|
||||
networkProxySettings: options?.networkProxySettings,
|
||||
signal: options?.signal
|
||||
})
|
||||
} catch (err) {
|
||||
warnClaudeUsageFetchFailure(options?.authPreparation, oauthCredentials, err)
|
||||
@@ -1079,7 +1146,11 @@ async function fetchManagedUsagePanelSupplement(input: {
|
||||
credentialsJson: string
|
||||
oauthLimits: ProviderRateLimits
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}): Promise<ProviderRateLimits | null> {
|
||||
if (input.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
const authPreparation = getManagedUsagePanelAuthPreparation(input.account, input.location)
|
||||
if (!authPreparation) {
|
||||
return null
|
||||
@@ -1087,8 +1158,12 @@ async function fetchManagedUsagePanelSupplement(input: {
|
||||
return withManagedPreviewKeychainCredentials(input.location, input.credentialsJson, async () => {
|
||||
const cliLimits = await fetchViaPty({
|
||||
authPreparation,
|
||||
networkProxySettings: input.networkProxySettings
|
||||
networkProxySettings: input.networkProxySettings,
|
||||
signal: input.signal
|
||||
})
|
||||
if (input.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
!canTrustManagedUsagePanelSupplement(input.oauthLimits, cliLimits, {
|
||||
requireMatchingOAuthWindow: input.location.kind === 'keychain'
|
||||
@@ -1108,8 +1183,14 @@ export async function fetchManagedAccountUsage(
|
||||
account: InactiveClaudeAccountInfo,
|
||||
options: FetchManagedAccountUsageOptions = {}
|
||||
): Promise<ProviderRateLimits> {
|
||||
if (options.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
const location = resolveManagedCredentialsLocation(account)
|
||||
let credentialsJson = location ? await readManagedCredentialsJson(location) : null
|
||||
if (options.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
if (!location || !credentialsJson) {
|
||||
return {
|
||||
provider: 'claude',
|
||||
@@ -1129,6 +1210,9 @@ export async function fetchManagedAccountUsage(
|
||||
let token = parseOAuthCredentialsJson(credentialsJson, 'credentials-file').token
|
||||
if (isOauthTokenExpiring(credentialsJson)) {
|
||||
const refreshed = await refreshClaudeOauthCredentials(credentialsJson)
|
||||
if (options.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
if (refreshed) {
|
||||
try {
|
||||
await writeManagedCredentialsJson(location, refreshed)
|
||||
@@ -1155,7 +1239,10 @@ export async function fetchManagedAccountUsage(
|
||||
// Why: PTY fallback is intentionally omitted for inactive accounts. The PTY
|
||||
// path is used only as a supplement after OAuth succeeds, and it points
|
||||
// directly at the managed account's isolated config so selection is unchanged.
|
||||
const oauthLimits = await fetchViaOAuth(token)
|
||||
const oauthLimits = await fetchViaOAuth(token, options.signal)
|
||||
if (options.signal?.aborted) {
|
||||
return abortedClaudeRateLimitResult()
|
||||
}
|
||||
if (
|
||||
!canSupplementOAuthUsageFromCli({
|
||||
oauthLimits,
|
||||
@@ -1171,7 +1258,8 @@ export async function fetchManagedAccountUsage(
|
||||
location,
|
||||
credentialsJson,
|
||||
oauthLimits,
|
||||
networkProxySettings: options.networkProxySettings
|
||||
networkProxySettings: options.networkProxySettings,
|
||||
signal: options.signal
|
||||
})
|
||||
return mergeClaudeUsageWindows(oauthLimits, cliLimits)
|
||||
} catch (err) {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('node-pty', () => ({
|
||||
}))
|
||||
|
||||
import { fetchViaPty } from './claude-pty'
|
||||
import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
|
||||
|
||||
function makeDisposable() {
|
||||
return { dispose: vi.fn() }
|
||||
@@ -97,6 +98,22 @@ describe('fetchViaPty', () => {
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('spawns the hidden usage PTY in a bounded non-root cwd', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
|
||||
const resultPromise = fetchViaPty()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const spawnCwd = spawnMock.mock.calls[0]?.[2]?.cwd as string
|
||||
expect(spawnCwd).toContain('rate-limit-pty-cwd')
|
||||
expect(spawnCwd).not.toBe('/')
|
||||
expect(spawnCwd).not.toMatch(/^[A-Za-z]:(?:[\\/])?$/)
|
||||
|
||||
term.emitExit()
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('leaves the spawn env proxy untouched when no proxy is configured', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
@@ -135,6 +152,8 @@ describe('fetchViaPty', () => {
|
||||
const [spawnFile, spawnArgs] = spawnMock.mock.calls[0] as [string, string[]]
|
||||
expect(spawnFile).toBe('wsl.exe')
|
||||
const bashCommand = spawnArgs.at(-1) as string
|
||||
expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain("export HTTPS_PROXY='http://127.0.0.1:7890'")
|
||||
expect(bashCommand).toContain('exec claude')
|
||||
|
||||
@@ -142,6 +161,28 @@ describe('fetchViaPty', () => {
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('kills and unregisters the hidden PTY when the fetch signal aborts', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
const controller = new AbortController()
|
||||
const killMock = term.kill
|
||||
|
||||
const resultPromise = fetchViaPty({ signal: controller.signal })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'claude',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(killMock).toHaveBeenCalledTimes(1)
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('clears the startup delay timer when the hidden PTY exits early', async () => {
|
||||
const term = makeMockTerm()
|
||||
spawnMock.mockReturnValue(term)
|
||||
|
||||
@@ -7,8 +7,12 @@ import { resolveClaudeCommand } from '../codex-cli/command'
|
||||
import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service'
|
||||
import { applyClaudeEnvPatch } from '../claude-accounts/environment'
|
||||
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
|
||||
import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
|
||||
import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
|
||||
import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser'
|
||||
import {
|
||||
getHiddenRateLimitWslCwdSetupCommands,
|
||||
resolveHiddenRateLimitPtyCwd
|
||||
} from './hidden-rate-limit-pty-cwd'
|
||||
|
||||
const PTY_TIMEOUT_MS = 25_000
|
||||
const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit
|
||||
@@ -197,11 +201,29 @@ function describeClaudeUsageFailure(output: string): string {
|
||||
return 'Claude usage is unavailable right now.'
|
||||
}
|
||||
|
||||
function abortedClaudeUsageResult(): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'claude',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: 'Rate-limit fetch aborted',
|
||||
status: 'error'
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchViaPty(options?: {
|
||||
authPreparation?: ClaudeRuntimeAuthPreparation
|
||||
networkProxySettings?: NetworkProxySettings
|
||||
signal?: AbortSignal
|
||||
}): Promise<ProviderRateLimits> {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeUsageResult()
|
||||
}
|
||||
const pty = await import('node-pty')
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedClaudeUsageResult()
|
||||
}
|
||||
|
||||
return new Promise<ProviderRateLimits>((resolve) => {
|
||||
let output = ''
|
||||
@@ -251,10 +273,13 @@ export async function fetchViaPty(options?: {
|
||||
// Why: Windows-side env does not cross into the distro without WSLENV,
|
||||
// so export the configured proxy inside the command for the inner claude.
|
||||
[
|
||||
// Why: hidden usage probes must not inherit a root-like WSL cwd;
|
||||
// keep Claude discovery bounded to a tiny temp directory.
|
||||
...getHiddenRateLimitWslCwdSetupCommands(),
|
||||
`export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}`,
|
||||
...Object.entries(proxyEnv).map(([key, value]) => `export ${key}=${shellQuote(value)}`),
|
||||
'exec claude'
|
||||
].join('; ')
|
||||
].join(' && ')
|
||||
]
|
||||
: isWin32
|
||||
? ['/c', `"${claudeCommand}"`]
|
||||
@@ -264,10 +289,14 @@ export async function fetchViaPty(options?: {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
// Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a
|
||||
// drive root), which can trigger unbounded file discovery.
|
||||
cwd: resolveHiddenRateLimitPtyCwd(),
|
||||
env: spawnEnv
|
||||
})
|
||||
const termDisposables: { dispose: () => void }[] = []
|
||||
const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)]
|
||||
let enterInterval: ReturnType<typeof setInterval> | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearFollowupTimers(): void {
|
||||
if (startupDelayTimer) {
|
||||
@@ -288,7 +317,32 @@ export async function fetchViaPty(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
function settleAborted(): void {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
clearFollowupTimers()
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
resolve(abortedClaudeUsageResult())
|
||||
}
|
||||
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
settleAborted()
|
||||
return
|
||||
}
|
||||
options.signal.addEventListener('abort', settleAborted, { once: true })
|
||||
termDisposables.push({
|
||||
dispose: () => options.signal?.removeEventListener('abort', settleAborted)
|
||||
})
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearFollowupTimers()
|
||||
@@ -342,7 +396,10 @@ export async function fetchViaPty(options?: {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
clearFollowupTimers()
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
|
||||
@@ -443,7 +500,10 @@ export async function fetchViaPty(options?: {
|
||||
clearFollowupTimers()
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
const clean = stripTerminalControlSequences(output)
|
||||
const { session, weekly, fableWeekly } = parsePtyUsage(clean)
|
||||
resolve({
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock('./codex-auth-presence', () => ({
|
||||
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { codexAuthExists } from './codex-auth-presence'
|
||||
import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
|
||||
|
||||
function makeDisposable() {
|
||||
return { dispose: vi.fn() }
|
||||
@@ -52,6 +53,25 @@ function makeRpcChild() {
|
||||
return child
|
||||
}
|
||||
|
||||
function makePtyTerm() {
|
||||
let dataHandler: ((data: string) => void) | null = null
|
||||
let exitHandler: (() => void) | null = null
|
||||
return {
|
||||
onData: vi.fn((callback: (data: string) => void) => {
|
||||
dataHandler = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
onExit: vi.fn((callback: () => void) => {
|
||||
exitHandler = callback
|
||||
return makeDisposable()
|
||||
}),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
emitData: (data: string) => dataHandler?.(data),
|
||||
emitExit: () => exitHandler?.()
|
||||
}
|
||||
}
|
||||
|
||||
describe('fetchCodexRateLimits', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
@@ -108,6 +128,83 @@ describe('fetchCodexRateLimits', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
|
||||
|
||||
const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string
|
||||
expect(spawnCwd).toContain('rate-limit-pty-cwd')
|
||||
expect(spawnCwd).not.toBe('/')
|
||||
expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
|
||||
|
||||
rpcChild.emit('close')
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('spawns the PTY fallback in a bounded non-root cwd', async () => {
|
||||
const term = makePtyTerm()
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue(term)
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const spawnCwd = ptySpawnMock.mock.calls[0]?.[2]?.cwd as string
|
||||
expect(spawnCwd).toContain('rate-limit-pty-cwd')
|
||||
expect(spawnCwd).not.toBe('/')
|
||||
expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
|
||||
|
||||
term.emitExit()
|
||||
await resultPromise
|
||||
})
|
||||
|
||||
it('kills the RPC child and skips PTY fallback when the fetch signal aborts', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
const controller = new AbortController()
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(rpcChild.kill).toHaveBeenCalledTimes(1)
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('kills and unregisters the PTY fallback when the fetch signal aborts', async () => {
|
||||
const term = makePtyTerm()
|
||||
childSpawnMock.mockImplementation(() => {
|
||||
throw new Error('rpc unavailable')
|
||||
})
|
||||
ptySpawnMock.mockReturnValue(term)
|
||||
const controller = new AbortController()
|
||||
const killMock = term.kill
|
||||
|
||||
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: 'Rate-limit fetch aborted'
|
||||
})
|
||||
expect(killMock).toHaveBeenCalledTimes(1)
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to the PTY status reader when RPC exits before returning usage', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
const ptyHandlers: { onData?: (data: string) => void } = {}
|
||||
@@ -437,17 +534,18 @@ describe('fetchCodexRateLimits', () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await resultPromise
|
||||
|
||||
expect(childSpawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-lc',
|
||||
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'"
|
||||
],
|
||||
const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0]
|
||||
expect(spawnFile).toBe('wsl.exe')
|
||||
const bashCommand = spawnArgs.at(-1) as string
|
||||
expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain(
|
||||
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'"
|
||||
)
|
||||
expect(bashCommand).toContain("exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'")
|
||||
expect(spawnOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringContaining('rate-limit-pty-cwd'),
|
||||
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
|
||||
})
|
||||
)
|
||||
@@ -488,17 +586,18 @@ describe('fetchCodexRateLimits', () => {
|
||||
rpcChild.emit('close')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(ptySpawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-lc',
|
||||
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'; exec codex "
|
||||
],
|
||||
const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0]
|
||||
expect(spawnFile).toBe('wsl.exe')
|
||||
const bashCommand = spawnArgs.at(-1) as string
|
||||
expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"')
|
||||
expect(bashCommand).toContain(
|
||||
"export CODEX_HOME='/home/alice/.local/share/orca/account/home'"
|
||||
)
|
||||
expect(bashCommand).toContain('exec codex ')
|
||||
expect(spawnOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
cwd: expect.stringContaining('rate-limit-pty-cwd'),
|
||||
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
|
||||
})
|
||||
)
|
||||
|
||||
@@ -14,9 +14,13 @@ import { codexAuthExists } from './codex-auth-presence'
|
||||
import { resolveCodexCommand } from '../codex-cli/command'
|
||||
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
|
||||
import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils'
|
||||
import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
|
||||
import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors'
|
||||
import {
|
||||
getHiddenRateLimitWslCwdSetupCommands,
|
||||
resolveHiddenRateLimitPtyCwd
|
||||
} from './hidden-rate-limit-pty-cwd'
|
||||
|
||||
const RPC_TIMEOUT_MS = 10_000
|
||||
const WSL_RPC_TIMEOUT_MS = 25_000
|
||||
@@ -26,6 +30,7 @@ const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000
|
||||
export type FetchCodexRateLimitsOptions = {
|
||||
codexHomePath?: string | null
|
||||
allowPtyFallback?: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -117,9 +122,10 @@ function buildWslCodexCommand(
|
||||
return null
|
||||
}
|
||||
const script = [
|
||||
...getHiddenRateLimitWslCwdSetupCommands(),
|
||||
`export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`,
|
||||
`exec codex ${args.map(shellQuote).join(' ')}`
|
||||
].join('; ')
|
||||
].join(' && ')
|
||||
return {
|
||||
command: 'wsl.exe',
|
||||
args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script]
|
||||
@@ -161,6 +167,17 @@ function normalizeCreditStatus(status: string | undefined): string {
|
||||
return status?.toLowerCase() ?? 'unknown'
|
||||
}
|
||||
|
||||
function abortedCodexRateLimitResult(): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: 'Rate-limit fetch aborted',
|
||||
status: 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function getNextAvailableCreditExpiry(
|
||||
credits: RateLimitResetCredits['credits'] | undefined
|
||||
): number | null {
|
||||
@@ -228,6 +245,9 @@ function mapBackendRateLimitResetCredits(
|
||||
async function getCodexBackendAuthHeaders(
|
||||
options?: FetchCodexRateLimitsOptions
|
||||
): Promise<CodexBackendAuthHeaders | null> {
|
||||
if (options?.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
const authPath = join(getCodexHomePath(options?.codexHomePath), 'auth.json')
|
||||
const auth = JSON.parse(await readFile(authPath, 'utf8')) as CodexAuthFile
|
||||
const accessToken = auth.tokens?.access_token
|
||||
@@ -250,16 +270,22 @@ async function getCodexBackendAuthHeaders(
|
||||
async function fetchBackendRateLimitResetCredits(
|
||||
options?: FetchCodexRateLimitsOptions
|
||||
): Promise<RateLimitResetCredits | null> {
|
||||
if (options?.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
const auth = await getCodexBackendAuthHeaders(options)
|
||||
if (!auth) {
|
||||
return null
|
||||
}
|
||||
if (options?.signal?.aborted) {
|
||||
return null
|
||||
}
|
||||
// Why: published Codex 0.140 can read windows through app-server but strips
|
||||
// reset-credit metadata that the backend already returns.
|
||||
const response = await fetch(
|
||||
'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits',
|
||||
auth
|
||||
)
|
||||
const response = await fetch('https://chatgpt.com/backend-api/wham/rate-limit-reset-credits', {
|
||||
...auth,
|
||||
signal: options?.signal
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
@@ -272,6 +298,7 @@ async function withBackendRateLimitResetCredits(
|
||||
options?: FetchCodexRateLimitsOptions
|
||||
): Promise<ProviderRateLimits> {
|
||||
if (
|
||||
options?.signal?.aborted ||
|
||||
limits.provider !== 'codex' ||
|
||||
(limits.rateLimitResetCredits?.nextExpiresAt !== undefined &&
|
||||
limits.rateLimitResetCredits.nextExpiresAt !== null)
|
||||
@@ -373,6 +400,9 @@ function mapRpcWindow(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<ProviderRateLimits> {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
return new Promise<ProviderRateLimits>((resolve) => {
|
||||
let buffer = ''
|
||||
let stderr = ''
|
||||
@@ -396,6 +426,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
: getSpawnArgsForWindows(codexCommand, codexArgs)
|
||||
const child = spawn(spawnCmd, spawnArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: resolveHiddenRateLimitPtyCwd(),
|
||||
// Why: the selected Codex rate-limit account must only affect this fetch
|
||||
// subprocess. Never mutate process.env globally or other Codex features
|
||||
// would inherit the managed account unintentionally.
|
||||
@@ -416,6 +447,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
options?.signal?.removeEventListener('abort', onAbort)
|
||||
child.stdout.off('data', onStdoutData)
|
||||
child.stderr.off('data', onStderrData)
|
||||
child.off('error', onError)
|
||||
@@ -434,6 +466,18 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
function onAbort(): void {
|
||||
settle(abortedCodexRateLimitResult(), { kill: true })
|
||||
}
|
||||
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
options.signal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
settle(
|
||||
{
|
||||
@@ -632,7 +676,13 @@ function parsePtyStatus(output: string): {
|
||||
}
|
||||
|
||||
async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<ProviderRateLimits> {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const pty = await import('node-pty')
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const wslCodex = options?.codexHomePath ? buildWslCodexCommand(options.codexHomePath, []) : null
|
||||
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
|
||||
|
||||
@@ -652,20 +702,50 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
let resolved = false
|
||||
let sentStatus = false
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const term = pty.spawn(spawnFile, spawnArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: resolveHiddenRateLimitPtyCwd(),
|
||||
env: {
|
||||
...(wslCodex ? cloneProcessEnvWithoutCodexHome() : process.env),
|
||||
TERM: 'xterm-256color',
|
||||
...(options?.codexHomePath && !wslCodex ? { CODEX_HOME: options.codexHomePath } : {})
|
||||
}
|
||||
})
|
||||
const termDisposables: { dispose: () => void }[] = []
|
||||
const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)]
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
function settleAborted(): void {
|
||||
if (resolved) {
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer)
|
||||
settleTimer = null
|
||||
}
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
resolve(abortedCodexRateLimitResult())
|
||||
}
|
||||
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
settleAborted()
|
||||
return
|
||||
}
|
||||
options.signal.addEventListener('abort', settleAborted, { once: true })
|
||||
termDisposables.push({
|
||||
dispose: () => options.signal?.removeEventListener('abort', settleAborted)
|
||||
})
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
if (settleTimer) {
|
||||
@@ -709,7 +789,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
return
|
||||
}
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
@@ -742,7 +825,10 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
}
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
||||
const { session, weekly } = parsePtyStatus(clean)
|
||||
@@ -773,6 +859,9 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
export async function fetchCodexRateLimits(
|
||||
options?: FetchCodexRateLimitsOptions
|
||||
): Promise<ProviderRateLimits> {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
// Why: never spawn the `codex` binary unless the user has signed in. Without
|
||||
// auth the RPC/PTY paths can only error, and spawning them shows up as an
|
||||
// unexpected background Codex process for users who don't use Codex.
|
||||
@@ -790,8 +879,12 @@ export async function fetchCodexRateLimits(
|
||||
// Path A: try RPC first
|
||||
try {
|
||||
const rpcResult = await fetchViaRpc(options)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
if (rpcResult.status === 'ok' || rpcResult.status === 'unavailable') {
|
||||
return await withBackendRateLimitResetCredits(rpcResult, options)
|
||||
const withResetCredits = await withBackendRateLimitResetCredits(rpcResult, options)
|
||||
return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits
|
||||
}
|
||||
if (isCodexAuthError(rpcResult.error)) {
|
||||
return rpcResult
|
||||
@@ -802,6 +895,9 @@ export async function fetchCodexRateLimits(
|
||||
// Why: app-server can fail independently of the interactive CLI. Keep the
|
||||
// status-bar useful by trying the older /status PTY reader on RPC errors.
|
||||
} catch {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
if (options?.allowPtyFallback === false) {
|
||||
return {
|
||||
provider: 'codex',
|
||||
@@ -817,8 +913,19 @@ export async function fetchCodexRateLimits(
|
||||
|
||||
// Path B: PTY fallback
|
||||
try {
|
||||
return await withBackendRateLimitResetCredits(await fetchViaPty(options), options)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const ptyResult = await fetchViaPty(options)
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const withResetCredits = await withBackendRateLimitResetCredits(ptyResult, options)
|
||||
return options?.signal?.aborted ? abortedCodexRateLimitResult() : withResetCredits
|
||||
} catch (err) {
|
||||
if (options?.signal?.aborted) {
|
||||
return abortedCodexRateLimitResult()
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
const isNotInstalled = message.includes('ENOENT')
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanupHiddenRateLimitPty } from './hidden-pty-cleanup'
|
||||
import {
|
||||
cleanupHiddenRateLimitPty,
|
||||
getActiveHiddenRateLimitPtyCount,
|
||||
registerHiddenRateLimitPty
|
||||
} from './hidden-pty-cleanup'
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
@@ -98,4 +102,34 @@ describe('cleanupHiddenRateLimitPty', () => {
|
||||
expect(term.kill).toHaveBeenCalledTimes(1)
|
||||
expect(term.destroy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('removes registered hidden PTYs when cleanup kills them', () => {
|
||||
setPlatform('darwin')
|
||||
const term = {
|
||||
kill: vi.fn(),
|
||||
destroy: vi.fn()
|
||||
}
|
||||
const registration = registerHiddenRateLimitPty(term)
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
|
||||
|
||||
cleanupHiddenRateLimitPty(term, [registration], { kill: true })
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('removes registered hidden PTYs after natural exit cleanup', () => {
|
||||
setPlatform('darwin')
|
||||
const term = {
|
||||
kill: vi.fn(),
|
||||
destroy: vi.fn()
|
||||
}
|
||||
const registration = registerHiddenRateLimitPty(term)
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
|
||||
|
||||
cleanupHiddenRateLimitPty(term, [registration], { kill: false })
|
||||
|
||||
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,21 @@ type Disposable = {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
const activeHiddenRateLimitPtys = new Set<HiddenPty>()
|
||||
|
||||
export function registerHiddenRateLimitPty(term: HiddenPty): Disposable {
|
||||
activeHiddenRateLimitPtys.add(term)
|
||||
return {
|
||||
dispose: () => {
|
||||
activeHiddenRateLimitPtys.delete(term)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveHiddenRateLimitPtyCount(): number {
|
||||
return activeHiddenRateLimitPtys.size
|
||||
}
|
||||
|
||||
export function cleanupHiddenRateLimitPty(
|
||||
term: HiddenPty,
|
||||
disposables: Disposable[],
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { mkdirSync, realpathSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { isRootLikePath } from '../providers/pty-path-safety'
|
||||
|
||||
const HIDDEN_RATE_LIMIT_PTY_CWD_DIR = 'rate-limit-pty-cwd'
|
||||
const WSL_RATE_LIMIT_PTY_CWD_DIR = 'orca-rate-limit-pty-cwd'
|
||||
|
||||
// Why: the hidden usage PTY must run in a bounded, never-root directory so
|
||||
// Claude's discovery cannot walk a whole filesystem — reject a root-like user
|
||||
// data path and scope to tmpdir instead (see runaway-cpu-hidden-usage-pty-design.md).
|
||||
function resolveUserDataRoot(userDataPath?: string | null): string {
|
||||
const root = userDataPath?.trim() || process.env.ORCA_USER_DATA_PATH?.trim()
|
||||
if (root && !isRootLikePath(root)) {
|
||||
return root
|
||||
}
|
||||
return join(tmpdir(), 'orca-rate-limit-pty')
|
||||
}
|
||||
|
||||
export function resolveHiddenRateLimitPtyCwd(options?: { userDataPath?: string | null }): string {
|
||||
const cwd = join(resolveUserDataRoot(options?.userDataPath), HIDDEN_RATE_LIMIT_PTY_CWD_DIR)
|
||||
mkdirSync(cwd, { recursive: true })
|
||||
const realCwd = realpathSync(cwd)
|
||||
if (isRootLikePath(realCwd) || !statSync(realCwd).isDirectory()) {
|
||||
throw new Error(`Hidden rate-limit PTY cwd is not a safe directory: ${realCwd}`)
|
||||
}
|
||||
return realCwd
|
||||
}
|
||||
|
||||
export function getHiddenRateLimitWslCwdSetupCommands(): string[] {
|
||||
return [
|
||||
`orca_rate_limit_cwd="\${TMPDIR:-/tmp}/${WSL_RATE_LIMIT_PTY_CWD_DIR}"`,
|
||||
'mkdir -p "$orca_rate_limit_cwd"',
|
||||
'cd "$orca_rate_limit_cwd"'
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted(
|
||||
() => {
|
||||
const netFetchMock = vi.fn()
|
||||
const cookiesSetMock = vi.fn(() => Promise.resolve())
|
||||
const clearStorageDataMock = vi.fn(() => Promise.resolve())
|
||||
const sessionFromPartitionMock = vi.fn(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock }
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
net: { fetch: netFetchMock },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
|
||||
import {
|
||||
extractMiniMaxCookieValue,
|
||||
fetchMiniMaxRateLimits,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret
|
||||
} from './minimax-fetcher'
|
||||
|
||||
const MINIMAX_URL = 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains'
|
||||
|
||||
function makeResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body
|
||||
} as Response
|
||||
}
|
||||
|
||||
function makeOkPayload(remainingPercent: number): unknown {
|
||||
const now = Date.now()
|
||||
return {
|
||||
base_resp: { status_code: 0, status_msg: 'ok' },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: remainingPercent,
|
||||
start_time: now - 60_000,
|
||||
end_time: now + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const FULL_COOKIE =
|
||||
'_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3'
|
||||
|
||||
function getCookieJarSetNames(): string[] {
|
||||
return cookiesSetMock.mock.calls.map((call) => {
|
||||
const [details] = call as unknown as [{ name: string }]
|
||||
return details.name
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchMiniMaxRateLimits', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-04T12:00:00.000Z'))
|
||||
clearStorageDataMock.mockClear()
|
||||
cookiesSetMock.mockClear()
|
||||
netFetchMock.mockReset()
|
||||
sessionFromPartitionMock.mockClear()
|
||||
sessionFromPartitionMock.mockImplementation(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns unavailable when cookie is empty', async () => {
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: '' })
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(result.provider).toBe('minimax')
|
||||
expect(result.session).toBeNull()
|
||||
expect(result.weekly).toBeNull()
|
||||
expect(result.error).toMatch(/not configured/i)
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns unavailable when cookie is only whitespace', async () => {
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: ' ' })
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when cookie has no _token', async () => {
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: '_twpid=tw.123; minimax_group_id_v2=12345'
|
||||
})
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toMatch(/MiniMax auth cookie not found/)
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies 401 as stale-token', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse({}, 401))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('stale-token')
|
||||
expect(result.error).toMatch(/session expired/i)
|
||||
})
|
||||
|
||||
it('classifies 403 as stale-token', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse({}, 403))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('stale-token')
|
||||
})
|
||||
|
||||
it('classifies 500 as server', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse({}, 500))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('server')
|
||||
expect(result.error).toMatch(/500/)
|
||||
})
|
||||
|
||||
it('aborts a hung fetch after the timeout and classifies it as network', async () => {
|
||||
netFetchMock.mockImplementation((_url: string, init: { signal: AbortSignal }) => {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const abort = (): void => {
|
||||
const error = new Error('The operation was aborted')
|
||||
error.name = 'AbortError'
|
||||
reject(error)
|
||||
}
|
||||
// The manual-cookie-header fallback receives the already-aborted signal.
|
||||
if (init.signal.aborted) {
|
||||
abort()
|
||||
return
|
||||
}
|
||||
init.signal.addEventListener('abort', abort)
|
||||
})
|
||||
})
|
||||
const promise = fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
const result = await promise
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('network')
|
||||
})
|
||||
|
||||
it('falls back to a manual Cookie header when the session cookie jar fetch throws', async () => {
|
||||
netFetchMock
|
||||
.mockRejectedValueOnce(new Error('cookie jar transport boom'))
|
||||
.mockResolvedValueOnce(makeResponse(makeOkPayload(70)))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(30)
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(2)
|
||||
// First (session-cookie-jar) transport sends no Cookie header; the fallback does.
|
||||
expect(netFetchMock.mock.calls[0][1].headers.Cookie).toBeUndefined()
|
||||
expect(netFetchMock.mock.calls[1][1].headers.Cookie).toContain('_token=')
|
||||
})
|
||||
|
||||
it('returns ok with session window mapping remaining to usedPercent', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(35)))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.provider).toBe('minimax')
|
||||
expect(result.session?.usedPercent).toBe(65)
|
||||
expect(result.session?.windowMinutes).toBe(300)
|
||||
expect(result.weekly).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a fixed 5-hour session window regardless of API interval drift', async () => {
|
||||
const startTime = 1_700_000_000_000
|
||||
const endTime = startTime + 295 * 60_000
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
base_resp: { status_code: 0, status_msg: 'ok' },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: 60,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
remains_time: endTime - startTime
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.windowMinutes).toBe(300)
|
||||
expect(result.session?.resetsAt).toBe(endTime)
|
||||
})
|
||||
|
||||
it('does not derive the windowMinutes label from the raw start/end interval', async () => {
|
||||
const startTime = 1_700_000_000_000
|
||||
const endTime = startTime + 4 * 60 * 60_000
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
base_resp: { status_code: 0, status_msg: 'ok' },
|
||||
model_remains: [
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: 63,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
remains_time: endTime - startTime
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.windowMinutes).toBe(300)
|
||||
expect(result.session?.usedPercent).toBe(37)
|
||||
expect(result.session?.resetsAt).toBe(endTime)
|
||||
})
|
||||
|
||||
it('sets the cookie jar and sends browser-like request headers', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledWith({
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
expect(getCookieJarSetNames()).toEqual([
|
||||
'_token',
|
||||
'_twpid',
|
||||
'minimax_group_id_v2',
|
||||
'platform_cookie_consent'
|
||||
])
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = netFetchMock.mock.calls[0]
|
||||
expect(url).toBe(MINIMAX_URL)
|
||||
expect(init.method).toBe('GET')
|
||||
expect(init.headers.Cookie).toBeUndefined()
|
||||
expect(init.headers['X-Group-Id']).toBe('12345')
|
||||
expect(init.headers.Referer).toBe('https://platform.minimax.io/console/usage')
|
||||
expect(init.headers.Accept).toMatch(/application\/json/)
|
||||
expect(init.headers['Accept-Language']).toBe('en-US,en;q=0.9')
|
||||
expect(init.headers['Sec-Fetch-Dest']).toBeUndefined()
|
||||
expect(init.headers['Sec-Fetch-Mode']).toBeUndefined()
|
||||
expect(init.headers['Sec-Fetch-Site']).toBeUndefined()
|
||||
expect(init.headers['User-Agent']).toMatch(/^Mozilla\/5\.0/)
|
||||
expect(init.headers['User-Agent']).not.toContain('orca-minimax-usage')
|
||||
})
|
||||
|
||||
it('accepts quoted MiniMax cookie storage syntax', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({
|
||||
cookie: '_token:"jwt-token" minimax_group_id_v2:"42"'
|
||||
})
|
||||
const [, init] = netFetchMock.mock.calls[0]
|
||||
expect(getCookieJarSetNames()).toEqual(['_token', 'minimax_group_id_v2'])
|
||||
expect(init.headers.Cookie).toBeUndefined()
|
||||
expect(init.headers['X-Group-Id']).toBe('42')
|
||||
})
|
||||
|
||||
it('preserves the complete browser Cookie header', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
const fullBrowserCookie = [
|
||||
'platform_cookie_consent=3',
|
||||
'_ga=analytics',
|
||||
'_token=tok',
|
||||
'_twpid=tw.1',
|
||||
'ak_bmsc=ak',
|
||||
'bm_sv=sv',
|
||||
'bm_sz=sz',
|
||||
'_abck=ab',
|
||||
'minimax_group_id_v2=42',
|
||||
'sensorsdata2015jssdkcross=analytics'
|
||||
].join('; ')
|
||||
await fetchMiniMaxRateLimits({
|
||||
cookie: fullBrowserCookie
|
||||
})
|
||||
const [, init] = netFetchMock.mock.calls[0]
|
||||
expect(getCookieJarSetNames()).toEqual([
|
||||
'platform_cookie_consent',
|
||||
'_ga',
|
||||
'_token',
|
||||
'_twpid',
|
||||
'ak_bmsc',
|
||||
'bm_sv',
|
||||
'bm_sz',
|
||||
'_abck',
|
||||
'minimax_group_id_v2',
|
||||
'sensorsdata2015jssdkcross'
|
||||
])
|
||||
expect(init.headers.Cookie).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers explicit groupId over cookie value', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: 'override-id' })
|
||||
expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('override-id')
|
||||
})
|
||||
|
||||
it('falls back to cookie minimax_group_id_v2 when groupId is empty', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(80)))
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE, groupId: '' })
|
||||
expect(netFetchMock.mock.calls[0][1].headers['X-Group-Id']).toBe('12345')
|
||||
})
|
||||
|
||||
it('surfaces base_resp status_code != 0 as usage-unavailable', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 401, status_msg: 'unauth' } })
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toContain('unauth')
|
||||
})
|
||||
|
||||
it('classifies malformed MiniMax JSON responses as parse failures', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token <')
|
||||
}
|
||||
} as unknown as Response)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('parse')
|
||||
expect(result.error).toContain('Unexpected token')
|
||||
})
|
||||
|
||||
it('returns error when model_remains is empty', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.usageMetadata?.failureKind).toBe('usage-unavailable')
|
||||
expect(result.error).toMatch(/configured model was not found/i)
|
||||
})
|
||||
|
||||
it('returns error when configured model is not in response', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
)
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: FULL_COOKIE,
|
||||
models: 'unrelated-model'
|
||||
})
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toMatch(/configured model was not found/i)
|
||||
})
|
||||
|
||||
it('falls back to the lone snapshot when no configured model matches', async () => {
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(makeOkPayload(40)))
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: FULL_COOKIE,
|
||||
models: 'unrelated-model'
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(60)
|
||||
})
|
||||
|
||||
it('selects the first configured model when multiple are listed', async () => {
|
||||
const payload = makeOkPayload(40)
|
||||
;(payload as { model_remains: unknown[] }).model_remains = [
|
||||
{
|
||||
model_name: 'unrelated',
|
||||
current_interval_remaining_percent: 10,
|
||||
start_time: Date.now() - 60_000,
|
||||
end_time: Date.now() + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
},
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: 40,
|
||||
start_time: Date.now() - 60_000,
|
||||
end_time: Date.now() + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
}
|
||||
]
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(payload))
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: FULL_COOKIE,
|
||||
models: 'general'
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(60)
|
||||
})
|
||||
|
||||
it('treats a blank model list as the default general model', async () => {
|
||||
const payload = makeOkPayload(25)
|
||||
;(payload as { model_remains: unknown[] }).model_remains = [
|
||||
{
|
||||
model_name: 'unrelated',
|
||||
current_interval_remaining_percent: 10,
|
||||
start_time: Date.now() - 60_000,
|
||||
end_time: Date.now() + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
},
|
||||
{
|
||||
model_name: 'general',
|
||||
current_interval_remaining_percent: 25,
|
||||
start_time: Date.now() - 60_000,
|
||||
end_time: Date.now() + 5 * 60 * 60 * 1000,
|
||||
remains_time: 5 * 60 * 60 * 1000
|
||||
}
|
||||
]
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(payload))
|
||||
const result = await fetchMiniMaxRateLimits({
|
||||
cookie: FULL_COOKIE,
|
||||
models: ' '
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
expect(result.session?.usedPercent).toBe(75)
|
||||
})
|
||||
|
||||
it('redacts _token in any error path that includes payload text', async () => {
|
||||
const payload = { base_resp: { status_code: 7, status_msg: FULL_COOKIE } }
|
||||
netFetchMock.mockResolvedValueOnce(makeResponse(payload))
|
||||
const result = await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(result.error).not.toContain('eyJh')
|
||||
expect(result.error).not.toContain('minimax_group_id_v2=12345')
|
||||
expect(result.error).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
it('logs MiniMax failures with cookie names but without cookie values', async () => {
|
||||
const warn = vi.mocked(console.warn)
|
||||
netFetchMock.mockResolvedValueOnce(
|
||||
makeResponse({ base_resp: { status_code: 7, status_msg: FULL_COOKIE } })
|
||||
)
|
||||
await fetchMiniMaxRateLimits({ cookie: FULL_COOKIE })
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[minimax] usage fetch failed',
|
||||
expect.objectContaining({
|
||||
baseRespStatusMsg: expect.not.stringContaining('eyJh'),
|
||||
cookieNames: ['_token', '_twpid', 'minimax_group_id_v2', 'platform_cookie_consent']
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeMiniMaxCookieHeader', () => {
|
||||
it('preserves all cookie pairs from a browser Cookie header', () => {
|
||||
const normalized = normalizeMiniMaxCookieHeader(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
expect(normalized).toBe(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes quoted MiniMax cookie storage syntax', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe(
|
||||
'_token=tok; minimax_group_id_v2=42'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a copied Cookie header line', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe(
|
||||
'session=abc; other=xyz'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractMiniMaxCookieValue', () => {
|
||||
it('returns the value for a given cookie name', () => {
|
||||
expect(extractMiniMaxCookieValue(FULL_COOKIE, 'minimax_group_id_v2')).toBe('12345')
|
||||
})
|
||||
it('returns null when the name is absent', () => {
|
||||
expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactMiniMaxSecret', () => {
|
||||
it('redacts _token values', () => {
|
||||
expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).toContain('_token=[REDACTED]')
|
||||
expect(redactMiniMaxSecret('cookie _token=eyJhABCDEF')).not.toContain('eyJhABCDEF')
|
||||
})
|
||||
it('redacts minimax_group_id_v2 values', () => {
|
||||
expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999')
|
||||
})
|
||||
it('redacts MiniMax anti-bot cookie values', () => {
|
||||
const redacted = redactMiniMaxSecret('ak_bmsc=secret bm_sv:"secret2"')
|
||||
expect(redacted).not.toContain('secret')
|
||||
expect(redacted).toContain('ak_bmsc=[REDACTED]')
|
||||
expect(redacted).toContain('bm_sv:[REDACTED]')
|
||||
})
|
||||
it('redacts Cookie: header lines', () => {
|
||||
expect(redactMiniMaxSecret('X-Cookie: _token=secret')).toContain('[REDACTED]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
|
||||
import {
|
||||
extractMiniMaxCookieValue,
|
||||
fetchMiniMaxWithManualCookieHeader,
|
||||
fetchMiniMaxWithSessionCookieJar,
|
||||
getUniqueMiniMaxCookieNames,
|
||||
logMiniMaxFetchFailure,
|
||||
makeMiniMaxRequestHeaders,
|
||||
MINIMAX_USAGE_ENDPOINT,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret,
|
||||
type MiniMaxFetchResponse
|
||||
} from './minimax-request-context'
|
||||
|
||||
export {
|
||||
extractMiniMaxCookieValue,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret
|
||||
} from './minimax-request-context'
|
||||
|
||||
const API_TIMEOUT_MS = 15_000
|
||||
|
||||
type MiniMaxUsageItem = {
|
||||
model_name?: unknown
|
||||
current_interval_remaining_percent?: unknown
|
||||
start_time?: unknown
|
||||
end_time?: unknown
|
||||
remains_time?: unknown
|
||||
}
|
||||
|
||||
type MiniMaxUsageResponse = {
|
||||
base_resp?: {
|
||||
status_code?: unknown
|
||||
status_msg?: unknown
|
||||
}
|
||||
model_remains?: MiniMaxUsageItem[]
|
||||
}
|
||||
|
||||
type MiniMaxUsageSnapshot = {
|
||||
modelName: string
|
||||
window: RateLimitWindow
|
||||
}
|
||||
|
||||
export type FetchMiniMaxRateLimitsOptions = {
|
||||
cookie: string
|
||||
groupId?: string | null
|
||||
models?: string | readonly string[] | null
|
||||
endpoint?: string
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(value)))
|
||||
}
|
||||
|
||||
function makeUnavailable(error: string): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'minimax',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error,
|
||||
status: 'unavailable',
|
||||
usageMetadata: { failureKind: 'missing-credentials', source: 'web' }
|
||||
}
|
||||
}
|
||||
|
||||
function makeError(
|
||||
error: string,
|
||||
failureKind: NonNullable<ProviderRateLimits['usageMetadata']>['failureKind']
|
||||
): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'minimax',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error,
|
||||
status: 'error',
|
||||
usageMetadata: { failureKind, source: 'web' }
|
||||
}
|
||||
}
|
||||
|
||||
function parseModels(models: FetchMiniMaxRateLimitsOptions['models']): string[] {
|
||||
if (Array.isArray(models)) {
|
||||
const parsed = models.map((model) => model.trim()).filter(Boolean)
|
||||
return parsed.length > 0 ? parsed : ['general']
|
||||
}
|
||||
if (typeof models === 'string') {
|
||||
const parsed = models
|
||||
.split(',')
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean)
|
||||
return parsed.length > 0 ? parsed : ['general']
|
||||
}
|
||||
return ['general']
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: MiniMax's API returns `end_time - start_time` that can drift below the
|
||||
// 5-hour bucket (e.g. 4h or 295 min). The UI labels must reflect the contracted
|
||||
// session — a fixed 5-hour window — so the status bar reads "5h" regardless of
|
||||
// what the API reports. Mirrors how Codex always reports 300/10080 minutes.
|
||||
const MINIMAX_SESSION_WINDOW_MINUTES = 300
|
||||
|
||||
function parseUsageItem(item: MiniMaxUsageItem): MiniMaxUsageSnapshot | null {
|
||||
const modelName = typeof item.model_name === 'string' ? item.model_name : null
|
||||
const remainingPercent = asNumber(item.current_interval_remaining_percent)
|
||||
const startTime = asNumber(item.start_time)
|
||||
const endTime = asNumber(item.end_time)
|
||||
if (!modelName || remainingPercent === null || startTime === null || endTime === null) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
modelName,
|
||||
window: {
|
||||
usedPercent: clampPercent(100 - remainingPercent),
|
||||
windowMinutes: MINIMAX_SESSION_WINDOW_MINUTES,
|
||||
resetsAt: endTime,
|
||||
resetDescription: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectSnapshot(
|
||||
snapshots: MiniMaxUsageSnapshot[],
|
||||
preferredModels: string[]
|
||||
): MiniMaxUsageSnapshot | null {
|
||||
for (const model of preferredModels) {
|
||||
const match = snapshots.find((snapshot) => snapshot.modelName === model)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return snapshots.length === 1 ? snapshots[0] : null
|
||||
}
|
||||
|
||||
async function fetchMiniMaxResponse(args: {
|
||||
cookie: string
|
||||
endpoint: string
|
||||
groupId: string | null
|
||||
signal: AbortSignal
|
||||
}): Promise<MiniMaxFetchResponse> {
|
||||
try {
|
||||
return await fetchMiniMaxWithSessionCookieJar(args)
|
||||
} catch (sessionFetchError) {
|
||||
const message =
|
||||
sessionFetchError instanceof Error ? sessionFetchError.message : String(sessionFetchError)
|
||||
console.warn(
|
||||
'[minimax] session cookie jar fetch failed; falling back to manual Cookie header',
|
||||
{
|
||||
error: redactMiniMaxSecret(message),
|
||||
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
|
||||
requestHeaderNames: Object.keys(makeMiniMaxRequestHeaders(args.groupId))
|
||||
}
|
||||
)
|
||||
return await fetchMiniMaxWithManualCookieHeader(args)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMiniMaxHttpError(fetchResult: MiniMaxFetchResponse): ProviderRateLimits | null {
|
||||
const { response } = fetchResult
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: fetchResult.transport,
|
||||
responseStatus: response.status,
|
||||
cookieNames: fetchResult.cookieNames,
|
||||
requestHeaderNames: fetchResult.requestHeaderNames
|
||||
})
|
||||
return makeError(
|
||||
'MiniMax session expired. Replace the MiniMax cookie in Settings.',
|
||||
'stale-token'
|
||||
)
|
||||
}
|
||||
if (!response.ok) {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: fetchResult.transport,
|
||||
responseStatus: response.status,
|
||||
cookieNames: fetchResult.cookieNames,
|
||||
requestHeaderNames: fetchResult.requestHeaderNames
|
||||
})
|
||||
return makeError(`MiniMax usage fetch failed (${response.status})`, 'server')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function handleMiniMaxPayloadError(
|
||||
fetchResult: MiniMaxFetchResponse,
|
||||
payload: MiniMaxUsageResponse
|
||||
): ProviderRateLimits | null {
|
||||
const statusCode = payload.base_resp?.status_code
|
||||
if (statusCode === undefined || statusCode === 0) {
|
||||
return null
|
||||
}
|
||||
logMiniMaxFetchFailure({
|
||||
transport: fetchResult.transport,
|
||||
responseStatus: fetchResult.response.status,
|
||||
statusCode,
|
||||
statusMsg: payload.base_resp?.status_msg,
|
||||
cookieNames: fetchResult.cookieNames,
|
||||
requestHeaderNames: fetchResult.requestHeaderNames
|
||||
})
|
||||
const message =
|
||||
typeof payload.base_resp?.status_msg === 'string'
|
||||
? payload.base_resp.status_msg
|
||||
: 'MiniMax returned an error'
|
||||
return makeError(redactMiniMaxSecret(message), 'usage-unavailable')
|
||||
}
|
||||
|
||||
export async function fetchMiniMaxRateLimits(
|
||||
options: FetchMiniMaxRateLimitsOptions
|
||||
): Promise<ProviderRateLimits> {
|
||||
const rawCookie = options.cookie.trim()
|
||||
if (!rawCookie) {
|
||||
return makeUnavailable('MiniMax session cookie not configured')
|
||||
}
|
||||
const cookie = normalizeMiniMaxCookieHeader(rawCookie)
|
||||
if (!extractMiniMaxCookieValue(cookie, '_token')) {
|
||||
return makeError(
|
||||
'MiniMax auth cookie not found — paste a Cookie header with _token',
|
||||
'missing-credentials'
|
||||
)
|
||||
}
|
||||
const groupId =
|
||||
options.groupId?.trim() || extractMiniMaxCookieValue(cookie, 'minimax_group_id_v2')
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
|
||||
try {
|
||||
const fetchResult = await fetchMiniMaxResponse({
|
||||
cookie,
|
||||
endpoint: options.endpoint ?? MINIMAX_USAGE_ENDPOINT,
|
||||
groupId,
|
||||
signal: controller.signal
|
||||
})
|
||||
const httpError = handleMiniMaxHttpError(fetchResult)
|
||||
if (httpError) {
|
||||
return httpError
|
||||
}
|
||||
let payload: MiniMaxUsageResponse
|
||||
try {
|
||||
payload = (await fetchResult.response.json()) as MiniMaxUsageResponse
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid MiniMax usage response'
|
||||
return makeError(redactMiniMaxSecret(message), 'parse')
|
||||
}
|
||||
const payloadError = handleMiniMaxPayloadError(fetchResult, payload)
|
||||
if (payloadError) {
|
||||
return payloadError
|
||||
}
|
||||
const snapshots = (payload.model_remains ?? [])
|
||||
.map(parseUsageItem)
|
||||
.filter((snapshot): snapshot is MiniMaxUsageSnapshot => snapshot !== null)
|
||||
const selected = selectSnapshot(snapshots, parseModels(options.models))
|
||||
if (!selected) {
|
||||
return makeError(
|
||||
'MiniMax usage data for the configured model was not found',
|
||||
'usage-unavailable'
|
||||
)
|
||||
}
|
||||
return {
|
||||
provider: 'minimax',
|
||||
session: selected.window,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: null,
|
||||
status: 'ok',
|
||||
usageMetadata: { source: 'web' }
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown MiniMax usage error'
|
||||
return makeError(redactMiniMaxSecret(message), 'network')
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock } = vi.hoisted(
|
||||
() => {
|
||||
const netFetchMock = vi.fn()
|
||||
const cookiesSetMock = vi.fn(() => Promise.resolve())
|
||||
const clearStorageDataMock = vi.fn(() => Promise.resolve())
|
||||
const sessionFromPartitionMock = vi.fn(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
return { clearStorageDataMock, cookiesSetMock, netFetchMock, sessionFromPartitionMock }
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
net: { fetch: netFetchMock },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
|
||||
import {
|
||||
clearMiniMaxSessionCookieJar,
|
||||
extractMiniMaxCookieValue,
|
||||
fetchMiniMaxWithManualCookieHeader,
|
||||
fetchMiniMaxWithSessionCookieJar,
|
||||
getUniqueMiniMaxCookieNames,
|
||||
logMiniMaxFetchFailure,
|
||||
makeMiniMaxRequestHeaders,
|
||||
MINIMAX_USAGE_ENDPOINT,
|
||||
normalizeMiniMaxCookieHeader,
|
||||
redactMiniMaxSecret
|
||||
} from './minimax-request-context'
|
||||
|
||||
const FULL_COOKIE =
|
||||
'_token=eyJh.eyJ.payload; _twpid=tw.123; minimax_group_id_v2=12345; platform_cookie_consent=3'
|
||||
|
||||
describe('normalizeMiniMaxCookieHeader', () => {
|
||||
it('preserves all cookie pairs from a browser Cookie header', () => {
|
||||
const normalized = normalizeMiniMaxCookieHeader(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
expect(normalized).toBe(
|
||||
'_token=tok; session=other; ak_bmsc=ak; minimax_group_id_v2=42; random=xyz'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes quoted MiniMax cookie storage syntax', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('_token:"tok" minimax_group_id_v2:"42"')).toBe(
|
||||
'_token=tok; minimax_group_id_v2=42'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a copied Cookie header line with prefix', () => {
|
||||
expect(normalizeMiniMaxCookieHeader('Cookie: session=abc; other=xyz')).toBe(
|
||||
'session=abc; other=xyz'
|
||||
)
|
||||
})
|
||||
|
||||
it('merges header and quoted forms without dropping any pair', () => {
|
||||
const mixed = '_token=header-quoted _twpid:"q-1"; minimax_group_id_v2=42'
|
||||
const normalized = normalizeMiniMaxCookieHeader(mixed)
|
||||
expect(normalized).toContain('_token=header-quoted')
|
||||
expect(normalized).toContain('_twpid=q-1')
|
||||
expect(normalized).toContain('minimax_group_id_v2=42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractMiniMaxCookieValue', () => {
|
||||
it('returns the value for a known name', () => {
|
||||
expect(extractMiniMaxCookieValue(FULL_COOKIE, '_token')).toBe('eyJh.eyJ.payload')
|
||||
})
|
||||
|
||||
it('returns null when the name is absent', () => {
|
||||
expect(extractMiniMaxCookieValue('_token=tok', 'minimax_group_id_v2')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles Chromium quoted syntax', () => {
|
||||
expect(
|
||||
extractMiniMaxCookieValue('_token:"jwt" minimax_group_id_v2:"42"', 'minimax_group_id_v2')
|
||||
).toBe('42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUniqueMiniMaxCookieNames', () => {
|
||||
it('deduplicates repeated names', () => {
|
||||
const names = getUniqueMiniMaxCookieNames(
|
||||
'_token=a; _token=b; minimax_group_id_v2=42; minimax_group_id_v2=99'
|
||||
)
|
||||
expect(names).toEqual(['_token', 'minimax_group_id_v2'])
|
||||
})
|
||||
|
||||
it('returns names from a quoted-only Cookie storage export', () => {
|
||||
const names = getUniqueMiniMaxCookieNames(
|
||||
'_token:"jwt" minimax_group_id_v2:"42" platform_cookie_consent:"3"'
|
||||
)
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining(['_token', 'minimax_group_id_v2', 'platform_cookie_consent'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactMiniMaxSecret', () => {
|
||||
it('redacts _token values in header and quoted syntax', () => {
|
||||
const redacted = redactMiniMaxSecret('cookie _token=eyJhABCDEF and _token:"x.y"')
|
||||
expect(redacted).toContain('_token=[REDACTED]')
|
||||
expect(redacted).not.toContain('eyJhABCDEF')
|
||||
expect(redacted).not.toContain('x.y')
|
||||
})
|
||||
|
||||
it('redacts quoted values with whitespace around the colon', () => {
|
||||
const redacted = redactMiniMaxSecret('_token : "spaced.secret"')
|
||||
expect(redacted).not.toContain('spaced.secret')
|
||||
expect(redacted).toContain('_token:[REDACTED]')
|
||||
})
|
||||
|
||||
it('redacts minimax_group_id_v2', () => {
|
||||
expect(redactMiniMaxSecret('minimax_group_id_v2=99999 trailing')).not.toContain('99999')
|
||||
})
|
||||
|
||||
it('redacts MiniMax anti-bot cookie values', () => {
|
||||
const redacted = redactMiniMaxSecret('ak_bmsc=zzzsecret bm_sv:"yyyvalue" _abck="xxxdata"')
|
||||
expect(redacted).not.toContain('zzzsecret')
|
||||
expect(redacted).not.toContain('yyyvalue')
|
||||
expect(redacted).not.toContain('xxxdata')
|
||||
expect(redacted).toContain('ak_bmsc=[REDACTED]')
|
||||
expect(redacted).toContain('bm_sv:[REDACTED]')
|
||||
expect(redacted).toContain('_abck=[REDACTED]')
|
||||
})
|
||||
|
||||
it('redacts full Cookie: header lines', () => {
|
||||
const redacted = redactMiniMaxSecret('Header line\nCookie: _token=secret\nFooter')
|
||||
expect(redacted).toContain('Cookie: [REDACTED]')
|
||||
expect(redacted).not.toContain('Cookie: _token=secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeMiniMaxRequestHeaders', () => {
|
||||
it('always includes browser-like Accept, Accept-Language, Referer, and User-Agent', () => {
|
||||
const headers = makeMiniMaxRequestHeaders(null)
|
||||
expect(headers.Accept).toMatch(/application\/json/)
|
||||
expect(headers['Accept-Language']).toBe('en-US,en;q=0.9')
|
||||
expect(headers.Referer).toBe('https://platform.minimax.io/console/usage')
|
||||
expect(headers['User-Agent']).toMatch(/^Mozilla\/5\.0/)
|
||||
expect(headers['User-Agent']).not.toContain('orca-minimax-usage')
|
||||
})
|
||||
|
||||
it('omits X-Group-Id when groupId is null', () => {
|
||||
const headers = makeMiniMaxRequestHeaders(null)
|
||||
expect(headers['X-Group-Id']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits X-Group-Id when groupId is empty string', () => {
|
||||
const headers = makeMiniMaxRequestHeaders('')
|
||||
expect(headers['X-Group-Id']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes X-Group-Id when groupId is provided', () => {
|
||||
const headers = makeMiniMaxRequestHeaders('2034972027806299092')
|
||||
expect(headers['X-Group-Id']).toBe('2034972027806299092')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMiniMaxWithSessionCookieJar', () => {
|
||||
beforeEach(() => {
|
||||
clearStorageDataMock.mockClear()
|
||||
cookiesSetMock.mockClear()
|
||||
netFetchMock.mockReset()
|
||||
sessionFromPartitionMock.mockClear()
|
||||
sessionFromPartitionMock.mockImplementation(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses a dedicated MiniMax partition and clears cookies before and after fetching', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await fetchMiniMaxWithSessionCookieJar({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
|
||||
expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, {
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, {
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
})
|
||||
|
||||
it('attempts the final session cleanup when the pre-fetch clear rejects', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
clearStorageDataMock
|
||||
.mockRejectedValueOnce(new Error('pre-clear boom'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(
|
||||
fetchMiniMaxWithSessionCookieJar({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
).rejects.toThrow('pre-clear boom')
|
||||
|
||||
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
|
||||
expect(clearStorageDataMock).toHaveBeenNthCalledWith(1, {
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
expect(clearStorageDataMock).toHaveBeenNthCalledWith(2, {
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the dedicated MiniMax partition on demand', async () => {
|
||||
await clearMiniMaxSessionCookieJar()
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledWith({
|
||||
origin: 'https://platform.minimax.io',
|
||||
storages: ['cookies']
|
||||
})
|
||||
})
|
||||
|
||||
it('sets every cookie pair onto the session jar with secure + path /', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await fetchMiniMaxWithSessionCookieJar({
|
||||
cookie: '_token=tok; ak_bmsc=ak; minimax_group_id_v2=42',
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: null,
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(cookiesSetMock).toHaveBeenCalledTimes(3)
|
||||
const setDetails = cookiesSetMock.mock.calls.map((call) => {
|
||||
const [details] = call as unknown as [
|
||||
{ name: string; value: string; secure: boolean; path: string }
|
||||
]
|
||||
return details
|
||||
})
|
||||
expect(setDetails).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: '_token', value: 'tok', secure: true, path: '/' }),
|
||||
expect.objectContaining({ name: 'ak_bmsc', value: 'ak', secure: true, path: '/' }),
|
||||
expect.objectContaining({
|
||||
name: 'minimax_group_id_v2',
|
||||
value: '42',
|
||||
secure: true,
|
||||
path: '/'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the transport name as session-cookie-jar on success', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const result = await fetchMiniMaxWithSessionCookieJar({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(result.transport).toBe('session-cookie-jar')
|
||||
expect(result.cookieNames).toEqual([
|
||||
'_token',
|
||||
'_twpid',
|
||||
'minimax_group_id_v2',
|
||||
'platform_cookie_consent'
|
||||
])
|
||||
expect(result.requestHeaderNames).toContain('X-Group-Id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchMiniMaxWithManualCookieHeader', () => {
|
||||
beforeEach(() => {
|
||||
clearStorageDataMock.mockClear()
|
||||
cookiesSetMock.mockClear()
|
||||
netFetchMock.mockReset()
|
||||
sessionFromPartitionMock.mockClear()
|
||||
sessionFromPartitionMock.mockImplementation(() => ({
|
||||
clearStorageData: clearStorageDataMock,
|
||||
cookies: { set: cookiesSetMock },
|
||||
fetch: netFetchMock
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('attaches the full Cookie header and X-Group-Id via the dedicated partition fetch', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const result = await fetchMiniMaxWithManualCookieHeader({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
expect(result.transport).toBe('manual-cookie-header')
|
||||
expect(sessionFromPartitionMock).toHaveBeenCalledWith('orca-minimax-rate-limit-fetch')
|
||||
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = netFetchMock.mock.calls[0]
|
||||
expect(url).toBe(MINIMAX_USAGE_ENDPOINT)
|
||||
expect(init.method).toBe('GET')
|
||||
expect(init.headers.Cookie).toBe(FULL_COOKIE)
|
||||
expect(init.headers['X-Group-Id']).toBe('12345')
|
||||
})
|
||||
|
||||
it('omits X-Group-Id when groupId is null', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await fetchMiniMaxWithManualCookieHeader({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: null,
|
||||
signal: controller.signal
|
||||
})
|
||||
const [, init] = netFetchMock.mock.calls[0]
|
||||
expect(init.headers['X-Group-Id']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('normalizes copied Cookie prefix and quoted syntax before sending manual header', async () => {
|
||||
netFetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ base_resp: { status_code: 0 }, model_remains: [] })
|
||||
})
|
||||
const controller = new AbortController()
|
||||
await fetchMiniMaxWithManualCookieHeader({
|
||||
cookie: 'Cookie: _token=tok; minimax_group_id_v2=42; _twpid:"tw"',
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: null,
|
||||
signal: controller.signal
|
||||
})
|
||||
const [, init] = netFetchMock.mock.calls[0]
|
||||
expect(init.headers.Cookie).toBe('_token=tok; minimax_group_id_v2=42; _twpid=tw')
|
||||
})
|
||||
|
||||
it('attempts the final cleanup when the manual fallback pre-fetch clear rejects', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
clearStorageDataMock
|
||||
.mockRejectedValueOnce(new Error('manual pre-clear boom'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(
|
||||
fetchMiniMaxWithManualCookieHeader({
|
||||
cookie: FULL_COOKIE,
|
||||
endpoint: MINIMAX_USAGE_ENDPOINT,
|
||||
groupId: '12345',
|
||||
signal: controller.signal
|
||||
})
|
||||
).rejects.toThrow('manual pre-clear boom')
|
||||
|
||||
expect(clearStorageDataMock).toHaveBeenCalledTimes(2)
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('logMiniMaxFetchFailure', () => {
|
||||
let warn: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('logs structured fields without leaking cookie values', () => {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: 'session-cookie-jar',
|
||||
responseStatus: 200,
|
||||
statusCode: 7,
|
||||
statusMsg: '_token=eyJhABCDEF ak_bmsc=secretvalue',
|
||||
cookieNames: ['_token', 'ak_bmsc'],
|
||||
requestHeaderNames: ['Accept', 'X-Group-Id']
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[minimax] usage fetch failed',
|
||||
expect.objectContaining({
|
||||
transport: 'session-cookie-jar',
|
||||
responseStatus: 200,
|
||||
baseRespStatusCode: 7,
|
||||
baseRespStatusMsg: expect.not.stringContaining('eyJhABCDEF'),
|
||||
cookieNames: ['_token', 'ak_bmsc'],
|
||||
requestHeaderNames: ['Accept', 'X-Group-Id']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('logs without redacting when statusMsg is not a string', () => {
|
||||
logMiniMaxFetchFailure({
|
||||
transport: 'manual-cookie-header',
|
||||
responseStatus: 401,
|
||||
statusCode: undefined,
|
||||
statusMsg: undefined,
|
||||
cookieNames: ['_token'],
|
||||
requestHeaderNames: ['Accept']
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[minimax] usage fetch failed',
|
||||
expect.objectContaining({
|
||||
transport: 'manual-cookie-header',
|
||||
responseStatus: 401,
|
||||
baseRespStatusCode: undefined,
|
||||
baseRespStatusMsg: undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import { session, type Session } from 'electron'
|
||||
|
||||
export const MINIMAX_USAGE_ENDPOINT =
|
||||
'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains'
|
||||
|
||||
const MINIMAX_ORIGIN = 'https://platform.minimax.io'
|
||||
const MINIMAX_REFERER = 'https://platform.minimax.io/console/usage'
|
||||
const MINIMAX_SESSION_PARTITION = 'orca-minimax-rate-limit-fetch'
|
||||
const SENSITIVE_COOKIE_NAMES = new Set([
|
||||
'_token',
|
||||
'_twpid',
|
||||
'_abck',
|
||||
'ak_bmsc',
|
||||
'bm_mi',
|
||||
'bm_sv',
|
||||
'bm_sz',
|
||||
'minimax_group_id_v2'
|
||||
])
|
||||
|
||||
export type MiniMaxFetchTransport = 'session-cookie-jar' | 'manual-cookie-header'
|
||||
|
||||
export type MiniMaxFetchResponse = {
|
||||
response: Response
|
||||
requestHeaderNames: string[]
|
||||
cookieNames: string[]
|
||||
transport: MiniMaxFetchTransport
|
||||
}
|
||||
|
||||
// Why: MiniMax's usage endpoint rejects non-browser clients, so we send a real
|
||||
// per-platform Firefox UA instead of a custom agent string. Don't "clean up".
|
||||
function getMiniMaxBrowserUserAgent(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0'
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0'
|
||||
}
|
||||
return 'Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0'
|
||||
}
|
||||
|
||||
function parseCookiePairs(cookie: string): { name: string; value: string }[] {
|
||||
const headerPairs = cookie
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.map((part) => {
|
||||
const normalizedPart = part.replace(/^Cookie:\s*/i, '')
|
||||
const eq = normalizedPart.indexOf('=')
|
||||
if (eq < 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
name: normalizedPart.slice(0, eq).trim(),
|
||||
value: normalizedPart.slice(eq + 1).trim()
|
||||
}
|
||||
})
|
||||
.filter((pair): pair is { name: string; value: string } => Boolean(pair?.name && pair.value))
|
||||
// Why: Chromium cookie storage exports are often copied as `name:"value"`,
|
||||
// not as an HTTP `Cookie` header. Accept both formats to avoid credential UX traps.
|
||||
const quotedCookiePairPattern = /(?:^|[;\s])([A-Za-z0-9_.-]+)\s*:\s*["']([^"']+)["']/g
|
||||
const quotedPairs = [...cookie.matchAll(quotedCookiePairPattern)]
|
||||
.map((match) => {
|
||||
const [, name = '', value = ''] = match
|
||||
return { name: name.trim(), value: value.trim() }
|
||||
})
|
||||
.filter((pair) => pair.name && pair.value)
|
||||
return [...headerPairs, ...quotedPairs]
|
||||
}
|
||||
|
||||
export function extractMiniMaxCookieValue(cookie: string, name: string): string | null {
|
||||
return parseCookiePairs(cookie).find((pair) => pair.name === name)?.value ?? null
|
||||
}
|
||||
|
||||
export function normalizeMiniMaxCookieHeader(cookie: string): string {
|
||||
return parseCookiePairs(cookie)
|
||||
.map((pair) => `${pair.name}=${pair.value}`)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
export function getUniqueMiniMaxCookieNames(cookie: string): string[] {
|
||||
return [...new Set(parseCookiePairs(cookie).map((pair) => pair.name))]
|
||||
}
|
||||
|
||||
export function redactMiniMaxSecret(value: string): string {
|
||||
let redacted = value.replace(/Cookie:\s*[^\n\r]+/gi, 'Cookie: [REDACTED]')
|
||||
for (const name of SENSITIVE_COOKIE_NAMES) {
|
||||
redacted = redacted
|
||||
.replace(new RegExp(`${name}=([^;\\s]+)`, 'g'), `${name}=[REDACTED]`)
|
||||
// Match parseCookiePairs' `\s*:\s*` tolerance so `name : "secret"` is redacted too.
|
||||
.replace(new RegExp(`${name}\\s*:\\s*["'][^"']+["']`, 'g'), `${name}:[REDACTED]`)
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
export function makeMiniMaxRequestHeaders(groupId: string | null): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
Referer: MINIMAX_REFERER,
|
||||
'User-Agent': getMiniMaxBrowserUserAgent()
|
||||
}
|
||||
if (groupId) {
|
||||
headers['X-Group-Id'] = groupId
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
async function clearMiniMaxSessionCookieJarForSession(miniMaxSession: Session): Promise<void> {
|
||||
await miniMaxSession.clearStorageData({ origin: MINIMAX_ORIGIN, storages: ['cookies'] })
|
||||
}
|
||||
|
||||
export async function clearMiniMaxSessionCookieJar(): Promise<void> {
|
||||
await clearMiniMaxSessionCookieJarForSession(session.fromPartition(MINIMAX_SESSION_PARTITION))
|
||||
}
|
||||
|
||||
export async function fetchMiniMaxWithSessionCookieJar(args: {
|
||||
cookie: string
|
||||
endpoint: string
|
||||
groupId: string | null
|
||||
signal: AbortSignal
|
||||
}): Promise<MiniMaxFetchResponse> {
|
||||
const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
|
||||
const cookiePairs = parseCookiePairs(args.cookie)
|
||||
try {
|
||||
await clearMiniMaxSessionCookieJarForSession(miniMaxSession)
|
||||
await Promise.all(
|
||||
cookiePairs.map((pair) =>
|
||||
miniMaxSession.cookies.set({
|
||||
url: MINIMAX_ORIGIN,
|
||||
name: pair.name,
|
||||
value: pair.value,
|
||||
secure: true,
|
||||
path: '/'
|
||||
})
|
||||
)
|
||||
)
|
||||
const headers = makeMiniMaxRequestHeaders(args.groupId)
|
||||
return {
|
||||
response: await miniMaxSession.fetch(args.endpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: args.signal
|
||||
}),
|
||||
requestHeaderNames: Object.keys(headers),
|
||||
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
|
||||
transport: 'session-cookie-jar'
|
||||
}
|
||||
} finally {
|
||||
await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => {
|
||||
console.warn('[minimax] failed to clear session cookie jar after fetch', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMiniMaxWithManualCookieHeader(args: {
|
||||
cookie: string
|
||||
endpoint: string
|
||||
groupId: string | null
|
||||
signal: AbortSignal
|
||||
}): Promise<MiniMaxFetchResponse> {
|
||||
const miniMaxSession = session.fromPartition(MINIMAX_SESSION_PARTITION)
|
||||
try {
|
||||
await clearMiniMaxSessionCookieJarForSession(miniMaxSession)
|
||||
const headers = {
|
||||
...makeMiniMaxRequestHeaders(args.groupId),
|
||||
Cookie: normalizeMiniMaxCookieHeader(args.cookie)
|
||||
}
|
||||
return {
|
||||
response: await miniMaxSession.fetch(args.endpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: args.signal
|
||||
}),
|
||||
requestHeaderNames: Object.keys(headers),
|
||||
cookieNames: getUniqueMiniMaxCookieNames(args.cookie),
|
||||
transport: 'manual-cookie-header'
|
||||
}
|
||||
} finally {
|
||||
await clearMiniMaxSessionCookieJarForSession(miniMaxSession).catch((error: unknown) => {
|
||||
console.warn('[minimax] failed to clear session cookie jar after fetch', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function logMiniMaxFetchFailure(details: {
|
||||
transport: MiniMaxFetchTransport
|
||||
responseStatus?: number
|
||||
statusCode?: unknown
|
||||
statusMsg?: unknown
|
||||
cookieNames: string[]
|
||||
requestHeaderNames: string[]
|
||||
}): void {
|
||||
console.warn('[minimax] usage fetch failed', {
|
||||
transport: details.transport,
|
||||
responseStatus: details.responseStatus,
|
||||
baseRespStatusCode: details.statusCode,
|
||||
baseRespStatusMsg:
|
||||
typeof details.statusMsg === 'string'
|
||||
? redactMiniMaxSecret(details.statusMsg)
|
||||
: details.statusMsg,
|
||||
cookieNames: details.cookieNames,
|
||||
requestHeaderNames: details.requestHeaderNames
|
||||
})
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import { RateLimitService } from './service'
|
||||
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
|
||||
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
||||
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
|
||||
vi.mock('./claude-fetcher', () => ({
|
||||
fetchClaudeRateLimits: vi.fn(),
|
||||
@@ -28,6 +30,14 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
|
||||
fetchOpenCodeGoRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./minimax-fetcher', () => ({
|
||||
fetchMiniMaxRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../minimax/minimax-cookie-store', () => ({
|
||||
hasMiniMaxSessionCookie: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
@@ -42,7 +52,7 @@ function deferred<T>(): Deferred<T> {
|
||||
}
|
||||
|
||||
function okProvider(
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
|
||||
usedPercent: number,
|
||||
updatedAt = Date.now()
|
||||
): ProviderRateLimits {
|
||||
@@ -62,7 +72,7 @@ function okProvider(
|
||||
}
|
||||
|
||||
function errorProvider(
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go',
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'minimax',
|
||||
message: string
|
||||
): ProviderRateLimits {
|
||||
return {
|
||||
@@ -116,6 +126,8 @@ describe('RateLimitService', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 0, Date.now()))
|
||||
vi.mocked(fetchOpenCodeGoRateLimits).mockResolvedValue(okProvider('opencode-go', 0, Date.now()))
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValue(okProvider('minimax', 0, Date.now()))
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => {
|
||||
@@ -333,6 +345,110 @@ describe('RateLimitService', () => {
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('aborts the active fetch cycle and clears queued refreshes on stop', async () => {
|
||||
const service = new RateLimitService()
|
||||
const capturedSignals: { claude?: AbortSignal; codex?: AbortSignal } = {}
|
||||
|
||||
vi.mocked(fetchClaudeRateLimits).mockImplementation(
|
||||
(options) =>
|
||||
new Promise((resolve) => {
|
||||
capturedSignals.claude = options?.signal
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => resolve(errorProvider('claude', 'aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
vi.mocked(fetchCodexRateLimits).mockImplementation(
|
||||
(options) =>
|
||||
new Promise((resolve) => {
|
||||
capturedSignals.codex = options?.signal
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => resolve(errorProvider('codex', 'aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const activeFetch = serviceInternals(service).fetchAll()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const queuedRefresh = service.refresh()
|
||||
await Promise.resolve()
|
||||
|
||||
service.stop()
|
||||
|
||||
expect(capturedSignals.claude?.aborted).toBe(true)
|
||||
expect(capturedSignals.codex?.aborted).toBe(true)
|
||||
|
||||
await queuedRefresh
|
||||
await activeFetch
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('aborts inactive Claude preview fetches on stop', async () => {
|
||||
const service = new RateLimitService()
|
||||
const account = { id: 'account-1', managedAuthPath: '/tmp/account-1/auth' }
|
||||
const capturedSignals: { claude?: AbortSignal } = {}
|
||||
service.setInactiveClaudeAccountsResolver(() => [account])
|
||||
vi.mocked(fetchManagedAccountUsage).mockImplementation(
|
||||
(_account, options) =>
|
||||
new Promise((resolve) => {
|
||||
capturedSignals.claude = options?.signal
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => resolve(errorProvider('claude', 'aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const previewFetch = service.fetchInactiveClaudeAccountsOnOpen()
|
||||
await Promise.resolve()
|
||||
|
||||
service.stop()
|
||||
|
||||
expect(capturedSignals.claude?.aborted).toBe(true)
|
||||
|
||||
await previewFetch
|
||||
|
||||
expect(service.getState().inactiveClaudeAccounts).toEqual([])
|
||||
})
|
||||
|
||||
it('aborts inactive Codex preview fetches on stop', async () => {
|
||||
const service = new RateLimitService()
|
||||
const account = { id: 'account-1', managedHomePath: '/tmp/account-1/home' }
|
||||
const capturedSignals: { codex?: AbortSignal } = {}
|
||||
service.setInactiveCodexAccountsResolver(() => [account])
|
||||
vi.mocked(fetchCodexRateLimits).mockImplementation(
|
||||
(options) =>
|
||||
new Promise((resolve) => {
|
||||
capturedSignals.codex = options?.signal
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => resolve(errorProvider('codex', 'aborted')),
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const previewFetch = service.fetchInactiveCodexAccountsOnOpen()
|
||||
await Promise.resolve()
|
||||
|
||||
service.stop()
|
||||
|
||||
expect(capturedSignals.codex?.aborted).toBe(true)
|
||||
|
||||
await previewFetch
|
||||
|
||||
expect(service.getState().inactiveCodexAccounts).toEqual([])
|
||||
})
|
||||
|
||||
it('fetches Gemini and OpenCode Go alongside Claude and Codex', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setOpenCodeGoConfigResolver(() => ({
|
||||
@@ -351,11 +467,14 @@ describe('RateLimitService', () => {
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchGeminiRateLimits).toHaveBeenCalledWith(true)
|
||||
@@ -453,16 +572,19 @@ describe('RateLimitService', () => {
|
||||
await service.refresh()
|
||||
|
||||
expect(resolver).toHaveBeenCalledWith({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: expect.objectContaining({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxConfigDir: '/home/jin/.claude',
|
||||
stripAuthEnv: true
|
||||
}),
|
||||
allowPtyFallback: true,
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authPreparation: expect.objectContaining({
|
||||
runtime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
wslLinuxConfigDir: '/home/jin/.claude',
|
||||
stripAuthEnv: true
|
||||
}),
|
||||
allowPtyFallback: true,
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
expect(service.getState().claudeTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
@@ -483,11 +605,14 @@ describe('RateLimitService', () => {
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: expect.objectContaining({ provenance: 'system' }),
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authPreparation: expect.objectContaining({ provenance: 'system' }),
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not use Claude PTY fallback when Claude auth preparation is unavailable', async () => {
|
||||
@@ -498,11 +623,14 @@ describe('RateLimitService', () => {
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authPreparation: undefined,
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not use Claude PTY fallback for WSL system-default usage refreshes', async () => {
|
||||
@@ -523,11 +651,14 @@ describe('RateLimitService', () => {
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith({
|
||||
authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchClaudeRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authPreparation: expect.objectContaining({ provenance: 'wsl:Ubuntu:system' }),
|
||||
allowPtyFallback: false,
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not cache host Codex usage under an outgoing WSL account', async () => {
|
||||
@@ -604,10 +735,13 @@ describe('RateLimitService', () => {
|
||||
|
||||
await service.fetchInactiveCodexAccountsOnOpen()
|
||||
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledWith({
|
||||
codexHomePath: wslCodexHome,
|
||||
allowPtyFallback: false
|
||||
})
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
codexHomePath: wslCodexHome,
|
||||
allowPtyFallback: false,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
expect(service.getState().inactiveCodexAccounts).toEqual([
|
||||
{
|
||||
accountId: 'account-1',
|
||||
@@ -629,9 +763,13 @@ describe('RateLimitService', () => {
|
||||
|
||||
await service.fetchInactiveClaudeAccountsOnOpen()
|
||||
|
||||
expect(fetchManagedAccountUsage).toHaveBeenCalledWith(account, {
|
||||
allowUsagePanelSupplement: true
|
||||
})
|
||||
expect(fetchManagedAccountUsage).toHaveBeenCalledWith(
|
||||
account,
|
||||
expect.objectContaining({
|
||||
allowUsagePanelSupplement: true,
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not start overlapping inactive Codex preview fetches', async () => {
|
||||
@@ -930,4 +1068,129 @@ describe('RateLimitService', () => {
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('fetches MiniMax alongside other providers when a config resolver is set', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setMiniMaxConfigResolver(() => ({
|
||||
sessionCookie: '_token=abc; minimax_group_id_v2=42',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
}))
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockResolvedValueOnce(okProvider('minimax', 50, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledWith({
|
||||
cookie: '_token=abc; minimax_group_id_v2=42',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
})
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.minimax?.status).toBe('ok')
|
||||
expect(state.minimax?.session?.usedPercent).toBe(50)
|
||||
expect(state.minimaxCookieConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('reports minimaxCookieConfigured from the cookie store even without a resolver', () => {
|
||||
const service = new RateLimitService()
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
|
||||
expect(service.getState().minimaxCookieConfigured).toBe(true)
|
||||
})
|
||||
|
||||
it('discards the previous MiniMax snapshot when its config hash changes', async () => {
|
||||
const service = new RateLimitService()
|
||||
let models = 'general'
|
||||
service.setMiniMaxConfigResolver(() => ({
|
||||
sessionCookie: '_token=abc',
|
||||
groupId: '',
|
||||
models
|
||||
}))
|
||||
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(true)
|
||||
vi.mocked(fetchMiniMaxRateLimits)
|
||||
.mockResolvedValueOnce(okProvider('minimax', 40, Date.now()))
|
||||
.mockResolvedValueOnce(okProvider('minimax', 10, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
expect(service.getState().minimax?.session?.usedPercent).toBe(40)
|
||||
|
||||
models = 'premium'
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2)
|
||||
expect(state.minimax?.session?.usedPercent).toBe(10)
|
||||
})
|
||||
|
||||
it('does not apply an in-flight MiniMax result after credential invalidation', async () => {
|
||||
const service = new RateLimitService()
|
||||
const firstMiniMax = deferred<ProviderRateLimits>()
|
||||
const secondMiniMax = deferred<ProviderRateLimits>()
|
||||
service.setMiniMaxConfigResolver(() => ({
|
||||
sessionCookie: '_token=abc',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
}))
|
||||
vi.mocked(fetchMiniMaxRateLimits)
|
||||
.mockImplementationOnce(() => firstMiniMax.promise)
|
||||
.mockImplementationOnce(() => secondMiniMax.promise)
|
||||
|
||||
const firstRefresh = service.refresh()
|
||||
await Promise.resolve()
|
||||
|
||||
service.invalidateMiniMaxCredentialState()
|
||||
const queuedRefresh = service.refresh()
|
||||
await Promise.resolve()
|
||||
|
||||
firstMiniMax.resolve(okProvider('minimax', 50, Date.now()))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(service.getState().minimax?.status).toBe('fetching')
|
||||
expect(service.getState().minimax?.session).toBeNull()
|
||||
|
||||
secondMiniMax.resolve(okProvider('minimax', 10, Date.now()))
|
||||
await firstRefresh
|
||||
await queuedRefresh
|
||||
|
||||
const state = service.getState()
|
||||
expect(fetchMiniMaxRateLimits).toHaveBeenCalledTimes(2)
|
||||
expect(state.minimax?.session?.usedPercent).toBe(10)
|
||||
})
|
||||
|
||||
it('isolates MiniMax failures from other providers', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setMiniMaxConfigResolver(() => ({
|
||||
sessionCookie: '_token=abc',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
}))
|
||||
vi.mocked(fetchMiniMaxRateLimits).mockRejectedValueOnce(new Error('minimax down'))
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(state.minimax?.status).toBe('error')
|
||||
expect(state.minimax?.error).toBe('minimax down')
|
||||
expect(state.claude?.status).toBe('ok')
|
||||
})
|
||||
|
||||
it('isolates MiniMax config resolver failures from other providers', async () => {
|
||||
const service = new RateLimitService()
|
||||
service.setMiniMaxConfigResolver(() => {
|
||||
throw new Error('MiniMax session cookie could not be decrypted')
|
||||
})
|
||||
vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now()))
|
||||
|
||||
await service.refresh()
|
||||
|
||||
const state = service.getState()
|
||||
expect(fetchMiniMaxRateLimits).not.toHaveBeenCalled()
|
||||
expect(state.minimax?.status).toBe('error')
|
||||
expect(state.minimax?.error).toBe('MiniMax session cookie could not be decrypted')
|
||||
expect(state.claude?.status).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
+361
-91
@@ -21,6 +21,8 @@ import {
|
||||
} from '../claude-accounts/runtime-selection'
|
||||
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
|
||||
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
|
||||
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
|
||||
import {
|
||||
normalizeCodexAccountSelectionTarget,
|
||||
@@ -43,6 +45,17 @@ type OpenCodeGoRateLimitConfig = {
|
||||
workspaceIdOverride: string
|
||||
}
|
||||
|
||||
type MiniMaxRateLimitConfig = {
|
||||
sessionCookie: string
|
||||
groupId: string
|
||||
models: string
|
||||
}
|
||||
|
||||
type MiniMaxResolvedConfig = {
|
||||
config: MiniMaxRateLimitConfig
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type GeminiCliOAuthEnabledResolver = () => boolean
|
||||
|
||||
// Why: Claude's subscription usage endpoint has a tight request budget. Quota
|
||||
@@ -64,6 +77,7 @@ type InternalRateLimitState = {
|
||||
gemini: ProviderRateLimits | null
|
||||
opencodeGo: ProviderRateLimits | null
|
||||
kimi: ProviderRateLimits | null
|
||||
minimax: ProviderRateLimits | null
|
||||
}
|
||||
|
||||
function normalizePollingInterval(ms: number): number {
|
||||
@@ -85,13 +99,18 @@ function isSystemDefaultClaudeAuth(
|
||||
return provenance === 'system' || Boolean(provenance?.endsWith(':system'))
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
export class RateLimitService {
|
||||
private state: InternalRateLimitState = {
|
||||
claude: null,
|
||||
codex: null,
|
||||
gemini: null,
|
||||
opencodeGo: null,
|
||||
kimi: null
|
||||
kimi: null,
|
||||
minimax: null
|
||||
}
|
||||
private pollInterval: number = DEFAULT_POLL_MS
|
||||
private timer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -103,11 +122,14 @@ export class RateLimitService {
|
||||
private fullFetchQueued = false
|
||||
private codexOnlyFetchQueued = false
|
||||
private claudeOnlyFetchQueued = false
|
||||
private activeFetchAbortControllers = new Set<AbortController>()
|
||||
private fetchIdleResolvers: (() => void)[] = []
|
||||
private codexFetchGeneration = 0
|
||||
private claudeFetchGeneration = 0
|
||||
private opencodeFetchGeneration = 0
|
||||
private minimaxFetchGeneration = 0
|
||||
private lastOpencodeConfigHash = ''
|
||||
private lastMiniMaxConfigHash = ''
|
||||
private codexHomePathResolver: CodexHomePathResolver | null = null
|
||||
private codexFetchTarget: NormalizedCodexAccountSelectionTarget = {
|
||||
runtime: 'host',
|
||||
@@ -119,6 +141,7 @@ export class RateLimitService {
|
||||
wslDistro: null
|
||||
}
|
||||
private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null
|
||||
private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null
|
||||
private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null
|
||||
private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null
|
||||
private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null
|
||||
@@ -162,6 +185,10 @@ export class RateLimitService {
|
||||
this.openCodeGoConfigResolver = resolver
|
||||
}
|
||||
|
||||
setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void {
|
||||
this.miniMaxConfigResolver = resolver
|
||||
}
|
||||
|
||||
setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void {
|
||||
this.geminiCliOAuthEnabledResolver = resolver
|
||||
}
|
||||
@@ -221,6 +248,11 @@ export class RateLimitService {
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.abortActiveFetchCycle()
|
||||
this.clearQueuedFetches()
|
||||
this.inactiveClaudeFetching.clear()
|
||||
this.inactiveCodexFetching.clear()
|
||||
this.resolveAndClearFetchIdleWaiters()
|
||||
this.stopTimer()
|
||||
this.clearDeferredStartupRefresh()
|
||||
this.detachWindowListeners?.()
|
||||
@@ -233,6 +265,10 @@ export class RateLimitService {
|
||||
this.pruneInactiveCodexState()
|
||||
return {
|
||||
...this.state,
|
||||
// Why: the cookie lives in the file system, not GlobalSettings. Surface
|
||||
// its presence on the pushed state so the renderer keeps the MiniMax
|
||||
// bar visible across reloads and between snapshot refreshes.
|
||||
minimaxCookieConfigured: hasMiniMaxSessionCookie(),
|
||||
claudeTarget: this.claudeFetchTarget,
|
||||
codexTarget: this.codexFetchTarget,
|
||||
inactiveClaudeAccounts: this.buildInactiveArray(
|
||||
@@ -255,6 +291,16 @@ export class RateLimitService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
invalidateMiniMaxCredentialState(): void {
|
||||
this.minimaxFetchGeneration += 1
|
||||
// Why: saving or forgetting the browser cookie can race an in-flight usage
|
||||
// fetch; clear the visible snapshot before any old-cookie result returns.
|
||||
this.updateState({
|
||||
...this.state,
|
||||
minimax: this.withFetchingStatus(null, 'minimax')
|
||||
})
|
||||
}
|
||||
|
||||
async refreshForCodexAccountChange(
|
||||
outgoingAccountId?: string | null,
|
||||
target?: CodexAccountSelectionTarget
|
||||
@@ -369,30 +415,18 @@ export class RateLimitService {
|
||||
return
|
||||
}
|
||||
const fetchGeneration = this.inactiveClaudeAccountsGeneration
|
||||
const controller = this.beginFetchCycle()
|
||||
const signal = controller.signal
|
||||
|
||||
for (const account of accounts) {
|
||||
this.inactiveClaudeFetching.add(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
|
||||
for (const account of accounts) {
|
||||
if (
|
||||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
|
||||
!this.isCurrentInactiveClaudeAccount(account.id)
|
||||
) {
|
||||
this.inactiveClaudeFetching.delete(account.id)
|
||||
if (!this.isCurrentInactiveClaudeAccount(account.id)) {
|
||||
this.inactiveClaudeCache.delete(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const fresh = await fetchManagedAccountUsage(account, {
|
||||
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
|
||||
networkProxySettings: this.networkProxySettingsResolver?.()
|
||||
})
|
||||
try {
|
||||
for (const account of accounts) {
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
|
||||
!this.isCurrentInactiveClaudeAccount(account.id)
|
||||
) {
|
||||
@@ -403,24 +437,46 @@ export class RateLimitService {
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
const cached = this.inactiveClaudeCache.get(account.id) ?? null
|
||||
this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached))
|
||||
} catch {
|
||||
// Why: per-account try/catch prevents one Keychain rejection or
|
||||
// network error from aborting the remaining accounts in the batch.
|
||||
if (
|
||||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
|
||||
!this.isCurrentInactiveClaudeAccount(account.id)
|
||||
) {
|
||||
this.inactiveClaudeCache.delete(account.id)
|
||||
try {
|
||||
const fresh = await fetchManagedAccountUsage(account, {
|
||||
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
|
||||
networkProxySettings: this.networkProxySettingsResolver?.(),
|
||||
signal
|
||||
})
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
|
||||
!this.isCurrentInactiveClaudeAccount(account.id)
|
||||
) {
|
||||
this.inactiveClaudeFetching.delete(account.id)
|
||||
if (!this.isCurrentInactiveClaudeAccount(account.id)) {
|
||||
this.inactiveClaudeCache.delete(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
const cached = this.inactiveClaudeCache.get(account.id) ?? null
|
||||
this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached))
|
||||
} catch {
|
||||
// Why: per-account try/catch prevents one Keychain rejection or
|
||||
// network error from aborting the remaining accounts in the batch.
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveClaudeAccountsGeneration ||
|
||||
!this.isCurrentInactiveClaudeAccount(account.id)
|
||||
) {
|
||||
this.inactiveClaudeCache.delete(account.id)
|
||||
}
|
||||
}
|
||||
this.inactiveClaudeFetching.delete(account.id)
|
||||
this.pushToRenderer()
|
||||
}
|
||||
this.inactiveClaudeFetching.delete(account.id)
|
||||
this.pushToRenderer()
|
||||
}
|
||||
|
||||
if (fetchGeneration === this.inactiveClaudeAccountsGeneration) {
|
||||
this.lastInactiveClaudeFetchAt = Date.now()
|
||||
if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) {
|
||||
this.lastInactiveClaudeFetchAt = Date.now()
|
||||
}
|
||||
} finally {
|
||||
this.finishFetchCycle(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,36 +495,18 @@ export class RateLimitService {
|
||||
// Why: account switching can make a previewed account active while its
|
||||
// RPC-only usage fetch is still in flight; stale results must be ignored.
|
||||
const fetchGeneration = this.inactiveCodexAccountsGeneration
|
||||
const controller = this.beginFetchCycle()
|
||||
const signal = controller.signal
|
||||
|
||||
for (const account of accounts) {
|
||||
this.inactiveCodexFetching.add(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
|
||||
for (const account of accounts) {
|
||||
if (
|
||||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
|
||||
!this.isCurrentInactiveCodexAccount(account.id)
|
||||
) {
|
||||
this.inactiveCodexFetching.delete(account.id)
|
||||
if (!this.isCurrentInactiveCodexAccount(account.id)) {
|
||||
this.inactiveCodexCache.delete(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// Why: fetchCodexRateLimits already accepts codexHomePath, so we can
|
||||
// point it at the managed account's home directory directly without
|
||||
// materializing credentials into the shared runtime location.
|
||||
// Why: opening the account switcher should never start hidden PTYs for
|
||||
// every inactive account. On Windows that fallback can crash inside
|
||||
// ConPTY; RPC-only is enough for this non-critical preview surface.
|
||||
const fresh = await fetchCodexRateLimits({
|
||||
codexHomePath: account.managedHomePath,
|
||||
allowPtyFallback: false
|
||||
})
|
||||
try {
|
||||
for (const account of accounts) {
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
|
||||
!this.isCurrentInactiveCodexAccount(account.id)
|
||||
) {
|
||||
@@ -479,23 +517,51 @@ export class RateLimitService {
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
const cached = this.inactiveCodexCache.get(account.id) ?? null
|
||||
this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached))
|
||||
} catch {
|
||||
// Why: per-account try/catch prevents one failure from aborting the batch.
|
||||
if (
|
||||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
|
||||
!this.isCurrentInactiveCodexAccount(account.id)
|
||||
) {
|
||||
this.inactiveCodexCache.delete(account.id)
|
||||
try {
|
||||
// Why: fetchCodexRateLimits already accepts codexHomePath, so we can
|
||||
// point it at the managed account's home directory directly without
|
||||
// materializing credentials into the shared runtime location.
|
||||
// Why: opening the account switcher should never start hidden PTYs for
|
||||
// every inactive account. On Windows that fallback can crash inside
|
||||
// ConPTY; RPC-only is enough for this non-critical preview surface.
|
||||
const fresh = await fetchCodexRateLimits({
|
||||
codexHomePath: account.managedHomePath,
|
||||
allowPtyFallback: false,
|
||||
signal
|
||||
})
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
|
||||
!this.isCurrentInactiveCodexAccount(account.id)
|
||||
) {
|
||||
this.inactiveCodexFetching.delete(account.id)
|
||||
if (!this.isCurrentInactiveCodexAccount(account.id)) {
|
||||
this.inactiveCodexCache.delete(account.id)
|
||||
}
|
||||
this.pushToRenderer()
|
||||
continue
|
||||
}
|
||||
const cached = this.inactiveCodexCache.get(account.id) ?? null
|
||||
this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached))
|
||||
} catch {
|
||||
// Why: per-account try/catch prevents one failure from aborting the batch.
|
||||
if (
|
||||
signal.aborted ||
|
||||
fetchGeneration !== this.inactiveCodexAccountsGeneration ||
|
||||
!this.isCurrentInactiveCodexAccount(account.id)
|
||||
) {
|
||||
this.inactiveCodexCache.delete(account.id)
|
||||
}
|
||||
}
|
||||
this.inactiveCodexFetching.delete(account.id)
|
||||
this.pushToRenderer()
|
||||
}
|
||||
this.inactiveCodexFetching.delete(account.id)
|
||||
this.pushToRenderer()
|
||||
}
|
||||
|
||||
if (fetchGeneration === this.inactiveCodexAccountsGeneration) {
|
||||
this.lastInactiveCodexFetchAt = Date.now()
|
||||
if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) {
|
||||
this.lastInactiveCodexFetchAt = Date.now()
|
||||
}
|
||||
} finally {
|
||||
this.finishFetchCycle(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,8 +710,13 @@ export class RateLimitService {
|
||||
try {
|
||||
let shouldContinue = true
|
||||
while (shouldContinue) {
|
||||
await this.runFetchAllCycle()
|
||||
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchAllCycle(fetchSignal)
|
||||
)
|
||||
shouldContinue = false
|
||||
if (signal.aborted) {
|
||||
break
|
||||
}
|
||||
if (this.fullFetchQueued) {
|
||||
this.fullFetchQueued = false
|
||||
shouldContinue = true
|
||||
@@ -653,11 +724,21 @@ export class RateLimitService {
|
||||
}
|
||||
if (this.codexOnlyFetchQueued) {
|
||||
this.codexOnlyFetchQueued = false
|
||||
await this.runFetchCodexOnlyCycle()
|
||||
const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchCodexOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (codexSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (this.claudeOnlyFetchQueued) {
|
||||
this.claudeOnlyFetchQueued = false
|
||||
await this.runFetchClaudeOnlyCycle()
|
||||
const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchClaudeOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (claudeSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -679,11 +760,21 @@ export class RateLimitService {
|
||||
try {
|
||||
let shouldContinue = true
|
||||
while (shouldContinue) {
|
||||
await this.runFetchCodexOnlyCycle()
|
||||
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchCodexOnlyCycle(fetchSignal)
|
||||
)
|
||||
shouldContinue = false
|
||||
if (signal.aborted) {
|
||||
break
|
||||
}
|
||||
if (this.fullFetchQueued) {
|
||||
this.fullFetchQueued = false
|
||||
await this.runFetchAllCycle()
|
||||
const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchAllCycle(fetchSignal)
|
||||
)
|
||||
if (fullSignal.aborted) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (this.codexOnlyFetchQueued) {
|
||||
@@ -692,7 +783,12 @@ export class RateLimitService {
|
||||
}
|
||||
if (this.claudeOnlyFetchQueued) {
|
||||
this.claudeOnlyFetchQueued = false
|
||||
await this.runFetchClaudeOnlyCycle()
|
||||
const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchClaudeOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (claudeSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -714,11 +810,21 @@ export class RateLimitService {
|
||||
try {
|
||||
let shouldContinue = true
|
||||
while (shouldContinue) {
|
||||
await this.runFetchClaudeOnlyCycle()
|
||||
const signal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchClaudeOnlyCycle(fetchSignal)
|
||||
)
|
||||
shouldContinue = false
|
||||
if (signal.aborted) {
|
||||
break
|
||||
}
|
||||
if (this.fullFetchQueued) {
|
||||
this.fullFetchQueued = false
|
||||
await this.runFetchAllCycle()
|
||||
const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchAllCycle(fetchSignal)
|
||||
)
|
||||
if (fullSignal.aborted) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (this.claudeOnlyFetchQueued) {
|
||||
@@ -727,7 +833,12 @@ export class RateLimitService {
|
||||
}
|
||||
if (this.codexOnlyFetchQueued) {
|
||||
this.codexOnlyFetchQueued = false
|
||||
await this.runFetchCodexOnlyCycle()
|
||||
const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) =>
|
||||
this.runFetchCodexOnlyCycle(fetchSignal)
|
||||
)
|
||||
if (codexSignal.aborted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -769,6 +880,49 @@ export class RateLimitService {
|
||||
}
|
||||
}
|
||||
|
||||
private beginFetchCycle(): AbortController {
|
||||
const controller = new AbortController()
|
||||
this.activeFetchAbortControllers.add(controller)
|
||||
return controller
|
||||
}
|
||||
|
||||
private finishFetchCycle(controller: AbortController): void {
|
||||
this.activeFetchAbortControllers.delete(controller)
|
||||
}
|
||||
|
||||
private async runWithFetchAbortSignal(
|
||||
fn: (signal: AbortSignal) => Promise<void>
|
||||
): Promise<AbortSignal> {
|
||||
const controller = this.beginFetchCycle()
|
||||
try {
|
||||
await fn(controller.signal)
|
||||
return controller.signal
|
||||
} finally {
|
||||
this.finishFetchCycle(controller)
|
||||
}
|
||||
}
|
||||
|
||||
private abortActiveFetchCycle(): void {
|
||||
for (const controller of this.activeFetchAbortControllers) {
|
||||
controller.abort()
|
||||
}
|
||||
this.activeFetchAbortControllers.clear()
|
||||
}
|
||||
|
||||
private clearQueuedFetches(): void {
|
||||
this.fullFetchQueued = false
|
||||
this.codexOnlyFetchQueued = false
|
||||
this.claudeOnlyFetchQueued = false
|
||||
}
|
||||
|
||||
private resolveAndClearFetchIdleWaiters(): void {
|
||||
const resolvers = this.fetchIdleResolvers
|
||||
this.fetchIdleResolvers = []
|
||||
for (const resolve of resolvers) {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
private isSameCodexTarget(
|
||||
left: NormalizedCodexAccountSelectionTarget,
|
||||
right: NormalizedCodexAccountSelectionTarget
|
||||
@@ -833,9 +987,45 @@ export class RateLimitService {
|
||||
return process.platform !== 'win32'
|
||||
}
|
||||
|
||||
private resolveMiniMaxConfig(): MiniMaxResolvedConfig {
|
||||
try {
|
||||
return {
|
||||
config: this.miniMaxConfigResolver?.() ?? {
|
||||
sessionCookie: '',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
},
|
||||
error: null
|
||||
}
|
||||
} catch (error) {
|
||||
// Why: one unreadable browser cookie must not abort every provider's
|
||||
// quota refresh; surface it as MiniMax-only state instead.
|
||||
return {
|
||||
config: {
|
||||
sessionCookie: '',
|
||||
groupId: '',
|
||||
models: 'general'
|
||||
},
|
||||
error: toErrorMessage(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getMiniMaxCredentialError(message: string): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'minimax',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: message,
|
||||
status: 'error',
|
||||
usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' }
|
||||
}
|
||||
}
|
||||
|
||||
private withFetchingStatus(
|
||||
current: ProviderRateLimits | null,
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax'
|
||||
): ProviderRateLimits {
|
||||
if (!current) {
|
||||
return {
|
||||
@@ -850,9 +1040,15 @@ export class RateLimitService {
|
||||
return { ...current, status: 'fetching' }
|
||||
}
|
||||
|
||||
private async runFetchAllCycle(): Promise<void> {
|
||||
private async runFetchAllCycle(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const claudeTarget = this.claudeFetchTarget
|
||||
const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system'
|
||||
const claudeGeneration = this.claudeFetchGeneration
|
||||
const codexTarget = this.codexFetchTarget
|
||||
@@ -863,6 +1059,10 @@ export class RateLimitService {
|
||||
const openCodeGoConfig = this.openCodeGoConfigResolver?.()
|
||||
const cookie = openCodeGoConfig?.sessionCookie ?? ''
|
||||
const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? ''
|
||||
const miniMaxConfigResult = this.resolveMiniMaxConfig()
|
||||
const miniMaxCookie = miniMaxConfigResult.config.sessionCookie
|
||||
const miniMaxGroupId = miniMaxConfigResult.config.groupId
|
||||
const miniMaxModels = miniMaxConfigResult.config.models
|
||||
const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false
|
||||
|
||||
// Detect if configuration changed — if it did, we must discard any stale
|
||||
@@ -875,6 +1075,14 @@ export class RateLimitService {
|
||||
}
|
||||
const opencodeGeneration = this.opencodeFetchGeneration
|
||||
|
||||
const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}`
|
||||
const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash
|
||||
if (miniMaxConfigChanged) {
|
||||
this.lastMiniMaxConfigHash = currentMiniMaxConfigHash
|
||||
this.minimaxFetchGeneration += 1
|
||||
}
|
||||
const miniMaxGeneration = this.minimaxFetchGeneration
|
||||
|
||||
// Mark all providers as fetching while keeping previous data visible.
|
||||
// Codex account changes clear Codex separately before this method is
|
||||
// called, so ordinary refreshes still preserve the current values.
|
||||
@@ -886,30 +1094,46 @@ export class RateLimitService {
|
||||
opencodeGo: opencodeConfigChanged
|
||||
? this.withFetchingStatus(null, 'opencode-go')
|
||||
: this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'),
|
||||
kimi: this.withFetchingStatus(previousState.kimi, 'kimi')
|
||||
kimi: this.withFetchingStatus(previousState.kimi, 'kimi'),
|
||||
minimax: miniMaxConfigChanged
|
||||
? this.withFetchingStatus(null, 'minimax')
|
||||
: this.withFetchingStatus(previousState.minimax, 'minimax')
|
||||
})
|
||||
|
||||
const missingWslCodexHome = codexHomePath
|
||||
? null
|
||||
: this.getMissingWslCodexHomeResult(codexTarget)
|
||||
const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult] =
|
||||
const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] =
|
||||
await Promise.allSettled([
|
||||
fetchClaudeRateLimits({
|
||||
authPreparation: claudeAuthPreparation,
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
|
||||
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
|
||||
networkProxySettings: this.networkProxySettingsResolver?.()
|
||||
networkProxySettings: this.networkProxySettingsResolver?.(),
|
||||
signal
|
||||
}),
|
||||
missingWslCodexHome ??
|
||||
fetchCodexRateLimits({
|
||||
codexHomePath,
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback()
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
|
||||
signal
|
||||
}),
|
||||
fetchGeminiRateLimits(geminiCliOAuthEnabled),
|
||||
fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined),
|
||||
fetchKimiRateLimits()
|
||||
fetchKimiRateLimits(),
|
||||
miniMaxConfigResult.error
|
||||
? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error))
|
||||
: fetchMiniMaxRateLimits({
|
||||
cookie: miniMaxCookie,
|
||||
groupId: miniMaxGroupId,
|
||||
models: miniMaxModels
|
||||
})
|
||||
])
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const claude =
|
||||
claudeResult.status === 'fulfilled'
|
||||
? claudeResult.value
|
||||
@@ -977,8 +1201,26 @@ export class RateLimitService {
|
||||
status: 'error'
|
||||
} satisfies ProviderRateLimits)
|
||||
|
||||
const miniMax =
|
||||
miniMaxResult.status === 'fulfilled'
|
||||
? miniMaxResult.value
|
||||
: ({
|
||||
provider: 'minimax',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error:
|
||||
miniMaxResult.reason instanceof Error
|
||||
? miniMaxResult.reason.message
|
||||
: 'Unknown error',
|
||||
status: 'error'
|
||||
} satisfies ProviderRateLimits)
|
||||
|
||||
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
|
||||
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
|
||||
const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath)
|
||||
const shouldApplyCodex =
|
||||
@@ -988,6 +1230,7 @@ export class RateLimitService {
|
||||
claudeProvenance === latestClaudeProvenance &&
|
||||
this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget)
|
||||
const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration
|
||||
const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration
|
||||
|
||||
// Why: account switches can race in-flight Codex fetches. Only apply a
|
||||
// Codex result if both the selected-account provenance and the request
|
||||
@@ -1007,13 +1250,21 @@ export class RateLimitService {
|
||||
? opencodeGo
|
||||
: this.applyStalePolicy(opencodeGo, previousState.opencodeGo)
|
||||
: this.state.opencodeGo,
|
||||
kimi: this.applyStalePolicy(kimi, previousState.kimi)
|
||||
kimi: this.applyStalePolicy(kimi, previousState.kimi),
|
||||
minimax: shouldApplyMiniMax
|
||||
? miniMaxConfigChanged
|
||||
? miniMax
|
||||
: this.applyStalePolicy(miniMax, previousState.minimax)
|
||||
: this.state.minimax
|
||||
})
|
||||
|
||||
this.lastFetchAt = Date.now()
|
||||
}
|
||||
|
||||
private async runFetchCodexOnlyCycle(): Promise<void> {
|
||||
private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const codexTarget = this.codexFetchTarget
|
||||
const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
|
||||
const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath)
|
||||
@@ -1033,7 +1284,8 @@ export class RateLimitService {
|
||||
? Promise.resolve(missingWslCodexHome)
|
||||
: fetchCodexRateLimits({
|
||||
codexHomePath,
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback()
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
|
||||
signal
|
||||
})
|
||||
).catch(
|
||||
(err): ProviderRateLimits => ({
|
||||
@@ -1046,6 +1298,10 @@ export class RateLimitService {
|
||||
})
|
||||
)
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const latestCodexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
|
||||
const latestCodexProvenance = this.getCodexProvenance(codexTarget, latestCodexHomePath)
|
||||
const shouldApplyCodex =
|
||||
@@ -1059,9 +1315,15 @@ export class RateLimitService {
|
||||
this.lastFetchAt = Date.now()
|
||||
}
|
||||
|
||||
private async runFetchClaudeOnlyCycle(): Promise<void> {
|
||||
private async runFetchClaudeOnlyCycle(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const claudeTarget = this.claudeFetchTarget
|
||||
const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system'
|
||||
const claudeGeneration = this.claudeFetchGeneration
|
||||
const previousState = this.state
|
||||
@@ -1075,7 +1337,8 @@ export class RateLimitService {
|
||||
authPreparation: claudeAuthPreparation,
|
||||
allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
|
||||
allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
|
||||
networkProxySettings: this.networkProxySettingsResolver?.()
|
||||
networkProxySettings: this.networkProxySettingsResolver?.(),
|
||||
signal
|
||||
}).catch(
|
||||
(err): ProviderRateLimits => ({
|
||||
provider: 'claude',
|
||||
@@ -1087,7 +1350,14 @@ export class RateLimitService {
|
||||
})
|
||||
)
|
||||
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system'
|
||||
const shouldApplyClaude =
|
||||
claudeGeneration === this.claudeFetchGeneration &&
|
||||
|
||||
@@ -811,6 +811,16 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-
|
||||
const HEADLESS_LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const HEADLESS_SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function isOriginMainBaseRefProbe(args: string[]): boolean {
|
||||
return (
|
||||
args[0] === 'rev-parse' &&
|
||||
args[1] === '--verify' &&
|
||||
(args.includes('origin/main') ||
|
||||
args.includes('refs/remotes/origin/main') ||
|
||||
args.includes('refs/remotes/origin/main^{commit}'))
|
||||
)
|
||||
}
|
||||
|
||||
function antigravityReadyScreen(model = 'Gemini 3.5 Flash (High)'): string {
|
||||
return [
|
||||
'Antigravity CLI 1.0.3',
|
||||
@@ -1218,27 +1228,33 @@ computeWorktreePathMock.mockImplementation(
|
||||
ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath)
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('projects worktree card display settings to paired clients', () => {
|
||||
it('projects runtime-backed settings to paired clients', () => {
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
})
|
||||
} as never)
|
||||
|
||||
expect(runtime.getClientSettings()).toMatchObject({
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts worktree card display setting updates from paired clients', () => {
|
||||
it('accepts runtime-backed setting updates from paired clients', () => {
|
||||
let settings = {
|
||||
...store.getSettings(),
|
||||
experimentalNewWorktreeCardStyle: false,
|
||||
compactWorktreeCards: false
|
||||
compactWorktreeCards: false,
|
||||
minimaxGroupId: '',
|
||||
minimaxUsageModels: 'general'
|
||||
}
|
||||
const updateSettings = vi.fn((updates: Partial<typeof settings>) => {
|
||||
settings = { ...settings, ...updates }
|
||||
@@ -1253,19 +1269,30 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(
|
||||
runtime.updateClientSettings({
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
})
|
||||
).toMatchObject({
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
})
|
||||
expect(updateSettings).toHaveBeenCalledWith(
|
||||
{ experimentalNewWorktreeCardStyle: true, compactWorktreeCards: true },
|
||||
{
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
},
|
||||
{ notifyListeners: true }
|
||||
)
|
||||
expect(runtime.getClientSettings()).toMatchObject({
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2370,9 +2397,8 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a runtime local worktree from an existing local base ref when the refresh fails', async () => {
|
||||
// Regression: an offline/auth failure while refreshing the remote-tracking
|
||||
// base must not block creation when a usable local base ref already exists.
|
||||
it('creates a runtime local worktree from the detected default when the persisted base is stale', async () => {
|
||||
// Regression: a stale persisted repo base must fall back to the detected default.
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const createdWorktree = {
|
||||
path: '/tmp/workspaces/cli-refresh-fails',
|
||||
@@ -2381,6 +2407,8 @@ describe('OrcaRuntimeService', () => {
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const repo = { ...store.getRepos()[0], worktreeBaseRef: 'origin/master' }
|
||||
const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never)
|
||||
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
|
||||
vi.mocked(addWorktree).mockResolvedValueOnce({})
|
||||
@@ -2392,8 +2420,10 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
|
||||
return { stdout: '/tmp/repo/.git\n', stderr: '' }
|
||||
}
|
||||
// Local remote-tracking base ref resolves -> usable fallback exists.
|
||||
if (args[0] === 'rev-parse' && args[1] === '--verify') {
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/master^{commit}')) {
|
||||
throw new Error('missing ref')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) {
|
||||
return { stdout: 'base-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
@@ -2409,8 +2439,135 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(addWorktree).toHaveBeenCalled()
|
||||
expect(addWorktree).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
createdWorktree.path,
|
||||
'cli-refresh-fails',
|
||||
'origin/main',
|
||||
false,
|
||||
false,
|
||||
{
|
||||
remoteTrackingBase: {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
},
|
||||
suggestLocalBaseRefUpdate: true
|
||||
}
|
||||
)
|
||||
expect(getDefaultBaseRef).toHaveBeenCalled()
|
||||
} finally {
|
||||
getReposSpy.mockRestore()
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a runtime local worktree from a usable persisted local branch base', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const createdWorktree = {
|
||||
path: '/tmp/workspaces/local-branch-base',
|
||||
head: 'develop-sha',
|
||||
branch: 'local-branch-base',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const repo = { ...store.getRepos()[0], worktreeBaseRef: 'develop' }
|
||||
const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never)
|
||||
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
|
||||
vi.mocked(addWorktree).mockResolvedValueOnce({})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([createdWorktree])
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
|
||||
return { stdout: '/tmp/repo/.git\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/heads/develop^{commit}')) {
|
||||
return { stdout: 'develop-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
throw new Error('network unavailable')
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
try {
|
||||
await expect(
|
||||
runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'local-branch-base'
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(addWorktree).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
createdWorktree.path,
|
||||
'local-branch-base',
|
||||
'develop',
|
||||
false
|
||||
)
|
||||
} finally {
|
||||
getReposSpy.mockRestore()
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a runtime local worktree from a slash-named local branch matching a remote prefix', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const createdWorktree = {
|
||||
path: '/tmp/workspaces/slash-local-base',
|
||||
head: 'team-feature-sha',
|
||||
branch: 'slash-local-base',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
const repo = { ...store.getRepos()[0], worktreeBaseRef: 'team/feature' }
|
||||
const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([repo] as never)
|
||||
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
|
||||
vi.mocked(addWorktree).mockResolvedValueOnce({})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([createdWorktree])
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'team\norigin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
|
||||
return { stdout: '/tmp/repo/.git\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/remotes/team/feature^{commit}')) {
|
||||
throw new Error('missing remote-tracking ref')
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('refs/heads/team/feature^{commit}')) {
|
||||
return { stdout: 'team-feature-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
throw new Error('network unavailable')
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
try {
|
||||
await expect(
|
||||
runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'slash-local-base'
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(addWorktree).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
createdWorktree.path,
|
||||
'slash-local-base',
|
||||
'team/feature',
|
||||
false
|
||||
)
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(
|
||||
['fetch', '--no-tags', 'team', '+refs/heads/feature:refs/remotes/team/feature'],
|
||||
expect.any(Object)
|
||||
)
|
||||
} finally {
|
||||
getReposSpy.mockRestore()
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
@@ -3140,6 +3297,9 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'origin/main\n', stderr: '' }
|
||||
}
|
||||
if (isOriginMainBaseRefProbe(args)) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
@@ -3244,6 +3404,9 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'origin/main\n', stderr: '' }
|
||||
}
|
||||
if (isOriginMainBaseRefProbe(args)) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
@@ -3397,6 +3560,9 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'origin/main\n', stderr: '' }
|
||||
}
|
||||
if (isOriginMainBaseRefProbe(args)) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
@@ -3507,6 +3673,9 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'origin/main\n', stderr: '' }
|
||||
}
|
||||
if (isOriginMainBaseRefProbe(args)) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
@@ -3609,6 +3778,9 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'origin/main\n', stderr: '' }
|
||||
}
|
||||
if (isOriginMainBaseRefProbe(args)) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
@@ -3765,6 +3937,9 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'origin/main\n', stderr: '' }
|
||||
}
|
||||
if (isOriginMainBaseRefProbe(args)) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
@@ -4840,6 +5015,9 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||||
}
|
||||
if (isOriginMainBaseRefProbe(args)) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
@@ -22332,6 +22510,9 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'origin/main\n', stderr: '' }
|
||||
}
|
||||
if (isOriginMainBaseRefProbe(args)) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
@@ -22425,6 +22606,9 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'origin/main\n', stderr: '' }
|
||||
}
|
||||
if (isOriginMainBaseRefProbe(args)) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
@@ -22535,6 +22719,9 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'origin/main\n', stderr: '' }
|
||||
}
|
||||
if (isOriginMainBaseRefProbe(args)) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
@@ -23216,6 +23403,9 @@ describe('OrcaRuntimeService', () => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return { stdout: 'refs/remotes/origin/main\n', stderr: '' }
|
||||
}
|
||||
if (isOriginMainBaseRefProbe(args)) {
|
||||
return { stdout: 'main-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'config') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ import { createHash, randomUUID } from 'node:crypto'
|
||||
import { homedir } from 'node:os'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { resolveWorktreeCreateBase } from '../worktree-create-base'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { formatMessagesForInjection } from './orchestration/formatter'
|
||||
import type {
|
||||
@@ -581,7 +583,8 @@ import {
|
||||
getRemoteDrift,
|
||||
getRecentDriftSubjects
|
||||
} from '../git/repo'
|
||||
import { hasLocalCommitObject } from '../git/commit-object-ref'
|
||||
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
|
||||
import { hasWorktreeBaseCommitRef } from '../git/worktree-base-ref-probe'
|
||||
import { resolveLocalGitUsername } from '../git/git-username'
|
||||
import {
|
||||
listWorktrees,
|
||||
@@ -793,6 +796,8 @@ type RuntimeStore = {
|
||||
githubProjects?: GlobalSettings['githubProjects']
|
||||
experimentalNewWorktreeCardStyle?: GlobalSettings['experimentalNewWorktreeCardStyle']
|
||||
compactWorktreeCards?: GlobalSettings['compactWorktreeCards']
|
||||
minimaxGroupId?: GlobalSettings['minimaxGroupId']
|
||||
minimaxUsageModels?: GlobalSettings['minimaxUsageModels']
|
||||
gitlabProjects?: GlobalSettings['gitlabProjects']
|
||||
experimentalWorktreeSymlinks?: boolean
|
||||
mobileAutoRestoreFitMs?: number | null
|
||||
@@ -1963,6 +1968,26 @@ type LayoutQueueEntry = {
|
||||
}[]
|
||||
}
|
||||
|
||||
async function hasLocalWorktreeBaseRef(
|
||||
repoPath: string,
|
||||
baseRef: string,
|
||||
options: { wslDistro?: string } = {}
|
||||
): Promise<boolean> {
|
||||
const refExists = (qualifiedRef: string) =>
|
||||
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
|
||||
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
|
||||
if (resolvedBaseRef !== baseRef) {
|
||||
return true
|
||||
}
|
||||
if (baseRef.startsWith('refs/')) {
|
||||
return refExists(baseRef)
|
||||
}
|
||||
return hasCommitObjectViaGitExec(
|
||||
(gitArgs) => gitExecFileAsync(gitArgs, { cwd: repoPath, ...options }),
|
||||
baseRef
|
||||
)
|
||||
}
|
||||
|
||||
export class OrcaRuntimeService {
|
||||
private readonly runtimeId = randomUUID()
|
||||
private readonly startedAt = Date.now()
|
||||
@@ -2372,6 +2397,8 @@ export class OrcaRuntimeService {
|
||||
| 'githubProjects'
|
||||
| 'experimentalNewWorktreeCardStyle'
|
||||
| 'compactWorktreeCards'
|
||||
| 'minimaxGroupId'
|
||||
| 'minimaxUsageModels'
|
||||
> {
|
||||
if (!this.store?.getSettings) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -2391,7 +2418,9 @@ export class OrcaRuntimeService {
|
||||
defaultLinearTeamSelection: settings.defaultLinearTeamSelection ?? null,
|
||||
githubProjects: settings.githubProjects,
|
||||
experimentalNewWorktreeCardStyle: settings.experimentalNewWorktreeCardStyle === true,
|
||||
compactWorktreeCards: settings.compactWorktreeCards === true
|
||||
compactWorktreeCards: settings.compactWorktreeCards === true,
|
||||
minimaxGroupId: settings.minimaxGroupId ?? '',
|
||||
minimaxUsageModels: settings.minimaxUsageModels ?? 'general'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2411,6 +2440,8 @@ export class OrcaRuntimeService {
|
||||
| 'githubProjects'
|
||||
| 'experimentalNewWorktreeCardStyle'
|
||||
| 'compactWorktreeCards'
|
||||
| 'minimaxGroupId'
|
||||
| 'minimaxUsageModels'
|
||||
>
|
||||
): Pick<
|
||||
GlobalSettings,
|
||||
@@ -2428,6 +2459,8 @@ export class OrcaRuntimeService {
|
||||
| 'githubProjects'
|
||||
| 'experimentalNewWorktreeCardStyle'
|
||||
| 'compactWorktreeCards'
|
||||
| 'minimaxGroupId'
|
||||
| 'minimaxUsageModels'
|
||||
> {
|
||||
if (!this.store?.getSettings || !this.store.updateSettings) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -5922,7 +5955,9 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
}
|
||||
|
||||
private async clearHeadlessTerminalBuffer(ptyId: string): Promise<void> {
|
||||
// Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile
|
||||
// mirror or a resubscribing mobile client resurrects the cleared scrollback.
|
||||
async clearHeadlessTerminalBuffer(ptyId: string): Promise<void> {
|
||||
const state = this.headlessTerminals.get(ptyId)
|
||||
if (!state) {
|
||||
return
|
||||
@@ -12992,12 +13027,42 @@ export class OrcaRuntimeService {
|
||||
let effectiveSanitizedName = sanitizedName
|
||||
const username = await resolveLocalGitUsername(repo.path)
|
||||
|
||||
const baseBranch =
|
||||
args.baseBranch ||
|
||||
repo.worktreeBaseRef ||
|
||||
(hasLocalWorktreeGitOptions
|
||||
? await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))
|
||||
: getDefaultBaseRef(repo.path))
|
||||
const baseBranch = await resolveWorktreeCreateBase({
|
||||
requestedBaseBranch: args.baseBranch,
|
||||
repoWorktreeBaseRef: repo.worktreeBaseRef,
|
||||
resolveDefaultBaseRef: () =>
|
||||
hasLocalWorktreeGitOptions
|
||||
? resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))
|
||||
: Promise.resolve(getDefaultBaseRef(repo.path)),
|
||||
isBaseUsable: async (baseBranchCandidate) => {
|
||||
const remoteTrackingBase = await this.resolveRemoteTrackingBase(
|
||||
repo.path,
|
||||
baseBranchCandidate,
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
if (remoteTrackingBase) {
|
||||
if (
|
||||
await this.hasRemoteTrackingRef(
|
||||
repo.path,
|
||||
remoteTrackingBase,
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return hasLocalWorktreeBaseRef(
|
||||
repo.path,
|
||||
baseBranchCandidate,
|
||||
hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
|
||||
)
|
||||
}
|
||||
return hasLocalWorktreeBaseRef(
|
||||
repo.path,
|
||||
baseBranchCandidate,
|
||||
hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
|
||||
)
|
||||
}
|
||||
})
|
||||
if (!baseBranch) {
|
||||
// Why: getDefaultBaseRef returns null when no suitable ref exists.
|
||||
// Don't fabricate 'origin/main' — passing it to addWorktree would
|
||||
@@ -13128,44 +13193,61 @@ export class OrcaRuntimeService {
|
||||
`Could not find an available worktree path for "${sanitizedName}". Pick a different worktree name.`
|
||||
)
|
||||
}
|
||||
const remoteTrackingBase = await this.resolveRemoteTrackingBase(
|
||||
let remoteTrackingBase = await this.resolveRemoteTrackingBase(
|
||||
repo.path,
|
||||
baseBranch,
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
if (remoteTrackingBase) {
|
||||
const hadLocalBaseRef = await this.hasRemoteTrackingRef(
|
||||
const hadRemoteTrackingBaseRef = await this.hasRemoteTrackingRef(
|
||||
repo.path,
|
||||
remoteTrackingBase,
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh(
|
||||
repo.path,
|
||||
remoteTrackingBase,
|
||||
...localWorktreeGitOptionArgs
|
||||
)
|
||||
if (!refreshResult.ok && !hadLocalBaseRef) {
|
||||
// Why: only block creation when the refresh failed AND there is no
|
||||
// usable local base ref to fall back on. If a local remote-tracking ref
|
||||
// already exists, `git worktree add` can create from it — a possibly
|
||||
// stale but valid base — so a transient offline/auth failure must not
|
||||
// make the workspace uncreatable. The compare-to-base view reflects any
|
||||
// drift once the remote is reachable again.
|
||||
throw new Error(
|
||||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
!hadLocalBaseRef &&
|
||||
!(await this.hasRemoteTrackingRef(
|
||||
const hasLocalBaseRef =
|
||||
hadRemoteTrackingBaseRef ||
|
||||
(await hasLocalWorktreeBaseRef(
|
||||
repo.path,
|
||||
baseBranch,
|
||||
hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
|
||||
))
|
||||
if (!hadRemoteTrackingBaseRef && hasLocalBaseRef) {
|
||||
remoteTrackingBase = null
|
||||
} else {
|
||||
const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh(
|
||||
repo.path,
|
||||
remoteTrackingBase,
|
||||
...localWorktreeGitOptionArgs
|
||||
))
|
||||
) {
|
||||
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
|
||||
)
|
||||
if (!refreshResult.ok && !hadRemoteTrackingBaseRef) {
|
||||
// Why: only block creation when the refresh failed AND there is no
|
||||
// usable local base ref to fall back on. If a local remote-tracking ref
|
||||
// already exists, `git worktree add` can create from it — a possibly
|
||||
// stale but valid base — so a transient offline/auth failure must not
|
||||
// make the workspace uncreatable. The compare-to-base view reflects any
|
||||
// drift once the remote is reachable again.
|
||||
throw new Error(
|
||||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
!hadRemoteTrackingBaseRef &&
|
||||
!(await this.hasRemoteTrackingRef(
|
||||
repo.path,
|
||||
remoteTrackingBase,
|
||||
...localWorktreeGitOptionArgs
|
||||
))
|
||||
) {
|
||||
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
|
||||
}
|
||||
}
|
||||
} else if (!(await hasLocalCommitObject(repo.path, baseBranch))) {
|
||||
} else if (
|
||||
!(await hasLocalWorktreeBaseRef(
|
||||
repo.path,
|
||||
baseBranch,
|
||||
hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}
|
||||
))
|
||||
) {
|
||||
// Why: local bases keep legacy best-effort fetch behavior. Verified PR
|
||||
// SHA bases already have the commit object needed by `git worktree add`.
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,8 @@ describe('client UI RPC methods', () => {
|
||||
defaultRepoSelection: ['repo-1'],
|
||||
defaultLinearTeamSelection: ['team-1'],
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5',
|
||||
githubProjects: {
|
||||
pinned: [],
|
||||
recent: [],
|
||||
@@ -77,6 +79,8 @@ describe('client UI RPC methods', () => {
|
||||
defaultTaskViewPreset: 'my-prs',
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5',
|
||||
defaultRepoSelection: settings.defaultRepoSelection,
|
||||
defaultLinearTeamSelection: ['team-1', 'team-2'],
|
||||
githubProjects: settings.githubProjects
|
||||
@@ -91,6 +95,8 @@ describe('client UI RPC methods', () => {
|
||||
defaultTaskViewPreset: 'my-prs',
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
minimaxUsageModels: 'general,abab6.5',
|
||||
defaultRepoSelection: settings.defaultRepoSelection,
|
||||
defaultLinearTeamSelection: ['team-1', 'team-2'],
|
||||
githubProjects: settings.githubProjects
|
||||
@@ -176,7 +182,10 @@ describe('client UI RPC methods', () => {
|
||||
...getDefaultUIState(),
|
||||
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
|
||||
_worktreeCardModeDefaulted: true,
|
||||
statusBarItems: ['codex'],
|
||||
statusBarItems: ['codex', 'kimi', 'minimax', 'ports'],
|
||||
_portsStatusBarDefaultAdded: true,
|
||||
_kimiStatusBarDefaultAdded: true,
|
||||
_minimaxStatusBarDefaultAdded: true,
|
||||
taskResumeState: {
|
||||
githubMode: 'items',
|
||||
githubItemsQuery: 'is:open',
|
||||
@@ -212,7 +221,10 @@ describe('client UI RPC methods', () => {
|
||||
const payload = {
|
||||
worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'],
|
||||
_worktreeCardModeDefaulted: true,
|
||||
statusBarItems: ['codex'],
|
||||
statusBarItems: ['codex', 'kimi', 'minimax', 'ports'],
|
||||
_portsStatusBarDefaultAdded: true,
|
||||
_kimiStatusBarDefaultAdded: true,
|
||||
_minimaxStatusBarDefaultAdded: true,
|
||||
taskResumeState: {
|
||||
githubMode: 'items',
|
||||
githubItemsQuery: 'is:open',
|
||||
|
||||
@@ -40,7 +40,17 @@ const WorktreeCardProperties = z
|
||||
.array(LegacyWorktreeCardProperty)
|
||||
.transform((value) => normalizeWorktreeCardProperties(value))
|
||||
const AgentActivityDisplayMode = z.enum(['compact', 'full'])
|
||||
const StatusBarItem = z.enum(['claude', 'codex', 'gemini', 'opencode-go', 'ssh', 'resource-usage'])
|
||||
const StatusBarItem = z.enum([
|
||||
'claude',
|
||||
'codex',
|
||||
'gemini',
|
||||
'opencode-go',
|
||||
'kimi',
|
||||
'minimax',
|
||||
'ssh',
|
||||
'resource-usage',
|
||||
'ports'
|
||||
])
|
||||
const WorkspaceStatusDefinition = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
@@ -142,6 +152,8 @@ const SettingsUpdate = z
|
||||
defaultRepoSelection: z.array(z.string()).nullable().optional(),
|
||||
defaultLinearTeamSelection: z.array(z.string()).nullable().optional(),
|
||||
compactWorktreeCards: z.boolean().optional(),
|
||||
minimaxGroupId: z.string().optional(),
|
||||
minimaxUsageModels: z.string().optional(),
|
||||
githubProjects: GitHubProjectSettings.optional()
|
||||
})
|
||||
.strict()
|
||||
@@ -188,6 +200,9 @@ const UiUpdate = z
|
||||
_workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(),
|
||||
_workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(),
|
||||
statusBarItems: z.array(StatusBarItem).optional(),
|
||||
_portsStatusBarDefaultAdded: z.boolean().optional(),
|
||||
_kimiStatusBarDefaultAdded: z.boolean().optional(),
|
||||
_minimaxStatusBarDefaultAdded: z.boolean().optional(),
|
||||
statusBarVisible: z.boolean().optional(),
|
||||
dismissedUpdateVersion: NullableString.optional(),
|
||||
lastUpdateCheckAt: z.number().finite().nullable().optional(),
|
||||
|
||||
@@ -62,10 +62,12 @@ import {
|
||||
import type { Store } from '../persistence'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
|
||||
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed'
|
||||
|
||||
type RemoteCliBridgeEnv = {
|
||||
remoteHome: string
|
||||
binDir: string
|
||||
relayDir: string
|
||||
nodePath: string
|
||||
@@ -79,6 +81,13 @@ type ForwardedReplayFingerprint = {
|
||||
deliveredAt: number
|
||||
}
|
||||
|
||||
export type SshRelayAiVaultHostInfo = {
|
||||
targetId: string
|
||||
executionHostId: ExecutionHostId
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
}
|
||||
|
||||
const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000
|
||||
const REPLAY_FINGERPRINT_EDGE_CHARS = 128
|
||||
|
||||
@@ -189,6 +198,19 @@ export class SshRelaySession {
|
||||
return this.remoteCliBridgeEnv?.hostPlatform ?? this.hostPlatform
|
||||
}
|
||||
|
||||
getAiVaultHostInfo(): SshRelayAiVaultHostInfo | null {
|
||||
const env = this.remoteCliBridgeEnv
|
||||
if (!env) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
targetId: this.targetId,
|
||||
executionHostId: toSshExecutionHostId(this.targetId),
|
||||
remoteHome: env.remoteHome,
|
||||
hostPlatform: env.hostPlatform
|
||||
}
|
||||
}
|
||||
|
||||
getPortScanner(): PortScanner | null {
|
||||
return this.portScanner
|
||||
}
|
||||
@@ -218,6 +240,7 @@ export class SshRelaySession {
|
||||
this.remoteCliBridgeEnv =
|
||||
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
|
||||
? {
|
||||
remoteHome,
|
||||
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
|
||||
relayDir: remoteRelayDir,
|
||||
nodePath,
|
||||
@@ -345,6 +368,7 @@ export class SshRelaySession {
|
||||
this.remoteCliBridgeEnv =
|
||||
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
|
||||
? {
|
||||
remoteHome,
|
||||
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
|
||||
relayDir: remoteRelayDir,
|
||||
nodePath,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { createSshOperationAbortError } from './ssh-connection-utils'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { isSshSessionLimitError } from './ssh-session-limit-error'
|
||||
|
||||
export type RemoteNodeResolutionOptions = {
|
||||
rethrowSessionLimitErrors?: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
const NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS = 8_000
|
||||
|
||||
const NODE_PACKAGE_MANAGER_HINTS: readonly { bin: string; label: string; install: string }[] = [
|
||||
{ bin: 'apt-get', label: 'Debian/Ubuntu', install: 'sudo apt-get install -y nodejs npm' },
|
||||
{ bin: 'dnf', label: 'Fedora/RHEL', install: 'sudo dnf install -y nodejs npm' },
|
||||
{ bin: 'yum', label: 'RHEL/CentOS', install: 'sudo yum install -y nodejs npm' },
|
||||
{ bin: 'pacman', label: 'Arch', install: 'sudo pacman -S --needed nodejs npm' },
|
||||
{ bin: 'apk', label: 'Alpine', install: 'sudo apk add nodejs npm' },
|
||||
{ bin: 'zypper', label: 'openSUSE', install: 'sudo zypper install -y nodejs npm' },
|
||||
{ bin: 'brew', label: 'macOS/Homebrew', install: 'brew install node' }
|
||||
]
|
||||
|
||||
export async function buildPosixNodeInstallGuidance(
|
||||
conn: SshConnection,
|
||||
options?: RemoteNodeResolutionOptions
|
||||
): Promise<string> {
|
||||
const detectedBin = await detectPackageManager(conn, options)
|
||||
return formatNodeInstallHints(detectedBin)
|
||||
}
|
||||
|
||||
async function detectPackageManager(
|
||||
conn: SshConnection,
|
||||
options?: RemoteNodeResolutionOptions
|
||||
): Promise<string | null> {
|
||||
throwIfAborted(options)
|
||||
const bins = NODE_PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ')
|
||||
try {
|
||||
const output = await execCommand(
|
||||
conn,
|
||||
`for p in ${bins}; do if command -v "$p" >/dev/null 2>&1; then echo "$p"; break; fi; done`,
|
||||
commandOptions(options)
|
||||
)
|
||||
const detected = output.trim().split('\n')[0]
|
||||
return NODE_PACKAGE_MANAGER_HINTS.some((hint) => hint.bin === detected) ? detected : null
|
||||
} catch (err) {
|
||||
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
|
||||
throw err
|
||||
}
|
||||
throwIfAborted(options)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatNodeInstallHints(detectedBin: string | null): string {
|
||||
const tailored = detectedBin
|
||||
? NODE_PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === detectedBin)
|
||||
: null
|
||||
const lines = [
|
||||
'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.',
|
||||
'',
|
||||
'Install Node.js 18+ with npm on the remote host, then reconnect:'
|
||||
]
|
||||
if (tailored) {
|
||||
lines.push(` ${tailored.label}: ${tailored.install}`)
|
||||
} else {
|
||||
for (const hint of NODE_PACKAGE_MANAGER_HINTS) {
|
||||
lines.push(` ${hint.label}: ${hint.install}`)
|
||||
}
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
'Verify the remote runtime before reconnecting:',
|
||||
' node --version # must be v18 or newer',
|
||||
' npm --version',
|
||||
'',
|
||||
'If your distro package is older than Node 18, install an LTS release from https://nodejs.org/.'
|
||||
)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function commandOptions(options?: RemoteNodeResolutionOptions): {
|
||||
timeoutMs: number
|
||||
signal?: AbortSignal
|
||||
} {
|
||||
const base = { timeoutMs: NODE_PACKAGE_MANAGER_PROBE_TIMEOUT_MS }
|
||||
return options?.signal ? { ...base, signal: options.signal } : base
|
||||
}
|
||||
|
||||
function throwIfAborted(options?: RemoteNodeResolutionOptions): void {
|
||||
if (options?.signal?.aborted) {
|
||||
throw createSshOperationAbortError()
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,14 @@ const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution')
|
||||
|
||||
const conn = {} as SshConnection
|
||||
|
||||
function decodePowerShellCommand(command: string): string {
|
||||
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
|
||||
if (!match) {
|
||||
throw new Error(`No encoded PowerShell command found in: ${command}`)
|
||||
}
|
||||
return Buffer.from(match[1]!, 'base64').toString('utf16le')
|
||||
}
|
||||
|
||||
describe('resolveRemoteNodePath', () => {
|
||||
beforeEach(() => {
|
||||
execCommandMock.mockReset()
|
||||
@@ -88,10 +96,23 @@ describe('resolveRemoteNodePath', () => {
|
||||
|
||||
const callScript = execCommandMock.mock.calls[0]![1] as string
|
||||
expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node')
|
||||
expect(callScript).toContain('"$HOME/.local/share/fnm/node-versions"/*/installation/bin/node')
|
||||
expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node')
|
||||
expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node')
|
||||
})
|
||||
|
||||
it('probes fnm XDG data directory installs', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce(
|
||||
'/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node\n'
|
||||
)
|
||||
.mockResolvedValueOnce('v24.18.0\n')
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
|
||||
'/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not depend on GNU sort when probing version-manager directories', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n')
|
||||
@@ -258,8 +279,9 @@ describe('resolveRemoteNodePath', () => {
|
||||
.mockResolvedValueOnce('\n') // path probe: empty
|
||||
.mockResolvedValueOnce('/bin/bash') // $SHELL
|
||||
.mockResolvedValueOnce('\n') // command -v node: empty
|
||||
.mockResolvedValueOnce('apt-get\n') // package manager hint probe
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
|
||||
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo apt-get install -y nodejs npm/)
|
||||
})
|
||||
|
||||
it('throws when the path-probe exec fails and the login shell finds nothing', async () => {
|
||||
@@ -267,8 +289,9 @@ describe('resolveRemoteNodePath', () => {
|
||||
.mockRejectedValueOnce(new Error('SSH exec channel failed')) // path probe errors
|
||||
.mockResolvedValueOnce('/bin/zsh') // $SHELL
|
||||
.mockResolvedValueOnce('\n') // command -v node: empty
|
||||
.mockRejectedValueOnce(new Error('package manager probe failed'))
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
|
||||
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Debian\/Ubuntu:/)
|
||||
})
|
||||
|
||||
it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => {
|
||||
@@ -318,6 +341,21 @@ describe('resolveRemoteNodePath', () => {
|
||||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('rethrows SSH session-limit errors from package-manager hint probing in strict mode', async () => {
|
||||
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
||||
reason: 4
|
||||
})
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('\n')
|
||||
.mockResolvedValueOnce('/bin/bash')
|
||||
.mockResolvedValueOnce('\n')
|
||||
.mockRejectedValueOnce(sessionLimitError)
|
||||
|
||||
await expect(
|
||||
resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true })
|
||||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => {
|
||||
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
||||
reason: 4
|
||||
@@ -331,6 +369,21 @@ describe('resolveRemoteNodePath', () => {
|
||||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('rethrows SSH session-limit errors from Windows version checks in strict mode', async () => {
|
||||
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
||||
reason: 4
|
||||
})
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n')
|
||||
.mockRejectedValueOnce(sessionLimitError)
|
||||
|
||||
await expect(
|
||||
resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), {
|
||||
rethrowSessionLimitErrors: true
|
||||
})
|
||||
).rejects.toBe(sessionLimitError)
|
||||
})
|
||||
|
||||
it('surfaces an AbortError instead of not-found when the shared signal is aborted', async () => {
|
||||
// Why: in concurrent bootstrap a sibling probe aborts this resolution via
|
||||
// the shared signal. Each strategy catch swallows the injected AbortError,
|
||||
@@ -357,7 +410,32 @@ describe('resolveRemoteNodePath', () => {
|
||||
.mockResolvedValueOnce('/bin/bash') // $SHELL
|
||||
.mockResolvedValueOnce('/old/node2\n') // login shell
|
||||
.mockResolvedValueOnce('v6.17.0\n') // too old
|
||||
.mockResolvedValueOnce('dnf\n') // package manager hint probe
|
||||
|
||||
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
|
||||
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo dnf install -y nodejs npm/)
|
||||
})
|
||||
|
||||
it('rejects a Windows node.exe below the minimum version', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n') // Get-Command / common paths
|
||||
.mockResolvedValueOnce('v16.20.2\n') // version check: too old
|
||||
|
||||
await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).rejects.toThrow(
|
||||
/winget install OpenJS\.NodeJS\.LTS/
|
||||
)
|
||||
})
|
||||
|
||||
it('does not stop Windows node discovery after the first existing candidate', async () => {
|
||||
execCommandMock
|
||||
.mockResolvedValueOnce('C:\\old\\node.exe\nC:\\Program Files\\nodejs\\node.exe\n')
|
||||
.mockResolvedValueOnce('v16.20.2\n')
|
||||
.mockResolvedValueOnce('v20.11.0\n')
|
||||
|
||||
await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).resolves.toBe(
|
||||
'C:/Program Files/nodejs/node.exe'
|
||||
)
|
||||
|
||||
const discoveryScript = decodePowerShellCommand(execCommandMock.mock.calls[0]![1] as string)
|
||||
expect(discoveryScript).not.toMatch(/Write-Output \$path\s+exit 0/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { SshConnection } from './ssh-connection'
|
||||
import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils'
|
||||
import type { RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform'
|
||||
import { powerShellCommand } from './ssh-remote-powershell'
|
||||
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
|
||||
import {
|
||||
buildPosixNodeInstallGuidance,
|
||||
type RemoteNodeResolutionOptions
|
||||
} from './ssh-remote-node-install-guidance'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { isSshSessionLimitError } from './ssh-session-limit-error'
|
||||
|
||||
@@ -16,11 +20,6 @@ const MIN_NODE_MAJOR = 18
|
||||
// hang a login shell, so keep this short.
|
||||
const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000
|
||||
|
||||
type RemoteNodeResolutionOptions = {
|
||||
rethrowSessionLimitErrors?: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export async function resolveRemoteNodePath(
|
||||
conn: SshConnection,
|
||||
host?: RemoteHostPlatform,
|
||||
@@ -47,7 +46,7 @@ export async function resolveRemoteNodePath(
|
||||
return loginShellPath
|
||||
}
|
||||
|
||||
throwNodeNotFound(options)
|
||||
return throwNodeNotFound(conn, options)
|
||||
}
|
||||
|
||||
// Probe the on-disk install directories of every common Node version manager
|
||||
@@ -91,6 +90,7 @@ for candidate in \\
|
||||
"$HOME/.local/bin/node" \\
|
||||
"$HOME/.fnm/aliases/default/bin/node" \\
|
||||
"$HOME/.fnm/node-versions"/*/installation/bin/node \\
|
||||
"$HOME/.local/share/fnm/node-versions"/*/installation/bin/node \\
|
||||
"$HOME/.local/share/mise/shims/node" \\
|
||||
"$HOME/.local/share/mise/installs/node"/*/bin/node \\
|
||||
"$HOME/.asdf/shims/node" \\
|
||||
@@ -104,7 +104,7 @@ true
|
||||
`
|
||||
|
||||
try {
|
||||
const result = await execCommand(conn, script, { signal: options?.signal })
|
||||
const result = await execCommandWithOptionalOptions(conn, script, signalOnlyOptions(options))
|
||||
const seen = new Set<string>()
|
||||
for (const line of result.split('\n')) {
|
||||
const candidate = line.trim()
|
||||
@@ -138,20 +138,21 @@ async function tryResolveViaLoginShell(
|
||||
// Using it — rather than hardcoding bash — means zsh/fish users whose
|
||||
// custom PATH hooks live in profile files get coverage too. We fall back
|
||||
// to sh if $SHELL is unset (rare, e.g. restricted accounts).
|
||||
const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', {
|
||||
timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS,
|
||||
signal: options?.signal
|
||||
})
|
||||
const shellResult = await execCommand(
|
||||
conn,
|
||||
'echo "${SHELL:-/bin/sh}"',
|
||||
commandOptions({ timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options)
|
||||
)
|
||||
const shell = shellResult.trim().split('\n')[0]
|
||||
if (!shell) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nodePath = await execCommand(conn, buildCommandInShell(shell, 'command -v node'), {
|
||||
wrapCommand: false,
|
||||
timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS,
|
||||
signal: options?.signal
|
||||
})
|
||||
const nodePath = await execCommand(
|
||||
conn,
|
||||
buildCommandInShell(shell, 'command -v node'),
|
||||
commandOptions({ wrapCommand: false, timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options)
|
||||
)
|
||||
const candidate = nodePath.trim().split('\n')[0]
|
||||
if (!candidate) {
|
||||
return null
|
||||
@@ -187,16 +188,12 @@ async function nodeMeetsVersionRequirement(
|
||||
options?: RemoteNodeResolutionOptions
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, {
|
||||
wrapCommand: false,
|
||||
signal: options?.signal
|
||||
})
|
||||
const match = versionOutput.trim().match(/^v?(\d+)/)
|
||||
if (!match) {
|
||||
return false
|
||||
}
|
||||
const major = Number.parseInt(match[1]!, 10)
|
||||
return major >= MIN_NODE_MAJOR
|
||||
const versionOutput = await execCommand(
|
||||
conn,
|
||||
`${shellEscape(nodePath)} --version`,
|
||||
commandOptions({ wrapCommand: false }, options)
|
||||
)
|
||||
return nodeVersionMeetsRequirement(versionOutput)
|
||||
} catch (err) {
|
||||
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
|
||||
throw err
|
||||
@@ -217,26 +214,34 @@ async function resolveRemoteWindowsNodePath(
|
||||
'if ($env:ProgramFiles) { $paths += (Join-Path $env:ProgramFiles "nodejs/node.exe") }',
|
||||
'if (${env:ProgramFiles(x86)}) { $paths += (Join-Path ${env:ProgramFiles(x86)} "nodejs/node.exe") }',
|
||||
'if ($env:LOCALAPPDATA) { $paths += (Join-Path $env:LOCALAPPDATA "Programs/nodejs/node.exe") }',
|
||||
'$found = $false',
|
||||
'foreach ($path in $paths) {',
|
||||
' if ($path -and (Test-Path -LiteralPath $path -PathType Leaf)) {',
|
||||
' Write-Output $path',
|
||||
' exit 0',
|
||||
' $found = $true',
|
||||
' }',
|
||||
'}',
|
||||
'if ($found) { exit 0 }',
|
||||
"Write-Error 'Node.js not found'",
|
||||
'exit 1'
|
||||
].join('\n')
|
||||
|
||||
try {
|
||||
const result = await execCommand(conn, powerShellCommand(script), {
|
||||
wrapCommand: false,
|
||||
signal: options?.signal
|
||||
})
|
||||
const nodePath = result.trim().split('\n')[0]
|
||||
if (nodePath) {
|
||||
const result = await execCommand(
|
||||
conn,
|
||||
powerShellCommand(script),
|
||||
commandOptions({ wrapCommand: false }, options)
|
||||
)
|
||||
for (const line of result.split('\n')) {
|
||||
const nodePath = line.trim()
|
||||
if (!nodePath) {
|
||||
continue
|
||||
}
|
||||
const normalized = normalizeWindowsRemotePath(nodePath)
|
||||
console.log(`[ssh-relay] Found Windows node at: ${normalized}`)
|
||||
return normalized
|
||||
if (await windowsNodeMeetsVersionRequirement(conn, normalized, options)) {
|
||||
console.log(`[ssh-relay] Found Windows node at: ${normalized}`)
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
|
||||
@@ -245,20 +250,98 @@ async function resolveRemoteWindowsNodePath(
|
||||
// Fall through to the shared error below.
|
||||
}
|
||||
|
||||
throwNodeNotFound(options)
|
||||
throwWindowsNodeNotFound(options)
|
||||
}
|
||||
|
||||
function throwNodeNotFound(options?: RemoteNodeResolutionOptions): never {
|
||||
// Why: when a sibling bootstrap probe aborts this resolution via the shared
|
||||
// signal, the strategy catch-blocks swallow the injected AbortError and fall
|
||||
// through to here. Surfacing "Node.js not found" would launder a cancellation
|
||||
// into a fatal non-session failure, defeating the concurrent-bootstrap
|
||||
// sequential fallback. Re-raise the abort so callers classify it correctly.
|
||||
async function windowsNodeMeetsVersionRequirement(
|
||||
conn: SshConnection,
|
||||
nodePath: string,
|
||||
options?: RemoteNodeResolutionOptions
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const versionOutput = await execCommand(
|
||||
conn,
|
||||
powerShellCommand(`& ${powerShellLiteral(nodePath)} --version`),
|
||||
commandOptions({ wrapCommand: false }, options)
|
||||
)
|
||||
return nodeVersionMeetsRequirement(versionOutput)
|
||||
} catch (err) {
|
||||
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
|
||||
throw err
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function nodeVersionMeetsRequirement(versionOutput: string): boolean {
|
||||
const match = versionOutput.trim().match(/^v?(\d+)/)
|
||||
if (!match) {
|
||||
return false
|
||||
}
|
||||
const major = Number.parseInt(match[1]!, 10)
|
||||
return major >= MIN_NODE_MAJOR
|
||||
}
|
||||
|
||||
async function throwNodeNotFound(
|
||||
conn: SshConnection,
|
||||
options?: RemoteNodeResolutionOptions
|
||||
): Promise<never> {
|
||||
throwIfAborted(options)
|
||||
const guidance = await buildPosixNodeInstallGuidance(conn, options)
|
||||
throwIfAborted(options)
|
||||
throw new Error(guidance)
|
||||
}
|
||||
|
||||
function throwWindowsNodeNotFound(options?: RemoteNodeResolutionOptions): never {
|
||||
throwIfAborted(options)
|
||||
throw new Error(
|
||||
[
|
||||
'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.',
|
||||
'',
|
||||
'Install Node.js 18+ on the remote host, then reconnect:',
|
||||
' winget install OpenJS.NodeJS.LTS',
|
||||
' choco install nodejs-lts',
|
||||
'',
|
||||
'Verify the remote runtime before reconnecting:',
|
||||
' node --version # must be v18 or newer',
|
||||
' npm --version',
|
||||
'',
|
||||
'If those package managers are unavailable, install an LTS release from https://nodejs.org/.'
|
||||
].join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
function throwIfAborted(options?: RemoteNodeResolutionOptions): void {
|
||||
// Why: strategy fallbacks intentionally swallow probe failures, but a shared
|
||||
// bootstrap abort must stay an AbortError so callers can continue fallback.
|
||||
if (options?.signal?.aborted) {
|
||||
throw createSshOperationAbortError()
|
||||
}
|
||||
throw new Error(
|
||||
'Node.js not found on remote host. Orca relay requires Node.js 18+. ' +
|
||||
'Install Node.js on the remote and try again.'
|
||||
)
|
||||
}
|
||||
|
||||
function signalOnlyOptions(
|
||||
options?: RemoteNodeResolutionOptions
|
||||
): { signal: AbortSignal } | undefined {
|
||||
return options?.signal ? { signal: options.signal } : undefined
|
||||
}
|
||||
|
||||
type RemoteExecOptions = {
|
||||
wrapCommand?: boolean
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
function commandOptions(
|
||||
base: RemoteExecOptions,
|
||||
options?: RemoteNodeResolutionOptions
|
||||
): RemoteExecOptions {
|
||||
return options?.signal ? { ...base, signal: options.signal } : base
|
||||
}
|
||||
|
||||
async function execCommandWithOptionalOptions(
|
||||
conn: SshConnection,
|
||||
command: string,
|
||||
options?: { signal: AbortSignal }
|
||||
): Promise<string> {
|
||||
return options ? execCommand(conn, command, options) : execCommand(conn, command)
|
||||
}
|
||||
|
||||
@@ -187,6 +187,86 @@ describe('fetchNewerReleaseTag', () => {
|
||||
expect(await fetchNewerReleaseTag('1.3.19-rc.6')).toBe('v1.3.20-rc.1')
|
||||
})
|
||||
|
||||
it('ignores perf-tagged prereleases for regular RC checks', async () => {
|
||||
respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.6', 'v1.4.121-rc.5'])
|
||||
|
||||
const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed')
|
||||
|
||||
expect(await fetchNewerReleaseTag('1.4.121-rc.5', { includePrerelease: true })).toBe(
|
||||
'v1.4.121-rc.6'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports no RC update when only perf-tagged prereleases are newer', async () => {
|
||||
respondWithAtom(['v1.4.121-rc.6.perf', 'v1.4.121-rc.5'])
|
||||
|
||||
const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed')
|
||||
|
||||
await expect(
|
||||
fetchNewerReleaseTagsWithReadiness('1.4.121-rc.5', 1, { includePrerelease: true })
|
||||
).resolves.toEqual({
|
||||
tags: [],
|
||||
state: 'no-newer'
|
||||
})
|
||||
})
|
||||
|
||||
it('picks the semver-newest perf-tagged prerelease', async () => {
|
||||
respondWithAtom([
|
||||
'v1.4.121-rc.6.perf',
|
||||
'v1.4.121-rc.7',
|
||||
'v1.4.121-rc.5.perf',
|
||||
'v1.4.121-rc.6',
|
||||
'v1.4.122'
|
||||
])
|
||||
|
||||
const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed')
|
||||
|
||||
expect(
|
||||
await fetchNewerReleaseTag('1.4.120', {
|
||||
includePrerelease: true,
|
||||
releaseFilter: 'perf'
|
||||
})
|
||||
).toBe('v1.4.121-rc.6.perf')
|
||||
})
|
||||
|
||||
it('matches only literal rc.N.perf prerelease tags for perf checks', async () => {
|
||||
respondWithAtom([
|
||||
'v1.4.121-rc.7.performance',
|
||||
'v1.4.121-beta.7.perf',
|
||||
'v1.4.121-rc.7.perf.extra',
|
||||
'v1.4.121-rc.6.perf'
|
||||
])
|
||||
|
||||
const { fetchNewerReleaseTag, isPerfPrereleaseTag } = await import('./updater-prerelease-feed')
|
||||
|
||||
expect(isPerfPrereleaseTag('v1.4.121-rc.6.perf')).toBe(true)
|
||||
expect(isPerfPrereleaseTag('v1.4.121-rc.7.performance')).toBe(false)
|
||||
expect(isPerfPrereleaseTag('v1.4.121-beta.7.perf')).toBe(false)
|
||||
expect(isPerfPrereleaseTag('v1.4.121-rc.7.perf.extra')).toBe(false)
|
||||
expect(
|
||||
await fetchNewerReleaseTag('1.4.120', {
|
||||
includePrerelease: true,
|
||||
releaseFilter: 'perf'
|
||||
})
|
||||
).toBe('v1.4.121-rc.6.perf')
|
||||
})
|
||||
|
||||
it('reports no perf update instead of falling back to stable or RC tags', async () => {
|
||||
respondWithAtom(['v1.4.122', 'v1.4.121-rc.7', 'v1.4.121'])
|
||||
|
||||
const { fetchNewerReleaseTagsWithReadiness } = await import('./updater-prerelease-feed')
|
||||
|
||||
await expect(
|
||||
fetchNewerReleaseTagsWithReadiness('1.4.120', 1, {
|
||||
includePrerelease: true,
|
||||
releaseFilter: 'perf'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
tags: [],
|
||||
state: 'no-newer'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a bounded fallback candidate after the newest newer tag', async () => {
|
||||
respondWithAtom(['v1.3.51-rc.7', 'v1.3.51-rc.6', 'v1.3.51-rc.5'])
|
||||
const { fetchNewerReleaseTags } = await import('./updater-prerelease-feed')
|
||||
|
||||
@@ -43,6 +43,18 @@ type ReleaseFeedTag = {
|
||||
version: string
|
||||
}
|
||||
|
||||
export function isPerfPrereleaseTag(tag: string): boolean {
|
||||
const version = normalizeTagToVersion(tag)
|
||||
const match = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-.]+)(?:\+[0-9A-Za-z-.]+)?$/)
|
||||
const identifiers = match?.[1]?.split('.') ?? []
|
||||
return (
|
||||
identifiers.length === 3 &&
|
||||
identifiers[0] === 'rc' &&
|
||||
/^\d+$/.test(identifiers[1]) &&
|
||||
identifiers[2] === 'perf'
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchReleaseFeedTags(): Promise<ReleaseFeedTag[] | null> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
@@ -156,6 +168,7 @@ async function hasReadyPlatformManifest(tag: string): Promise<boolean> {
|
||||
*/
|
||||
type FetchNewerReleaseTagOptions = {
|
||||
includePrerelease?: boolean
|
||||
releaseFilter?: 'perf'
|
||||
}
|
||||
|
||||
export type FetchNewerReleaseTagsResult = {
|
||||
@@ -190,9 +203,14 @@ export async function fetchNewerReleaseTagsWithReadiness(
|
||||
return { tags: [], state: 'unavailable' }
|
||||
}
|
||||
|
||||
const candidates = includePrerelease
|
||||
? tags
|
||||
: tags.filter(({ version }) => !isPrereleaseVersion(version))
|
||||
// Why: perf builds are explicit opt-in; regular prerelease checks should
|
||||
// stay on the main RC/stable series even though perf tags are semver-newer.
|
||||
const candidates =
|
||||
options.releaseFilter === 'perf'
|
||||
? tags.filter(({ tag }) => isPerfPrereleaseTag(tag))
|
||||
: includePrerelease
|
||||
? tags.filter(({ tag }) => !isPerfPrereleaseTag(tag))
|
||||
: tags.filter(({ version }) => !isPrereleaseVersion(version))
|
||||
const newestNewerIndex = candidates.findIndex(
|
||||
({ version }) => compareVersions(version, currentVersion) > 0
|
||||
)
|
||||
|
||||
@@ -1012,6 +1012,97 @@ describe('updater', () => {
|
||||
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls + 1)
|
||||
})
|
||||
|
||||
it('pins the generic feed to a perf-tagged prerelease when requested', async () => {
|
||||
appMock.getVersion.mockReturnValue('1.4.120')
|
||||
fetchNewerReleaseTagsMock.mockResolvedValue(['v1.4.121-rc.6.perf'])
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const mainWindow = { webContents: { send: vi.fn() } }
|
||||
|
||||
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
|
||||
|
||||
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
|
||||
|
||||
checkForUpdatesFromMenu({ includePerfPrerelease: true })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, {
|
||||
includePrerelease: true,
|
||||
releaseFilter: 'perf'
|
||||
})
|
||||
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
|
||||
provider: 'generic',
|
||||
url: 'https://github.com/stablyai/orca/releases/download/v1.4.121-rc.6.perf'
|
||||
})
|
||||
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(autoUpdaterMock.allowPrerelease).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces no-update feedback when no newer perf-tagged prerelease exists', async () => {
|
||||
appMock.getVersion.mockReturnValue('1.4.120')
|
||||
fetchNewerReleaseTagsMock.mockResolvedValue({ tags: [], state: 'no-newer' })
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = { webContents: { send: sendMock } }
|
||||
|
||||
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
|
||||
|
||||
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
|
||||
const setupFeedUrlCalls = autoUpdaterMock.setFeedURL.mock.calls.length
|
||||
|
||||
checkForUpdatesFromMenu({ includePerfPrerelease: true })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendMock).toHaveBeenCalledWith('updater:status', {
|
||||
state: 'not-available',
|
||||
userInitiated: true
|
||||
})
|
||||
})
|
||||
expect(fetchNewerReleaseTagsMock).toHaveBeenCalledWith('1.4.120', 2, {
|
||||
includePrerelease: true,
|
||||
releaseFilter: 'perf'
|
||||
})
|
||||
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
|
||||
expect(autoUpdaterMock.setFeedURL.mock.calls.length).toBe(setupFeedUrlCalls)
|
||||
})
|
||||
|
||||
it('keeps background retries on the stable channel after a perf publishing-window miss', async () => {
|
||||
vi.useFakeTimers()
|
||||
appMock.getVersion.mockReturnValue('1.4.120')
|
||||
fetchNewerReleaseTagsMock
|
||||
.mockResolvedValueOnce({ tags: [], state: 'not-ready' })
|
||||
.mockResolvedValueOnce(['v1.4.121'])
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const sendMock = vi.fn()
|
||||
const mainWindow = { webContents: { send: sendMock } }
|
||||
|
||||
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
|
||||
|
||||
setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() })
|
||||
|
||||
checkForUpdatesFromMenu({ includePerfPrerelease: true })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendMock).toHaveBeenCalledWith('updater:status', {
|
||||
state: 'error',
|
||||
message: "Couldn't reach the update server. Try again in a few minutes.",
|
||||
userInitiated: true
|
||||
})
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchNewerReleaseTagsMock).toHaveBeenNthCalledWith(2, '1.4.120', 1, {
|
||||
includePrerelease: false
|
||||
})
|
||||
expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({
|
||||
provider: 'generic',
|
||||
url: 'https://github.com/stablyai/orca/releases/download/v1.4.121'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the feed URL alone for a normal user-initiated check', async () => {
|
||||
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
|
||||
const mainWindow = { webContents: { send: vi.fn() } }
|
||||
|
||||
+95
-27
@@ -2,7 +2,7 @@
|
||||
import { app, BrowserWindow, powerMonitor } from 'electron'
|
||||
import type { NsisUpdater } from 'electron-updater'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import type { UpdateStatus } from '../shared/types'
|
||||
import type { UpdateCheckOptions, UpdateStatus } from '../shared/types'
|
||||
import { killAllPty } from './ipc/pty'
|
||||
import { withUpdaterSpan } from './observability/instrumentation'
|
||||
import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader'
|
||||
@@ -29,6 +29,8 @@ import { fetchNudge, shouldApplyNudge } from './updater-nudge'
|
||||
type CheckFailureSource = 'event' | 'promise' | 'fallback-promise'
|
||||
type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean }
|
||||
type PrimaryEventSuppression = { failureKey: string; error: unknown }
|
||||
type UpdateCheckVariant = 'default' | 'prerelease' | 'perf'
|
||||
type ReleaseFeedPreflightResult = 'ready' | 'not-available'
|
||||
|
||||
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
|
||||
@@ -44,9 +46,9 @@ let currentStatus: UpdateStatus = { state: 'idle' }
|
||||
let userInitiatedCheck = false
|
||||
let onBeforeQuitCleanup: (() => void | Promise<void>) | null = null
|
||||
let autoUpdaterInitialized = false
|
||||
// Why: Shift-clicking "Check for Updates" opts the user into the RC release
|
||||
// channel for the rest of this process. The generic feed still gets pinned to
|
||||
// a concrete tag on every check so cancelled RCs without manifests are skipped.
|
||||
// Why: modifier-clicking "Check for Updates" can target prerelease manifests.
|
||||
// The generic feed still gets pinned to a concrete tag on every check so
|
||||
// cancelled prereleases without manifests are skipped.
|
||||
let includePrereleaseActive = false
|
||||
let availableVersion: string | null = null
|
||||
let availableReleaseUrl: string | null = null
|
||||
@@ -69,7 +71,7 @@ let activeUpdateCheckAttemptId: number | null = null
|
||||
let activeUpdateCheckLaunchAttemptId: number | null = null
|
||||
let activeUpdateCheckEventAttemptId: number | null = null
|
||||
let updateAvailableEventPendingAttemptId: number | null = null
|
||||
let pendingPrereleaseUserInitiatedCheckAfterInFlight = false
|
||||
let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null
|
||||
let activeUpdateNudgeId: string | null = null
|
||||
let awaitingNudgeCheckOutcome = false
|
||||
let nudgeCheckInFlight = false
|
||||
@@ -156,8 +158,9 @@ function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus {
|
||||
}
|
||||
|
||||
function sendStatus(status: UpdateStatus): void {
|
||||
const shouldLaunchPendingPrereleaseCheck =
|
||||
pendingPrereleaseUserInitiatedCheckAfterInFlight &&
|
||||
const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight
|
||||
const shouldLaunchPendingUserInitiatedCheck =
|
||||
pendingUserInitiatedCheckVariant !== null &&
|
||||
(status.state === 'idle' ||
|
||||
status.state === 'not-available' ||
|
||||
status.state === 'available' ||
|
||||
@@ -225,8 +228,8 @@ function sendStatus(status: UpdateStatus): void {
|
||||
) {
|
||||
downloadInFlight = false
|
||||
}
|
||||
if (shouldLaunchPendingPrereleaseCheck) {
|
||||
launchPendingPrereleaseUserInitiatedCheckAfterInFlight()
|
||||
if (shouldLaunchPendingUserInitiatedCheck) {
|
||||
launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant)
|
||||
return
|
||||
}
|
||||
if (statusesEqual(currentStatus, decoratedStatus)) {
|
||||
@@ -236,16 +239,37 @@ function sendStatus(status: UpdateStatus): void {
|
||||
mainWindowRef?.webContents.send('updater:status', decoratedStatus)
|
||||
}
|
||||
|
||||
function launchPendingPrereleaseUserInitiatedCheckAfterInFlight(): void {
|
||||
pendingPrereleaseUserInitiatedCheckAfterInFlight = false
|
||||
function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions {
|
||||
switch (variant) {
|
||||
case 'perf':
|
||||
return { includePrerelease: true, includePerfPrerelease: true }
|
||||
case 'prerelease':
|
||||
return { includePrerelease: true }
|
||||
case 'default':
|
||||
return { includePrerelease: false }
|
||||
}
|
||||
}
|
||||
|
||||
function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant {
|
||||
if (options?.includePerfPrerelease) {
|
||||
return 'perf'
|
||||
}
|
||||
if (options?.includePrerelease) {
|
||||
return 'prerelease'
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void {
|
||||
pendingUserInitiatedCheckAfterInFlight = null
|
||||
setTimeout(() => {
|
||||
// Why: electron-updater clears its in-flight promise after emitting the
|
||||
// terminal event. Deferring one tick lets the queued RC check start fresh
|
||||
// instead of being deduped into the just-finished stable check.
|
||||
// terminal event. Deferring one tick lets the queued modifier check start
|
||||
// fresh instead of being deduped into the just-finished stable check.
|
||||
if (currentStatus.state === 'checking') {
|
||||
currentStatus = { state: 'idle' }
|
||||
}
|
||||
checkForUpdatesFromMenu({ includePrerelease: true })
|
||||
checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant))
|
||||
}, 0)
|
||||
}
|
||||
|
||||
@@ -769,7 +793,9 @@ function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): v
|
||||
)
|
||||
}
|
||||
|
||||
async function pinDefaultReleaseFeed(): Promise<void> {
|
||||
async function pinDefaultReleaseFeed(
|
||||
variant: UpdateCheckVariant = 'default'
|
||||
): Promise<ReleaseFeedPreflightResult> {
|
||||
const autoUpdater = getAutoUpdater()
|
||||
// Why: the /releases/latest/download/ redirect can move between the update
|
||||
// check and the later manual download click. Pinning to the concrete tag
|
||||
@@ -778,12 +804,15 @@ async function pinDefaultReleaseFeed(): Promise<void> {
|
||||
// Prerelease users still need any-channel resolution so they can move to a
|
||||
// newer RC or the next stable. Stable users should only resolve stable tags.
|
||||
const currentVersion = app.getVersion()
|
||||
const includePrerelease = includePrereleaseActive || isPrereleaseVersion(currentVersion)
|
||||
const isPerfCheck = variant === 'perf'
|
||||
const includePrerelease =
|
||||
isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion)
|
||||
const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness(
|
||||
currentVersion,
|
||||
includePrerelease ? 2 : 1,
|
||||
{
|
||||
includePrerelease
|
||||
includePrerelease,
|
||||
...(isPerfCheck ? { releaseFilter: 'perf' as const } : {})
|
||||
}
|
||||
)
|
||||
const newerTag = releaseTagsResult.tags[0] ?? null
|
||||
@@ -814,6 +843,7 @@ async function pinDefaultReleaseFeed(): Promise<void> {
|
||||
`[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}`
|
||||
)
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url })
|
||||
return 'ready'
|
||||
} else if (releaseTagsResult.state === 'not-ready') {
|
||||
clearPrereleaseFallbackContext()
|
||||
if (releaseTagsResult.lastGoodTag) {
|
||||
@@ -825,13 +855,23 @@ async function pinDefaultReleaseFeed(): Promise<void> {
|
||||
)
|
||||
publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag }
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url })
|
||||
return
|
||||
return 'ready'
|
||||
}
|
||||
clearPublishingWindowLastGoodCheck()
|
||||
console.info(
|
||||
`[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are still publishing`
|
||||
)
|
||||
throw new Error('Latest release assets are still publishing')
|
||||
} else if (isPerfCheck) {
|
||||
clearPrereleaseFallbackContext()
|
||||
clearPublishingWindowLastGoodCheck()
|
||||
if (releaseTagsResult.state === 'no-newer') {
|
||||
console.info(
|
||||
`[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}`
|
||||
)
|
||||
return 'not-available'
|
||||
}
|
||||
throw new Error('Could not resolve perf update feed')
|
||||
} else {
|
||||
clearPrereleaseFallbackContext()
|
||||
clearPublishingWindowLastGoodCheck()
|
||||
@@ -840,6 +880,7 @@ async function pinDefaultReleaseFeed(): Promise<void> {
|
||||
`[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}`
|
||||
)
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url })
|
||||
return 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -972,6 +1013,10 @@ export function checkForUpdates(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function enablePrereleaseManifestChecks(): void {
|
||||
getAutoUpdater().allowPrerelease = true
|
||||
}
|
||||
|
||||
function enableIncludePrerelease(): void {
|
||||
if (includePrereleaseActive) {
|
||||
return
|
||||
@@ -980,20 +1025,26 @@ function enableIncludePrerelease(): void {
|
||||
// accept a prerelease manifest for users who intentionally Shift-clicked.
|
||||
// We keep using the manifest-probed generic feed instead of the native
|
||||
// GitHub provider because cancelled RC releases can appear without assets.
|
||||
getAutoUpdater().allowPrerelease = true
|
||||
enablePrereleaseManifestChecks()
|
||||
includePrereleaseActive = true
|
||||
}
|
||||
|
||||
/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */
|
||||
export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean }): void {
|
||||
export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void {
|
||||
if (!app.isPackaged || is.dev) {
|
||||
sendStatus({ state: 'not-available', userInitiated: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (options?.includePrerelease) {
|
||||
const checkVariant = getUpdateCheckVariant(options)
|
||||
if (checkVariant === 'prerelease') {
|
||||
clearPrereleaseFallbackContext()
|
||||
enableIncludePrerelease()
|
||||
} else if (checkVariant === 'perf') {
|
||||
clearPrereleaseFallbackContext()
|
||||
// Why: perf checks need prerelease manifests for this check, but must not
|
||||
// opt future default/background checks into the RC channel.
|
||||
enablePrereleaseManifestChecks()
|
||||
}
|
||||
|
||||
const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking'
|
||||
@@ -1009,10 +1060,10 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
|
||||
if (checkAlreadyInFlight) {
|
||||
backgroundCheckPromotedToUserInitiated = true
|
||||
rearmActiveUpdateCheckStallTimer()
|
||||
if (options?.includePrerelease) {
|
||||
// Why: the in-flight check may have already pinned the stable feed.
|
||||
// Queue a fresh RC check so Shift-click doesn't inherit a stable result.
|
||||
pendingPrereleaseUserInitiatedCheckAfterInFlight = true
|
||||
if (checkVariant !== 'default') {
|
||||
// Why: the in-flight check may have already pinned the stable feed. Queue
|
||||
// a fresh modifier check so it doesn't inherit a stale-channel result.
|
||||
pendingUserInitiatedCheckAfterInFlight = checkVariant
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1026,9 +1077,26 @@ export function checkForUpdatesFromMenu(options?: { includePrerelease?: boolean
|
||||
markUpdateCheckLaunched(attemptId)
|
||||
return autoUpdater.checkForUpdates()
|
||||
}
|
||||
const run = pinDefaultReleaseFeed().then(launch)
|
||||
const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => {
|
||||
if (preflightResult === 'not-available') {
|
||||
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
||||
return false
|
||||
}
|
||||
userInitiatedCheck = false
|
||||
finishActiveUpdateCheckAttempt()
|
||||
recordCompletedUpdateCheck()
|
||||
sendStatus({ state: 'not-available', userInitiated: true })
|
||||
return false
|
||||
}
|
||||
return launch()
|
||||
})
|
||||
void Promise.resolve(run)
|
||||
.then(() => handleSettledUpdateCheckPromise(attemptId))
|
||||
.then((launchResult) => {
|
||||
if (launchResult === false) {
|
||||
return
|
||||
}
|
||||
handleSettledUpdateCheckPromise(attemptId)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!isActiveUpdateCheckAttempt(attemptId)) {
|
||||
return
|
||||
|
||||
@@ -4,7 +4,11 @@ import { randomUUID } from 'node:crypto'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type { CreateWorktreeResult, WorktreeStartupLaunch } from '../../shared/types'
|
||||
import type {
|
||||
CreateWorktreeResult,
|
||||
UpdateCheckOptions,
|
||||
WorktreeStartupLaunch
|
||||
} from '../../shared/types'
|
||||
import { registerRepoHandlers } from '../ipc/repos'
|
||||
import { registerWorktreeHandlers } from '../ipc/worktrees'
|
||||
import { registerWorkspaceCleanupHandlers } from '../ipc/workspace-cleanup'
|
||||
@@ -448,7 +452,7 @@ export function registerUpdaterHandlers(_store: Store): void {
|
||||
|
||||
ipcMain.handle('updater:getStatus', () => getUpdateStatus())
|
||||
ipcMain.handle('updater:getVersion', () => app.getVersion())
|
||||
ipcMain.handle('updater:check', (_event, options?: { includePrerelease?: boolean }) => {
|
||||
ipcMain.handle('updater:check', (_event, options?: UpdateCheckOptions) => {
|
||||
ensureAutoUpdaterConfigured()
|
||||
return checkForUpdatesFromMenu(options)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
import type { Repo } from '../shared/types'
|
||||
import { hasLocalCommitObject } from './git/commit-object-ref'
|
||||
import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref'
|
||||
import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe'
|
||||
import { getDefaultBaseRef } from './git/repo'
|
||||
import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote'
|
||||
import { resolveWorktreeCreateBase } from './worktree-create-base'
|
||||
import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref'
|
||||
|
||||
type RemoteTrackingBaseForPrefetch = {
|
||||
remote: string
|
||||
@@ -17,6 +20,7 @@ type WorktreeCreateBasePrefetchRuntime = {
|
||||
repoPath: string,
|
||||
baseBranch: string
|
||||
) => Promise<RemoteTrackingBaseForPrefetch | null>
|
||||
hasRemoteTrackingRef: (repoPath: string, base: RemoteTrackingBaseForPrefetch) => Promise<boolean>
|
||||
getOrStartRemoteTrackingBaseRefresh: (
|
||||
repoPath: string,
|
||||
base: RemoteTrackingBaseForPrefetch
|
||||
@@ -24,23 +28,62 @@ type WorktreeCreateBasePrefetchRuntime = {
|
||||
fetchRemoteWithCache: (repoPath: string, remote: string) => Promise<void>
|
||||
}
|
||||
|
||||
async function hasLocalWorktreeBaseRef(repoPath: string, baseRef: string): Promise<boolean> {
|
||||
const refExists = (qualifiedRef: string) => hasWorktreeBaseCommitRef(repoPath, qualifiedRef)
|
||||
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
|
||||
if (resolvedBaseRef !== baseRef) {
|
||||
return true
|
||||
}
|
||||
if (baseRef.startsWith('refs/')) {
|
||||
return refExists(baseRef)
|
||||
}
|
||||
return hasLocalCommitObject(repoPath, baseRef)
|
||||
}
|
||||
|
||||
async function prefetchLocalWorktreeCreateBase(
|
||||
repo: Repo,
|
||||
baseBranch: string | undefined,
|
||||
runtime: WorktreeCreateBasePrefetchRuntime
|
||||
): Promise<void> {
|
||||
const resolvedBaseBranch = baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
|
||||
const resolvedBaseBranch = await resolveWorktreeCreateBase({
|
||||
requestedBaseBranch: baseBranch,
|
||||
repoWorktreeBaseRef: repo.worktreeBaseRef,
|
||||
resolveDefaultBaseRef: async () => getDefaultBaseRef(repo.path),
|
||||
isBaseUsable: async (baseBranchCandidate) => {
|
||||
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
|
||||
repo.path,
|
||||
baseBranchCandidate
|
||||
)
|
||||
if (remoteTrackingBase) {
|
||||
if (await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) {
|
||||
return true
|
||||
}
|
||||
return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate)
|
||||
}
|
||||
return hasLocalWorktreeBaseRef(repo.path, baseBranchCandidate)
|
||||
}
|
||||
})
|
||||
if (!resolvedBaseBranch) {
|
||||
return
|
||||
}
|
||||
if (await hasLocalCommitObject(repo.path, resolvedBaseBranch)) {
|
||||
// Why: hosted-review start points can be verified commit SHAs; a broad
|
||||
// remote fetch cannot make an already-local object fresher.
|
||||
if (
|
||||
isFullGitObjectId(resolvedBaseBranch) &&
|
||||
(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch))
|
||||
) {
|
||||
return
|
||||
}
|
||||
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, resolvedBaseBranch)
|
||||
if (remoteTrackingBase) {
|
||||
await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase)
|
||||
if (
|
||||
(await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)) ||
|
||||
!(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch))
|
||||
) {
|
||||
await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) {
|
||||
// Why: hosted-review start points and local branch bases are already local; a broad remote fetch cannot make them fresher.
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveWorktreeCreateBase } from './worktree-create-base'
|
||||
|
||||
describe('resolveWorktreeCreateBase', () => {
|
||||
it('falls back from a stale persisted base to the detected default', async () => {
|
||||
const resolveDefaultBaseRef = vi.fn().mockResolvedValue('origin/main')
|
||||
const isBaseUsable = vi.fn().mockResolvedValue(false)
|
||||
|
||||
await expect(
|
||||
resolveWorktreeCreateBase({
|
||||
repoWorktreeBaseRef: 'origin/master',
|
||||
resolveDefaultBaseRef,
|
||||
isBaseUsable
|
||||
})
|
||||
).resolves.toBe('origin/main')
|
||||
|
||||
expect(resolveDefaultBaseRef).toHaveBeenCalledTimes(1)
|
||||
expect(isBaseUsable).toHaveBeenCalledWith('origin/master')
|
||||
})
|
||||
|
||||
it('returns an explicit base without probing defaults or usability', async () => {
|
||||
const resolveDefaultBaseRef = vi.fn()
|
||||
const isBaseUsable = vi.fn()
|
||||
|
||||
await expect(
|
||||
resolveWorktreeCreateBase({
|
||||
requestedBaseBranch: 'origin/master',
|
||||
repoWorktreeBaseRef: 'origin/main',
|
||||
resolveDefaultBaseRef,
|
||||
isBaseUsable
|
||||
})
|
||||
).resolves.toBe('origin/master')
|
||||
|
||||
expect(resolveDefaultBaseRef).not.toHaveBeenCalled()
|
||||
expect(isBaseUsable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a usable persisted base when it is still valid', async () => {
|
||||
const resolveDefaultBaseRef = vi.fn().mockResolvedValue('origin/main')
|
||||
const isBaseUsable = vi.fn().mockResolvedValue(true)
|
||||
|
||||
await expect(
|
||||
resolveWorktreeCreateBase({
|
||||
repoWorktreeBaseRef: 'origin/master',
|
||||
resolveDefaultBaseRef,
|
||||
isBaseUsable
|
||||
})
|
||||
).resolves.toBe('origin/master')
|
||||
|
||||
expect(resolveDefaultBaseRef).toHaveBeenCalledTimes(1)
|
||||
expect(isBaseUsable).toHaveBeenCalledWith('origin/master')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
type ResolveWorktreeCreateBaseArgs = {
|
||||
requestedBaseBranch?: string
|
||||
repoWorktreeBaseRef?: string | null
|
||||
resolveDefaultBaseRef: () => Promise<string | null>
|
||||
isBaseUsable: (baseBranch: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export async function resolveWorktreeCreateBase(
|
||||
args: ResolveWorktreeCreateBaseArgs
|
||||
): Promise<string | null> {
|
||||
if (args.requestedBaseBranch) {
|
||||
return args.requestedBaseBranch
|
||||
}
|
||||
|
||||
const defaultBaseRef = await args.resolveDefaultBaseRef()
|
||||
const repoWorktreeBaseRef = args.repoWorktreeBaseRef
|
||||
if (!repoWorktreeBaseRef) {
|
||||
return defaultBaseRef
|
||||
}
|
||||
if (!defaultBaseRef) {
|
||||
return (await args.isBaseUsable(repoWorktreeBaseRef)) ? repoWorktreeBaseRef : null
|
||||
}
|
||||
// Resolving the default already proved matching persisted refs exist.
|
||||
if (repoWorktreeBaseRef === defaultBaseRef) {
|
||||
return repoWorktreeBaseRef
|
||||
}
|
||||
// Stale persisted refs fall back to the detected default; usable custom refs stay authoritative.
|
||||
return (await args.isBaseUsable(repoWorktreeBaseRef)) ? repoWorktreeBaseRef : defaultBaseRef
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user