Merge remote-tracking branch 'origin/main' into fix/remote-desktop-viewer-pty-width

# Conflicts:
#	src/main/runtime/rpc/methods/terminal.ts
This commit is contained in:
Jinwoo-H
2026-07-11 05:41:48 -04:00
116 changed files with 5951 additions and 622 deletions
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/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.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/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.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk)
- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
+145
View File
@@ -2736,6 +2736,151 @@
],
"demotionRule": "Cannot promote if success is based only on absence of visible artifacts."
},
{
"id": "terminal-query.mobile-view-authority",
"title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "mobile-runtime-rpc-contract",
"surfaces": [
"mobile terminal WebView",
"terminal query authority",
"runtime RPC",
"mobile subscription replay",
"multi-mobile input floor"
],
"platforms": [
"macos",
"linux",
"windows",
"mobile"
],
"providers": [
"local",
"daemon",
"ssh",
"remote-runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [],
"coverageNotes": "Deterministic local tests execute the exact injected mobile replay/generation gate, the React Native query classifier, stale-subscription sender, server-side single-responder election, query-reply RPC semantics, and live-output capture during async mobile fit. The provider write path is shared, but no live iOS/Android, SSH, WSL, or multi-device run is registered yet.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8128",
"https://github.com/stablyai/orca/pull/8206"
],
"invariant": "Snapshot and replacement-terminal replay answer no terminal queries; every live query delivered to subscribed mobile views has at most one current responder; a live query queued behind replay is answered after the replay boundary; terminal-generated replies never transfer the user-input floor.",
"oracle": "The injected gate emits zero replies during replay, exactly one after its live boundary, and ignores a superseded generation; native routing rejects ordinary input; the sender rejects disconnected or unsubscribed handles; runtime election accepts only the earliest active mobile subscriber and promotes its survivor; terminal.send writes only validated query grammar without mobileTookFloor; legacy binary subscribe captures a query while phone-fit is still pending and emits it after the snapshot.",
"commands": [
"pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts"
],
"testFiles": [
"mobile/src/terminal/terminal-webview-query-reply.test.ts",
"mobile/src/terminal/terminal-webview-query-reply-routing.test.ts",
"mobile/src/terminal/mobile-terminal-query-reply.test.ts",
"src/main/runtime/rpc/terminal-send.test.ts",
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"src/main/runtime/mobile-presence-lock.test.ts",
"src/shared/terminal-query-reply.test.ts",
"src/shared/terminal-reply-query-scan.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts"
],
"assertionRefs": [
{
"file": "mobile/src/terminal/terminal-webview-query-reply.test.ts",
"assertions": [
"snapshot replay emits no terminal-data message before the replay boundary",
"a live query after the boundary emits exactly one terminal-data message",
"a superseded terminal generation cannot regain reply authority",
"xterm parser replies remain enabled while its DOM textarea and hardware-key path stay inert"
]
},
{
"file": "src/main/runtime/rpc/terminal-send.test.ts",
"assertions": [
"only the elected mobile subscriber can write a claimed query reply",
"valid replies skip mobileTookFloor",
"ordinary bytes and reply payloads combined with enter, interrupt, or guarded-send semantics are rejected"
]
},
{
"file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"assertions": [
"live terminal data and view authority register before asynchronous mobile fit",
"a query received during that await is retained and emitted after the snapshot"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"desktop xterm onData replies are dropped while mobile owns the terminal driver",
"desktop capability-handler replies outside onData honor the same mobile authority lock"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-10",
"runner": "local",
"platform": "macos",
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts",
"result": "passed",
"durationSeconds": 1,
"summary": "10 mobile bridge, routing, replay/generation, and stale-subscription tests passed. Before implementation, the WebView listener, native route, and sender tests failed."
},
{
"date": "2026-07-10",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts",
"result": "passed",
"durationSeconds": 4,
"summary": "69 shared/runtime tests passed. Before implementation, terminal.send took the floor for claimed replies and accepted ordinary claimed bytes."
},
{
"date": "2026-07-10",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"result": "passed",
"durationSeconds": 11,
"summary": "421 renderer terminal connection tests passed, including desktop onData and capability-handler suppression while mobile owns query authority."
}
],
"runtimeBudget": {
"p95Seconds": 20,
"scope": "mobile and runtime deterministic unit gate"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New deterministic gate; no soak history yet. Tests use no timing sleeps or external services."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Observed red before implementation for the missing WebView listener/native route/sender and for RPC floor-taking plus unvalidated claims; all are green after the fix. The later multi-mobile and pre-fit race assertions have green deterministic coverage but no saved intentional-break artifact yet."
},
"performanceBudget": {
"required": true,
"evidence": "No polling, scans, timers, or output fanout were added. The WebView adds one disposable onData listener; native/RPC work occurs only for xterm data and is grammar-filtered; responder election is O(1) from Map insertion order; pre-fit output uses the existing capped pending-output queue."
},
"promotionCriteria": [
"Attach an intentional-break artifact for the pre-fit capture and multi-mobile election assertions.",
"Run live iOS and Android WebViews against a real querying TUI and verify no software-keyboard regression.",
"Exercise two physical/virtual mobile clients on one PTY and observe one reply per query.",
"Accumulate the normal soak window with zero unexplained flakes."
],
"knownGaps": [
"No live iOS or Android WebView run is registered.",
"No real SSH, WSL, daemon, or two-device provider-contract run is registered.",
"Mixed-version pairing is gated by the terminal.query-reply-input.v1 capability (mobile drops replies unless the host advertises it), but no live old-binary pairing run is registered.",
"A desktop-to-mobile driver handoff has a bounded double-reply window: the server elects the mobile responder synchronously while the desktop renderer's cached driver map updates via an async event, so an in-flight query can be answered by both until the driver-change event lands."
],
"demotionRule": "Keep experimental or disable mobile forwarding if the gate flakes without a product bug, live WebView input isolation regresses, or duplicate replies appear under multi-mobile use."
},
{
"id": "terminal-runtime.mobile-stream-budget",
"title": "Runtime and mobile terminal streams keep snapshot and live-output bytes bounded",
+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/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.27/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.26/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/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.27/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.26/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/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.27/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.26/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.26](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk)
- **Android:** [Baixar APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/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.27/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.26/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
---
+30 -9
View File
@@ -96,20 +96,40 @@ ingestion and an async write; the decision must not be re-read at reply time):
stream (CLI `terminal.read`, automation observers) do not suppress — they
also do not answer; that bounded no-reply case matches today's behavior.
Mobile streams preserve that singularity across snapshot startup and multiple
views. The mobile WebView suppresses `onData` during snapshot replay, then
enables replies at an explicit live-output boundary. Clearing the WebView drops
that replay queue and resumes live reply authority immediately. If a live query
arrived while the initial snapshot was being built and its output sequence is
covered by that snapshot, runtime RPC re-emits only the bounded query sequence
after the snapshot; ordinary covered output stays deduplicated. `terminal.send`
accepts the resulting `inputKind: query-reply` only from the earliest active
phone-fitted mobile subscriber while mobile owns the terminal driver. Earliest
is determined by preserved `subscribedAt`, so a soft-leave resubscribe does not
change reply ownership; passive desktop-mode watchers are excluded. Peer phones
are rejected, the next subscriber is promoted on unsubscribe, and desktop
parser and capability handlers stay silent until desktop retakes the driver.
Mixed versions: hosts advertise `terminal.query-reply-input.v1`; a mobile
client never forwards replies to a host that lacks it, because such hosts
strip `inputKind` and would treat the bytes as floor-taking shell input.
Residual: a desktop→mobile driver handoff has a bounded double-reply window
until the async driver-change event reaches the desktop renderer's cached
driver map (`isPtyLocked`).
Everything the emulator emits outside a forwarding window is discarded, which
also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes
triggered by option mutations).
## Reply classes
| Class | Queries | Answer source |
| --- | --- | --- |
| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
| Class | Queries | Answer source |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
### View-attribute bridge
@@ -237,7 +257,8 @@ any spawn-window drops).
the documented ConPTY DA1 variant and the view-attribute parser handlers
the headless core cannot serve.
6. Remote views keep view authority; main yields whenever a remote view
subscriber is attached.
subscriber is attached. When multiple desktop/mobile views coexist, the
terminal driver and server-side mobile election keep one reply writer.
**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is
replaced by:
@@ -112,6 +112,8 @@ import {
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply'
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
import {
getTerminalCommandKeyboardType,
@@ -2424,10 +2426,13 @@ export default function SessionScreen() {
terminalGestureInputInFlightRef.current.clear()
}, [connState])
const hostQueryReplyInputSupportedRef = useRef(false)
useEffect(() => {
if (!client || connState !== 'connected') {
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
hostQueryReplyInputSupportedRef.current = false
return
}
let stale = false
@@ -2444,11 +2449,16 @@ export default function SessionScreen() {
setAgentSessionHistorySupported(
status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true
)
// Why: hosts without this capability strip inputKind from terminal.send,
// so a forwarded xterm reply would become floor-stealing shell input.
hostQueryReplyInputSupportedRef.current =
status.capabilities?.includes(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) === true
})
.catch(() => {
if (!stale) {
setBrowserScreencastSupported(false)
setAgentSessionHistorySupported(false)
hostQueryReplyInputSupportedRef.current = false
}
})
return () => {
@@ -3531,6 +3541,18 @@ export default function SessionScreen() {
[allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput]
)
const handleTerminalQueryReply = useCallback((handle: string, bytes: string) => {
void sendMobileTerminalQueryReply({
bytes,
client: clientRef.current,
clientId: deviceTokenRef.current,
connected: connStateRef.current === 'connected',
handle,
hostSupportsQueryReplyInput: hostQueryReplyInputSupportedRef.current,
subscribedTerminals: terminalUnsubsRef.current
})
}, [])
async function handleClearTerminal(target: Terminal) {
if (!client) {
return
@@ -4800,6 +4822,7 @@ export default function SessionScreen() {
onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics}
onHaptic={handleHaptic}
onTerminalInput={handleTerminalInput}
onTerminalQueryReply={handleTerminalQueryReply}
onTerminalTap={handleTerminalTap}
onFileTap={handleFileTap}
onOpenUrl={handleTerminalOpenUrl}
+3
View File
@@ -23,6 +23,7 @@ type TerminalPaneViewProps = {
onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput: (handle: string, bytes: string) => void
onTerminalQueryReply: (handle: string, bytes: string) => void
onTerminalTap: (handle: string) => void
onFileTap: (handle: string, pathText: string, line: number | null, column: number | null) => void
onOpenUrl: (handle: string, url: string) => void
@@ -44,6 +45,7 @@ export function TerminalPaneView({
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
@@ -80,6 +82,7 @@ export function TerminalPaneView({
onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)}
onHaptic={onHaptic}
onTerminalInput={(bytes) => onTerminalInput(handle, bytes)}
onTerminalQueryReply={(bytes) => onTerminalQueryReply(handle, bytes)}
onTerminalTap={() => onTerminalTap(handle)}
onFileTap={(pathText, line, column) => onFileTap(handle, pathText, line, column)}
onOpenUrl={(url) => onOpenUrl(handle, url)}
+5 -2
View File
@@ -1,7 +1,6 @@
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
import { Platform, View } from 'react-native'
import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import { WebView, type WebViewMessageEvent } from 'react-native-webview'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
import {
@@ -13,6 +12,7 @@ import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
type Props = TerminalWebViewProps
@@ -32,6 +32,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
@@ -115,6 +116,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
} catch {
return
}
routeTerminalQueryReply(msg, onTerminalQueryReply)
if (msg.type === 'web-ready') {
confirmWebReady(true)
@@ -228,6 +230,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
onKeyboardAvoidanceMetrics,
onHaptic,
onTerminalInput,
onTerminalQueryReply,
onTerminalTap,
onFileTap,
onOpenUrl,
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { sendMobileTerminalQueryReply } from './mobile-terminal-query-reply'
function createClient() {
return {
sendRequest: vi.fn().mockResolvedValue({
id: '1',
ok: true,
result: { send: { accepted: true, bytesWritten: 6 } },
_meta: { runtimeId: 'runtime-1' }
})
} as unknown as Pick<RpcClient, 'sendRequest'>
}
describe('sendMobileTerminalQueryReply', () => {
it('sends a subscribed terminal reply immediately with additive RPC metadata', async () => {
const client = createClient()
await expect(
sendMobileTerminalQueryReply({
bytes: '\x1b[3;4R',
client,
clientId: 'mobile-1',
connected: true,
handle: 'terminal-1',
hostSupportsQueryReplyInput: true,
subscribedTerminals: new Set(['terminal-1'])
})
).resolves.toBe(true)
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'terminal-1',
text: '\x1b[3;4R',
enter: false,
inputKind: 'query-reply',
client: { id: 'mobile-1', type: 'mobile' }
})
})
it.each([
['disconnected', false, new Set(['terminal-1']), '\x1b[3;4R', 'terminal-1'],
['unsubscribed', true, new Set(), '\x1b[3;4R', 'terminal-1'],
['stale handle', true, new Set(['terminal-2']), '\x1b[3;4R', 'terminal-1'],
['ordinary input', true, new Set(['terminal-1']), 'a', 'terminal-1']
])('does not send %s data', async (_case, connected, subscribedTerminals, bytes, handle) => {
const client = createClient()
await expect(
sendMobileTerminalQueryReply({
bytes,
client,
clientId: 'mobile-1',
connected,
handle,
hostSupportsQueryReplyInput: true,
subscribedTerminals
})
).resolves.toBe(false)
expect(client.sendRequest).not.toHaveBeenCalled()
})
// Why: hosts without terminal.query-reply-input.v1 strip inputKind (zod drops
// unknown keys) and would treat the reply as floor-taking shell input.
it('does not send to a host that has not advertised query-reply input support', async () => {
const client = createClient()
await expect(
sendMobileTerminalQueryReply({
bytes: '\x1b[3;4R',
client,
clientId: 'mobile-1',
connected: true,
handle: 'terminal-1',
hostSupportsQueryReplyInput: false,
subscribedTerminals: new Set(['terminal-1'])
})
).resolves.toBe(false)
expect(client.sendRequest).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,51 @@
import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply'
import type { RpcClient } from '../transport/rpc-client'
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
type TerminalSubscriptionRegistry = {
has: (handle: string) => boolean
}
type MobileTerminalQueryReplyOptions = {
bytes: string
client: Pick<RpcClient, 'sendRequest'> | null
clientId: string | null
connected: boolean
handle: string
hostSupportsQueryReplyInput: boolean
subscribedTerminals: TerminalSubscriptionRegistry
}
export function sendMobileTerminalQueryReply({
bytes,
client,
clientId,
connected,
handle,
hostSupportsQueryReplyInput,
subscribedTerminals
}: MobileTerminalQueryReplyOptions): Promise<boolean> {
// Why: every subscribed mobile xterm suppresses main's responder, including
// hidden panes, so ownership follows the subscription rather than focus.
// Hosts without terminal.query-reply-input.v1 strip inputKind and would take
// reply bytes as floor-stealing shell input, so drop (pre-fix behavior).
if (
!client ||
!connected ||
!hostSupportsQueryReplyInput ||
!subscribedTerminals.has(handle) ||
!isTerminalQueryReply(bytes)
) {
return Promise.resolve(false)
}
return client
.sendRequest('terminal.send', {
terminal: handle,
text: bytes,
enter: false,
inputKind: 'query-reply',
...(clientId ? { client: { id: clientId, type: 'mobile' as const } } : {})
})
.then(isTerminalSendRpcAccepted, () => false)
}
@@ -28,6 +28,7 @@ export type TerminalSelectionEvents = {
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
onTerminalQueryReply?: (bytes: string) => void
onTerminalTap?: () => void
// Tap landed on a detected file path; RN resolves + opens it.
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
+15 -3
View File
@@ -8,6 +8,7 @@ import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.gen
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
@@ -215,7 +216,7 @@ window.onerror = function(msg) {
var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g');
var statusDotPendingSelector = false;
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null;
var term = null; ${TERMINAL_QUERY_REPLY_JS}
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
@@ -599,6 +600,10 @@ ${TERMINAL_WEBVIEW_THEME_JS}
writeQueue.push(normalizeStatusDotPresentation(data));
}
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
resetWriteQueue();
@@ -644,6 +649,7 @@ ${TERMINAL_WEBVIEW_THEME_JS}
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = nextQueuedWrite();
if (typeof next !== 'string') {
if (typeof next === 'function') return next(), pumpWrites(gen);
var callbacks = afterDrainCallbacks;
afterDrainCallbacks = [];
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
@@ -675,6 +681,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1;
terminalGeneration++;
var gen = terminalGeneration;
// Why: snapshot replay can contain old queries whose replies must never
// re-enter the live PTY. Each replacement terminal earns authority anew.
resetTerminalDataReplyAuthority();
cancelWebglContextRecovery();
webglAddon = null;
ready = false;
@@ -731,7 +740,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
fontWeight: '300',
fontWeightBold: '500',
scrollback: 5000,
disableStdin: true,
// Why: xterm suppresses parser-generated query replies when disableStdin
// is true. Native accepts only validated reply grammars from onData.
disableStdin: false,
cursorBlink: false,
cursorStyle: 'bar',
cursorInactiveStyle: 'none',
@@ -749,6 +760,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
resetEvictionCounter();
cancelSelect();
attachTermObservers();
attachTerminalQueryReplyBridge(term, gen);
requestAnimationFrame(function() {
if (gen !== terminalGeneration) return;
@@ -943,7 +955,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
write(msg.data);
} else if (msg.type === 'clear') {
terminalGeneration++;
resetWriteQueue();
resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary.
statusDotPendingSelector = false;
afterDrainCallbacks = [];
writesDraining = false;
@@ -0,0 +1,44 @@
// Kept as one injectable unit so tests execute the same replay/generation gate
// that the WebView document runs, rather than a TypeScript reimplementation.
export const TERMINAL_QUERY_REPLY_JS = `
var terminalDataRepliesEnabled = false;
function resetTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = false;
}
function resumeTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = true;
}
function forwardTerminalDataReply(data) {
if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data });
}
function enqueueTerminalDataReplyBoundary(gen) {
enqueueWriteBoundary(function() {
if (gen === terminalGeneration) terminalDataRepliesEnabled = true;
});
}
function attachTerminalQueryReplyBridge(term, gen) {
// Why: parser replies require stdin enabled, but mobile input is owned by
// native controls. Keep xterm's textarea inert for touch/hardware keys.
try {
term.attachCustomKeyEventHandler(function() { return false; });
if (term.textarea) {
term.textarea.readOnly = true;
term.textarea.tabIndex = -1;
term.textarea.setAttribute('inputmode', 'none');
}
} catch (e) {}
try {
termObserverDisposables.push(term.onData(function(data) {
forwardTerminalDataReply(data);
}));
} catch (e) {}
// Why: live output can queue before initial replay finishes. Enable replies
// at the replay boundary so those live queries are answered, never replayed ones.
enqueueTerminalDataReplyBoundary(gen);
}
`
@@ -0,0 +1,69 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TerminalWebView } from './TerminalWebView'
vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
StyleSheet: {
absoluteFillObject: {
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0
},
create: (styles: unknown) => styles
},
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-webview', async () => {
const React = await import('react')
const WebView = React.forwardRef((props: Record<string, unknown>, _ref) =>
React.createElement('WebView', props)
)
return { WebView, default: WebView }
})
vi.mock('lucide-react-native', () => ({
RefreshCw: 'RefreshCw'
}))
let renderer: ReactTestRenderer | null = null
function postWebViewMessage(payload: Record<string, unknown>): void {
if (!renderer) {
throw new Error('TerminalWebView did not render')
}
const webView = renderer.root.findByType('WebView')
act(() => {
webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } })
})
}
describe('TerminalWebView query reply routing', () => {
afterEach(() => {
if (renderer) {
act(() => renderer?.unmount())
renderer = null
}
vi.restoreAllMocks()
})
it('routes only complete terminal query replies to native', () => {
const onTerminalQueryReply = vi.fn()
vi.spyOn(console, 'error').mockImplementation(() => {})
act(() => {
renderer = create(createElement(TerminalWebView, { onTerminalQueryReply }))
})
postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[3;4R' })
postWebViewMessage({ type: 'terminal-data', bytes: 'a' })
postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[A' })
expect(onTerminalQueryReply).toHaveBeenCalledTimes(1)
expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[3;4R')
})
})
@@ -0,0 +1,14 @@
import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply'
export function routeTerminalQueryReply(
message: Record<string, unknown>,
onTerminalQueryReply: ((bytes: string) => void) | undefined
): void {
if (
message.type === 'terminal-data' &&
typeof message.bytes === 'string' &&
isTerminalQueryReply(message.bytes)
) {
onTerminalQueryReply?.(message.bytes)
}
}
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
type QueryReplyGate = {
forward: (data: string) => void
queueBoundary: (generation: number) => void
reset: () => void
resume: () => void
setGeneration: (generation: number) => void
}
function createQueryReplyGate(notify: (message: unknown) => void): {
gate: QueryReplyGate
queuedBoundaries: Array<() => void>
} {
const queuedBoundaries: Array<() => void> = []
const factory = new Function(
'notify',
'enqueueWriteBoundary',
`var terminalGeneration = 0;
${TERMINAL_QUERY_REPLY_JS}
return {
forward: forwardTerminalDataReply,
queueBoundary: enqueueTerminalDataReplyBoundary,
reset: resetTerminalDataReplyAuthority,
resume: resumeTerminalDataReplyAuthority,
setGeneration: function(next) { terminalGeneration = next; }
};`
) as (
notify: (message: unknown) => void,
enqueueBoundary: (callback: () => void) => void
) => QueryReplyGate
const gate = factory(notify, (callback) => queuedBoundaries.push(callback))
return { gate, queuedBoundaries }
}
describe('mobile terminal query replies', () => {
it('forwards xterm-generated data only after initial replay drains', () => {
const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)')
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'attachTerminalQueryReplyBridge(term, gen)',
listenerIndex
)
const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'forwardTerminalDataReply(data)',
listenerIndex
)
expect(listenerIndex).toBeGreaterThan(-1)
expect(enableIndex).toBeGreaterThan(listenerIndex)
expect(notifyIndex).toBeGreaterThan(listenerIndex)
expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false')
expect(XTERM_WEBVIEW_SOURCE.html).toContain(
'term.attachCustomKeyEventHandler(function() { return false; })'
)
expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true')
})
it('mutes a replacement terminal until its own replay drains', () => {
const initIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('function init(cols, rows, initialData')
const disableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'resetTerminalDataReplyAuthority()',
initIndex
)
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'attachTerminalQueryReplyBridge(term, gen)',
disableIndex
)
expect(initIndex).toBeGreaterThan(-1)
expect(disableIndex).toBeGreaterThan(initIndex)
expect(enableIndex).toBeGreaterThan(disableIndex)
})
it('suppresses replay, then forwards live queries queued behind its boundary', () => {
const messages: unknown[] = []
const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
gate.setGeneration(1)
gate.reset()
gate.queueBoundary(1)
gate.forward('\x1b[1;1R')
expect(messages).toEqual([])
queuedBoundaries[0]?.()
gate.forward('\x1b[2;2R')
expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[2;2R' }])
})
it('does not let a superseded generation reclaim reply authority', () => {
const messages: unknown[] = []
const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
gate.setGeneration(1)
gate.queueBoundary(1)
gate.setGeneration(2)
gate.reset()
queuedBoundaries[0]?.()
gate.forward('\x1b[1;1R')
expect(messages).toEqual([])
})
it('restores reply authority when clear discards the replay boundary', () => {
const messages: unknown[] = []
const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message))
gate.setGeneration(1)
gate.reset()
gate.queueBoundary(1)
// A clear drops all queued writes, including the replay boundary.
queuedBoundaries.length = 0
gate.resume()
gate.forward('\x1b[3;4R')
expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }])
const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf("} else if (msg.type === 'clear') {")
const clearEnd = XTERM_WEBVIEW_SOURCE.html.indexOf(
"} else if (msg.type === 'measure')",
clearStart
)
expect(XTERM_WEBVIEW_SOURCE.html.slice(clearStart, clearEnd)).toContain(
'resumeTerminalDataReplyAuthority()'
)
})
})
@@ -0,0 +1,119 @@
import os from 'node:os'
import { app } from 'electron'
import {
sanitizeCrashReportString,
type CrashReportDiagnosticBundle
} from '../../shared/crash-reporting'
import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability'
import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint'
import type { FeedbackDiagnosticBundleAttachment, FeedbackSubmitResult } from '../ipc/feedback'
const CRASH_REPORT_LOG_LOOKBACK_MINUTES = 3 * 24 * 60
export type CrashDiagnosticBundleAttachment = {
readonly diagnosticBundle: CrashReportDiagnosticBundle
readonly feedbackDiagnosticBundle?: FeedbackDiagnosticBundleAttachment
}
function formatUnknownError(error: unknown): string {
return sanitizeCrashReportString(error instanceof Error ? error.message : String(error))
}
function skippedCrashDiagnosticBundle(): CrashDiagnosticBundleAttachment {
return {
diagnosticBundle: {
status: 'not_uploaded',
reason: 'diagnostic log upload skipped by user'
}
}
}
function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachment {
const status = getDiagnosticsStatus()
if (!status.bundleEnabled) {
return {
diagnosticBundle: {
status: 'not_uploaded',
reason: status.disabledReason ?? 'diagnostic bundle collection is disabled'
}
}
}
let bundle: ReturnType<typeof collectDiagnosticBundle>
try {
bundle = collectDiagnosticBundle({
appVersion: app.getVersion(),
platform: os.platform(),
arch: os.arch(),
osRelease: os.release(),
orcaChannel: resolveDiagnosticOrcaChannel(),
// Why: Help > Report Crash is often used after relaunch, long after the
// default 30 minute support bundle window would miss the failure context.
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES
})
} catch (error) {
return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } }
}
return {
diagnosticBundle: {
status: 'attached',
bundleSubmissionId: bundle.bundleSubmissionId,
bytes: bundle.bytes,
spanCount: bundle.spanCount
},
feedbackDiagnosticBundle: {
bundleSubmissionId: bundle.bundleSubmissionId,
content: bundle.payload,
bytes: bundle.bytes,
spanCount: bundle.spanCount
}
}
}
export function prepareCrashDiagnosticBundle(
includeDiagnosticLogs: boolean
): CrashDiagnosticBundleAttachment {
return includeDiagnosticLogs
? collectCrashDiagnosticBundleAttachment()
: skippedCrashDiagnosticBundle()
}
export function diagnosticBundleForReportOnlyRetry(
attachment: CrashDiagnosticBundleAttachment
): CrashReportDiagnosticBundle | undefined {
const bundle = attachment.feedbackDiagnosticBundle
if (!bundle) {
return undefined
}
return {
status: 'not_uploaded',
reason: 'diagnostic log attachment could not be sent; report retried without logs',
bundleSubmissionId: bundle.bundleSubmissionId,
bytes: bundle.bytes,
spanCount: bundle.spanCount
}
}
export function resolveSubmittedDiagnosticBundle(
attachment: CrashDiagnosticBundleAttachment,
result: FeedbackSubmitResult
): CrashReportDiagnosticBundle {
const bundle = attachment.feedbackDiagnosticBundle
if (!bundle) {
return attachment.diagnosticBundle
}
const failure =
result.diagnosticBundleFailure ??
(!result.ok ? { status: result.status, error: result.error } : undefined)
if (!failure) {
return attachment.diagnosticBundle
}
return {
status: 'not_uploaded',
reason: `diagnostic log attachment failed: ${formatUnknownError(failure.error)}`,
bundleSubmissionId: bundle.bundleSubmissionId,
bytes: bundle.bytes,
spanCount: bundle.spanCount
}
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { formatCrashReportCopyText } from './crash-report-copy-text'
describe('formatCrashReportCopyText', () => {
it('appends only sanitized report and diagnostic omission failure fields', () => {
const text = formatCrashReportCopyText('[Crash Report]', {
error: 'proxy failed at C:\\Users\\alice\\Orca',
diagnosticContext: {
status: 'not_uploaded',
reason: 'timeout token=super-secret-value\n- Forged field: hidden',
internalEndpointError: 'must-not-cross-copy-boundary'
},
internalTransportFailure: 'must-not-cross-copy-boundary'
})
expect(text).toContain('Submission failure:')
expect(text).toContain('Report error: proxy failed at [redacted-path]')
expect(text).toContain('Diagnostic logs not uploaded: timeout token=[redacted]')
expect(text).not.toContain('\n- Forged field')
expect(text).not.toContain('alice')
expect(text).not.toContain('must-not-cross-copy-boundary')
})
it('includes a sanitized uploaded ticket without accepting unrelated fields', () => {
const text = formatCrashReportCopyText('[Crash Report]', {
error: 'report link failed',
diagnosticContext: {
status: 'uploaded',
ticketId: 'ticket-123',
bundleSubmissionId: 'not-allow-listed'
}
})
expect(text).toContain('Diagnostic ticket uploaded but not linked: ticket-123')
expect(text).not.toContain('not-allow-listed')
})
it('leaves the report unchanged when failure context is invalid', () => {
expect(formatCrashReportCopyText('[Crash Report]', { error: '' })).toBe('[Crash Report]')
})
})
@@ -0,0 +1,53 @@
import {
sanitizeCrashReportString,
type CrashReportCopySubmissionFailure
} from '../../shared/crash-reporting'
function sanitizedNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const sanitized = sanitizeCrashReportString(value.replace(/[\r\n]+/g, ' ')).trim()
return sanitized || null
}
function normalizeSubmissionFailure(value: unknown): CrashReportCopySubmissionFailure | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const record = value as Record<string, unknown>
const error = sanitizedNonEmptyString(record.error)
if (!error) {
return null
}
const rawContext = record.diagnosticContext
if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) {
return { error }
}
const context = rawContext as Record<string, unknown>
if (context.status === 'uploaded') {
const ticketId = sanitizedNonEmptyString(context.ticketId)
return ticketId ? { error, diagnosticContext: { status: 'uploaded', ticketId } } : { error }
}
if (context.status === 'not_uploaded') {
const reason = sanitizedNonEmptyString(context.reason)
return reason ? { error, diagnosticContext: { status: 'not_uploaded', reason } } : { error }
}
return { error }
}
export function formatCrashReportCopyText(baseText: string, submissionFailure: unknown): string {
const failure = normalizeSubmissionFailure(submissionFailure)
if (!failure) {
return baseText
}
const lines = [baseText, '', 'Submission failure:', `- Report error: ${failure.error}`]
if (failure.diagnosticContext?.status === 'uploaded') {
lines.push(`- Diagnostic ticket uploaded but not linked: ${failure.diagnosticContext.ticketId}`)
} else if (failure.diagnosticContext?.status === 'not_uploaded') {
lines.push(`- Diagnostic logs not uploaded: ${failure.diagnosticContext.reason}`)
}
return lines.join('\n')
}
@@ -1,7 +1,17 @@
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { grantDirAclAsyncMock } = vi.hoisted(() => ({
grantDirAclAsyncMock: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('../win32-utils', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, grantDirAclAsync: grantDirAclAsyncMock }
})
import { CrashReportStore } from './crash-report-store'
import type { CrashReportCreateInput } from '../../shared/crash-reporting'
@@ -38,6 +48,9 @@ function input(reason = 'crashed'): CrashReportCreateInput {
}
afterEach(async () => {
vi.restoreAllMocks()
grantDirAclAsyncMock.mockReset()
grantDirAclAsyncMock.mockResolvedValue(undefined)
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })))
})
@@ -134,4 +147,72 @@ describe('CrashReportStore', () => {
await expect(store.listRecent()).resolves.toHaveLength(5)
})
it('waits for an in-flight crash write before reading the pending report', async () => {
const { store } = await createStore()
const recordPromise = store.record(input())
const pendingPromise = store.getLatestPending()
const report = await recordPromise
await expect(pendingPromise).resolves.toMatchObject({ id: report.id, status: 'pending' })
})
it('repairs the Windows userData ACL and retries a denied crash write', async () => {
const { store, filePath } = await createStore()
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const permissionError = Object.assign(new Error('permission denied'), { code: 'EPERM' })
const writeFileSpy = vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(permissionError)
const report = await store.record(input())
expect(grantDirAclAsyncMock).toHaveBeenCalledWith(path.dirname(filePath))
expect(writeFileSpy).toHaveBeenCalledTimes(2)
await expect(store.getLatestPending()).resolves.toMatchObject({ id: report.id })
})
it.each(['EPERM', 'EACCES', 'EBUSY'] as const)(
'recovers a pending report after a transient Windows %s read failure',
async (code) => {
const { store, filePath } = await createStore()
const report = await store.record(input())
const reloaded = new CrashReportStore(filePath)
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const readError = Object.assign(new Error('temporary read failure'), { code })
const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValueOnce(readError)
await expect(reloaded.getLatestPending()).resolves.toMatchObject({ id: report.id })
expect(readFileSpy).toHaveBeenCalledTimes(2)
if (code === 'EBUSY') {
expect(grantDirAclAsyncMock).not.toHaveBeenCalled()
} else {
expect(grantDirAclAsyncMock).toHaveBeenCalledOnce()
expect(grantDirAclAsyncMock).toHaveBeenCalledWith(path.dirname(filePath))
}
}
)
it('retries a transient Windows rename lock', async () => {
const { store } = await createStore()
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const busyError = Object.assign(new Error('file busy'), { code: 'EBUSY' })
const renameSpy = vi.spyOn(fs, 'rename').mockRejectedValueOnce(busyError)
await expect(store.record(input())).resolves.toMatchObject({ status: 'pending' })
expect(renameSpy).toHaveBeenCalledTimes(2)
expect(grantDirAclAsyncMock).not.toHaveBeenCalled()
})
it('removes its temp file after a terminal write failure', async () => {
const { store, filePath } = await createStore()
const ioError = Object.assign(new Error('disk error'), { code: 'EIO' })
vi.spyOn(fs, 'rename').mockRejectedValueOnce(ioError)
await expect(store.record(input())).rejects.toBe(ioError)
const entries = await fs.readdir(path.dirname(filePath))
expect(entries.filter((entry) => entry.endsWith('.tmp'))).toEqual([])
})
})
+68 -5
View File
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { app } from 'electron'
import { grantDirAclAsync, isPermissionError } from '../win32-utils'
import {
formatCrashReportText,
sanitizeCrashReportBreadcrumbs,
@@ -14,6 +15,7 @@ import {
const MAX_REPORTS = 5
const RELATED_CRASH_WINDOW_MS = 5_000
const WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS = [50, 100, 150, 200, 250]
type CrashReportFile = {
reports: CrashReportRecord[]
@@ -37,6 +39,46 @@ function isRelatedCrashEvent(anchor: CrashReportRecord, candidate: CrashReportRe
)
}
function isRetryableWindowsFileOperationError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code
return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY'
}
async function wait(delayMs: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, delayMs))
}
async function runCrashReportFileOperationWithWindowsRecovery<T>(
directory: string,
operation: () => Promise<T>
): Promise<T> {
let repairedAcl = false
for (let attempt = 0; ; attempt += 1) {
try {
return await operation()
} catch (error) {
if (
process.platform !== 'win32' ||
!isRetryableWindowsFileOperationError(error) ||
attempt >= WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS.length
) {
throw error
}
if (!repairedAcl && isPermissionError(error)) {
repairedAcl = true
try {
// Why: Chromium can reset userData ACLs before startup capture or
// recovery, so both the crash write and next prompt must repair it.
await grantDirAclAsync(directory)
} catch {
// The bounded retry below still handles transient file locks.
}
}
await wait(WINDOWS_FILE_OPERATION_RETRY_DELAYS_MS[attempt])
}
}
}
export class CrashReportStore {
private writeChain = Promise.resolve()
@@ -135,7 +177,9 @@ export class CrashReportStore {
mutate: (reports: CrashReportRecord[]) => Promise<{ reports: CrashReportRecord[]; result: T }>
): Promise<T> {
const run = this.writeChain.then(async () => {
const reports = await this.readReports()
// Why: awaiting writeChain from inside its own callback would deadlock;
// this writer already has exclusive ownership and can read disk directly.
const reports = await this.readReportsFromDisk()
const { reports: nextReports, result } = await mutate(reports)
await this.writeReports(nextReports)
return result
@@ -148,8 +192,18 @@ export class CrashReportStore {
}
private async readReports(): Promise<CrashReportRecord[]> {
// Why: renderer recovery can query the one-shot startup prompt while the
// crash write is still in flight. Wait so a successful capture is visible.
await this.writeChain
return this.readReportsFromDisk()
}
private async readReportsFromDisk(): Promise<CrashReportRecord[]> {
try {
const raw = await fs.readFile(this.filePath, 'utf8')
const raw = await runCrashReportFileOperationWithWindowsRecovery(
path.dirname(this.filePath),
() => fs.readFile(this.filePath, 'utf8')
)
const parsed = JSON.parse(raw) as Partial<CrashReportFile>
return Array.isArray(parsed.reports) ? parsed.reports.slice(0, MAX_REPORTS) : []
} catch (error) {
@@ -161,9 +215,18 @@ export class CrashReportStore {
}
private async writeReports(reports: CrashReportRecord[]): Promise<void> {
await fs.mkdir(path.dirname(this.filePath), { recursive: true })
const directory = path.dirname(this.filePath)
const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`
await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8')
await fs.rename(tmpPath, this.filePath)
try {
await runCrashReportFileOperationWithWindowsRecovery(directory, async () => {
await fs.mkdir(directory, { recursive: true })
await fs.writeFile(tmpPath, `${JSON.stringify({ reports }, null, 2)}${os.EOL}`, 'utf8')
await fs.rename(tmpPath, this.filePath)
})
} finally {
// Why: disk-full and terminal rename failures must not accumulate a new
// orphaned multi-report temp file after every crash.
await fs.rm(tmpPath, { force: true }).catch(() => {})
}
}
}
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store'
import { recordDurableCrashBreadcrumb } from './durable-crash-breadcrumb'
import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer'
type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType<typeof vi.fn> }
function capturingSink(): CapturingSink {
const records: unknown[] = []
const flushMock = vi.fn()
return {
records,
flushMock,
push: (record) => records.push(record),
flush: flushMock,
close: vi.fn()
}
}
let sink: CapturingSink
beforeEach(() => {
sink = capturingSink()
setActiveSink(sink)
clearCrashBreadcrumbsForTest()
})
afterEach(() => {
_resetTracerForTests()
clearCrashBreadcrumbsForTest()
})
describe('recordDurableCrashBreadcrumb', () => {
it('records, traces, and flushes a sanitized crash breadcrumb', () => {
recordDurableCrashBreadcrumb('process_gone_suppressed', {
source: 'renderer',
path: 'C:\\Users\\alice\\project'
})
expect(getCrashBreadcrumbSnapshot()).toEqual([
expect.objectContaining({
name: 'process_gone_suppressed',
data: { source: 'renderer', path: '[redacted-path]' }
})
])
expect(sink.records).toEqual([
expect.objectContaining({
name: 'crash.breadcrumb',
attributes: expect.objectContaining({
kind: 'crash-breadcrumb',
'breadcrumb.name': 'process_gone_suppressed'
}),
exit: { _tag: 'Success' }
})
])
expect(sink.flushMock).toHaveBeenCalledOnce()
})
it('records persistence failures as failed, flushed spans', () => {
recordDurableCrashBreadcrumb(
'crash_report_persist_failed',
{ errorCode: 'EPERM' },
'EPERM at C:\\Users\\alice\\AppData\\Roaming\\Orca'
)
expect(sink.records).toEqual([
expect.objectContaining({
exit: expect.objectContaining({ _tag: 'Failure', cause: expect.stringContaining('EPERM') })
})
])
expect(JSON.stringify(sink.records)).not.toContain('alice')
expect(sink.flushMock).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,33 @@
import {
sanitizeCrashReportDetails,
sanitizeCrashReportString,
type CrashReportBreadcrumbData
} from '../../shared/crash-reporting'
import { flushActiveSink, startSpan } from '../observability/tracer'
import { recordCrashBreadcrumb } from './crash-breadcrumb-store'
export function recordDurableCrashBreadcrumb(
name: string,
data?: CrashReportBreadcrumbData,
failureCause?: string
): void {
const sanitizedName = sanitizeCrashReportString(name)
const sanitizedData = data ? sanitizeCrashReportDetails(data) : undefined
recordCrashBreadcrumb(sanitizedName, sanitizedData)
const span = startSpan('crash.breadcrumb', {
attributes: {
kind: 'crash-breadcrumb',
'breadcrumb.name': sanitizedName,
...(sanitizedData ? { 'breadcrumb.data': sanitizedData } : {})
}
})
if (failureCause) {
span.fail(sanitizeCrashReportString(failureCause, 1_000))
} else {
span.end()
}
// Why: these breadcrumbs explain a missing crash record; losing one to the
// normal trace batching window would recreate the diagnostic blind spot.
flushActiveSink()
}
@@ -11,6 +11,27 @@ describe('ProcessGoneDedupe', () => {
expect(dedupe.shouldRecord(key, 3_000)).toBe(true)
})
it('allows an immediate retry after a failed claim is released', () => {
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
const claim = dedupe.tryClaim('renderer', 1_000)
expect(claim).not.toBeNull()
expect(dedupe.tryClaim('renderer', 1_001)).toBeNull()
dedupe.release(claim!)
expect(dedupe.tryClaim('renderer', 1_001)).not.toBeNull()
})
it('does not release a newer claim when an older persistence attempt fails late', () => {
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
const oldClaim = dedupe.tryClaim('renderer', 1_000)
const currentClaim = dedupe.tryClaim('renderer', 3_000)
expect(oldClaim).not.toBeNull()
expect(currentClaim).not.toBeNull()
dedupe.release(oldClaim!)
expect(dedupe.tryClaim('renderer', 3_001)).toBeNull()
})
it('prunes stale keys outside the dedupe window', () => {
const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 })
@@ -6,10 +6,19 @@ type ProcessGoneDedupeOptions = {
maxKeys?: number
}
export type ProcessGoneDedupeClaim = {
readonly key: string
}
type ProcessGoneDedupeEntry = {
readonly recordedAt: number
readonly claim: ProcessGoneDedupeClaim
}
export class ProcessGoneDedupe {
private readonly windowMs: number
private readonly maxKeys: number
private readonly recentKeys = new Map<string, number>()
private readonly recentKeys = new Map<string, ProcessGoneDedupeEntry>()
constructor(options: ProcessGoneDedupeOptions = {}) {
this.windowMs = options.windowMs ?? DEFAULT_PROCESS_GONE_DEDUPE_WINDOW_MS
@@ -17,19 +26,33 @@ export class ProcessGoneDedupe {
}
shouldRecord(key: string, now = Date.now()): boolean {
return this.tryClaim(key, now) !== null
}
tryClaim(key: string, now = Date.now()): ProcessGoneDedupeClaim | null {
this.prune(now)
const previous = this.recentKeys.get(key)
if (previous !== undefined && now - previous < this.windowMs) {
return false
if (previous && now - previous.recordedAt < this.windowMs) {
return null
}
const claim = { key }
// Why: process-gone tuples come from Electron and can vary by exit code;
// keep the short dedupe window without retaining stale tuples forever.
this.recentKeys.delete(key)
this.recentKeys.set(key, now)
this.recentKeys.set(key, { recordedAt: now, claim })
this.prune(now)
return true
return claim
}
release(claim: ProcessGoneDedupeClaim): void {
const current = this.recentKeys.get(claim.key)
// Why: an old failed write must not erase a newer claim for the same
// renderer after the dedupe window expires or bounded entries are evicted.
if (current?.claim === claim) {
this.recentKeys.delete(claim.key)
}
}
get size(): number {
@@ -37,8 +60,8 @@ export class ProcessGoneDedupe {
}
private prune(now: number): void {
for (const [key, recordedAt] of this.recentKeys) {
if (now - recordedAt >= this.windowMs) {
for (const [key, entry] of this.recentKeys) {
if (now - entry.recordedAt >= this.windowMs) {
this.recentKeys.delete(key)
}
}
@@ -71,6 +71,7 @@ describe('process gone diagnostics', () => {
processType: 'Utility',
reason: 'killed',
exitCode: 1,
expectedTeardown: 'app-shutdown',
details: {
name: 'Network Service',
serviceName: 'network.mojom.NetworkService',
@@ -82,6 +83,7 @@ describe('process gone diagnostics', () => {
processType: 'Utility',
reason: 'killed',
exitCode: 1,
expectedTeardown: 'app-shutdown',
name: 'Network Service',
serviceName: 'network.mojom.NetworkService'
})
@@ -4,6 +4,7 @@ import {
type CrashReportBreadcrumbData,
type CrashReportDetailValue
} from '../../shared/crash-reporting'
import type { ExpectedTeardownScope } from './process-gone-classification'
type ProcessMetricLike = {
pid?: unknown
@@ -124,19 +125,22 @@ export function buildSuppressedProcessGoneBreadcrumbData({
processType,
reason,
exitCode,
expectedTeardown,
details
}: {
source: 'renderer' | 'child'
processType: string
reason: string
exitCode: number | null
expectedTeardown: ExpectedTeardownScope
details: Record<string, unknown>
}): CrashReportBreadcrumbData {
const breadcrumb: CrashReportBreadcrumbData = {
source,
processType,
reason,
exitCode
exitCode,
expectedTeardown
}
const name = safeString(details.name)
if (name) {
@@ -0,0 +1,213 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
getVersion: () => '1.2.3-test',
getAppMetrics: () => []
}
}))
import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store'
import { ProcessGoneDedupe } from './process-gone-dedupe'
import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder'
import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer'
type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType<typeof vi.fn> }
function capturingSink(): CapturingSink {
const records: unknown[] = []
const flushMock = vi.fn()
return {
records,
flushMock,
push: (record) => records.push(record),
flush: flushMock,
close: vi.fn()
}
}
function event(overrides: Partial<ProcessGoneCrashEvent> = {}): ProcessGoneCrashEvent {
return {
source: 'renderer',
processType: 'renderer',
reason: 'crashed',
exitCode: 5,
expectedTeardown: 'none',
details: { processType: 'renderer' },
...overrides
}
}
let sink: CapturingSink
beforeEach(() => {
sink = capturingSink()
setActiveSink(sink)
clearCrashBreadcrumbsForTest()
})
afterEach(() => {
vi.restoreAllMocks()
_resetTracerForTests()
clearCrashBreadcrumbsForTest()
})
describe('recordProcessGoneCrash', () => {
it('durably records when the crash report store is unavailable', () => {
recordProcessGoneCrash(null, event(), new ProcessGoneDedupe())
expect(getCrashBreadcrumbSnapshot()).toEqual([
expect.objectContaining({
name: 'crash_report_store_unavailable',
data: expect.objectContaining({
source: 'renderer',
expectedTeardown: 'none'
})
})
])
expect(sink.records).toEqual([
expect.objectContaining({
name: 'crash.breadcrumb',
exit: expect.objectContaining({ _tag: 'Failure' })
})
])
expect(sink.flushMock).toHaveBeenCalledOnce()
})
it('durably records why an expected renderer teardown was suppressed', () => {
const record = vi.fn()
recordProcessGoneCrash(
{ record } as never,
event({ reason: 'killed', exitCode: 1, expectedTeardown: 'renderer-reload' }),
new ProcessGoneDedupe()
)
expect(record).not.toHaveBeenCalled()
expect(getCrashBreadcrumbSnapshot()).toEqual([
expect.objectContaining({
name: 'process_gone_suppressed',
data: expect.objectContaining({ expectedTeardown: 'renderer-reload' })
})
])
expect(sink.records).toEqual([
expect.objectContaining({
name: 'crash.breadcrumb',
attributes: expect.objectContaining({
'breadcrumb.name': 'process_gone_suppressed'
})
})
])
expect(sink.flushMock).toHaveBeenCalledOnce()
})
it('persists a report and flushes the process-gone trace before recovery', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
expect(record).toHaveBeenCalledWith(
expect.objectContaining({ source: 'renderer', reason: 'crashed', exitCode: 5 })
)
expect(sink.records).toEqual([
expect.objectContaining({
name: 'electron.process_gone',
exit: expect.objectContaining({ _tag: 'Failure' })
})
])
expect(sink.flushMock).toHaveBeenCalledOnce()
})
it('still persists the report when the forced trace flush fails', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
sink.flushMock.mockImplementation(() => {
throw new Error('trace disk unavailable')
})
expect(() =>
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
).not.toThrow()
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
})
it('still persists the report when the trace sink handoff fails', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
sink.push = () => {
throw new Error('trace rotation failed')
}
expect(() =>
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
).not.toThrow()
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
})
it('durably records a sanitized crash-report persistence failure', async () => {
const persistError = Object.assign(
new Error('EPERM at C:\\Users\\alice\\AppData\\Roaming\\Orca\\crash-reports.json'),
{ code: 'EPERM' }
)
const record = vi.fn().mockRejectedValue(persistError)
vi.spyOn(console, 'error').mockImplementation(() => {})
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
await vi.waitFor(() => {
expect(getCrashBreadcrumbSnapshot()).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'crash_report_persist_failed',
data: expect.objectContaining({ errorCode: 'EPERM' })
})
])
)
})
expect(sink.records).toHaveLength(2)
expect(sink.records[1]).toEqual(
expect.objectContaining({
name: 'crash.breadcrumb',
exit: expect.objectContaining({ _tag: 'Failure' })
})
)
expect(JSON.stringify(sink.records)).not.toContain('alice')
expect(sink.flushMock).toHaveBeenCalledTimes(2)
})
it('keeps null persistence rejections inside the fail-open diagnostic path', async () => {
const record = vi.fn().mockRejectedValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
recordProcessGoneCrash({ record } as never, event(), new ProcessGoneDedupe())
await vi.waitFor(() =>
expect(getCrashBreadcrumbSnapshot()).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'crash_report_persist_failed',
data: expect.objectContaining({ errorName: 'object', errorMessage: 'null' })
})
])
)
)
})
it('allows the same renderer crash to retry after persistence fails', async () => {
const record = vi
.fn()
.mockRejectedValueOnce(new Error('disk unavailable'))
.mockResolvedValueOnce({ id: 'report-2' })
const dedupe = new ProcessGoneDedupe()
vi.spyOn(console, 'error').mockImplementation(() => {})
recordProcessGoneCrash({ record } as never, event(), dedupe)
await vi.waitFor(() =>
expect(getCrashBreadcrumbSnapshot()).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'crash_report_persist_failed' })])
)
)
recordProcessGoneCrash({ record } as never, event(), dedupe)
await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(2))
})
})
@@ -0,0 +1,137 @@
import os from 'node:os'
import { app } from 'electron'
import { isCrashReportReason, sanitizeCrashReportString } from '../../shared/crash-reporting'
import type { CrashReportStore } from './crash-report-store'
import { getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store'
import { recordDurableCrashBreadcrumb } from './durable-crash-breadcrumb'
import {
shouldRecordProcessGoneCrash,
type ExpectedTeardownScope,
type ProcessGoneSource
} from './process-gone-classification'
import {
buildProcessGoneCrashDetails,
buildSuppressedProcessGoneBreadcrumbData
} from './process-gone-diagnostics'
import {
getProcessGoneDedupeKey,
processGoneDedupe,
type ProcessGoneDedupe
} from './process-gone-dedupe'
import { flushActiveSink, startSpan } from '../observability/tracer'
export type ProcessGoneCrashEvent = {
source: ProcessGoneSource
processType: string
reason: string
exitCode: number | null
expectedTeardown: ExpectedTeardownScope
details: Record<string, unknown>
}
type CrashReportRecorderStore = Pick<CrashReportStore, 'record'>
function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) {
return buildSuppressedProcessGoneBreadcrumbData(event)
}
function persistFailureData(event: ProcessGoneCrashEvent, error: unknown) {
const errorCode =
typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
? error.code
: undefined
return {
...processGoneBreadcrumbData(event),
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: sanitizeCrashReportString(error instanceof Error ? error.message : String(error)),
...(errorCode ? { errorCode } : {})
}
}
export function recordProcessGoneCrash(
store: CrashReportRecorderStore | null,
event: ProcessGoneCrashEvent,
dedupe: ProcessGoneDedupe = processGoneDedupe
): void {
if (!isCrashReportReason(event.reason)) {
return
}
if (
!shouldRecordProcessGoneCrash({
source: event.source,
processType: event.processType,
serviceName:
typeof event.details.serviceName === 'string' ? event.details.serviceName : undefined,
reason: event.reason,
exitCode: event.exitCode,
expectedTeardown: event.expectedTeardown
})
) {
recordDurableCrashBreadcrumb('process_gone_suppressed', processGoneBreadcrumbData(event))
return
}
if (!store) {
recordDurableCrashBreadcrumb(
'crash_report_store_unavailable',
processGoneBreadcrumbData(event),
'Crash report store unavailable'
)
return
}
const key = getProcessGoneDedupeKey(event.source, event.processType, event.reason, event.exitCode)
const claim = dedupe.tryClaim(key)
if (!claim) {
return
}
const crashDetails = buildProcessGoneCrashDetails(event.details)
const breadcrumbs = getCrashBreadcrumbSnapshot()
const span = startSpan('electron.process_gone', {
attributes: {
'crash.source': event.source,
'crash.process_type': event.processType,
'crash.reason': event.reason,
...(event.exitCode !== null ? { 'crash.exit_code': event.exitCode } : {}),
'app.version': app.getVersion(),
platform: process.platform,
osRelease: os.release(),
arch: process.arch,
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
details: crashDetails,
breadcrumbs
}
})
// Why: a renderer crash can be followed by another process exit before the
// trace batch window closes, so make the primary signal durable immediately.
span.fail(
`${event.source} process gone: ${event.processType} ${event.reason} (${event.exitCode ?? 'unknown'})`
)
flushActiveSink()
void store
.record({
source: event.source,
processType: event.processType,
reason: event.reason,
exitCode: event.exitCode,
appVersion: app.getVersion(),
platform: process.platform,
osRelease: os.release(),
arch: process.arch,
electronVersion: process.versions.electron ?? 'unknown',
chromeVersion: process.versions.chrome ?? 'unknown',
details: crashDetails,
breadcrumbs
})
.catch((error) => {
dedupe.release(claim)
console.error('[crash-reporting] Failed to persist crash report:', error)
const data = persistFailureData(event, error)
recordDurableCrashBreadcrumb(
'crash_report_persist_failed',
data,
`${String(data.errorName)}: ${String(data.errorMessage)}`
)
})
}
+7 -1
View File
@@ -2291,6 +2291,7 @@ describe('createPtySubprocess', () => {
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
env: {
ORCA_TERMINAL_HANDLE: 'term_wsl',
ORCA_HERMES_STARTUP_QUERY: 'line one\nline two',
WSLENV: 'FOO/u'
}
})
@@ -2317,7 +2318,12 @@ describe('createPtySubprocess', () => {
// Why: the daemon inherits optional agent-hook env in development. This
// test owns only the terminal handle and Powerlevel10k WSLENV contract.
expect(spawnCall[2].env.WSLENV?.split(':')).toEqual(
expect.arrayContaining(['FOO/u', 'ORCA_TERMINAL_HANDLE/u', POWERLEVEL10K_WIZARD_DISABLE_ENV])
expect.arrayContaining([
'FOO/u',
'ORCA_TERMINAL_HANDLE/u',
'ORCA_HERMES_STARTUP_QUERY',
POWERLEVEL10K_WIZARD_DISABLE_ENV
])
)
})
+6
View File
@@ -56,6 +56,7 @@ 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'
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -717,6 +718,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
// through Windows wsl.exe; non-default env vars need WSLENV import.
addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR'])
}
if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
// Why: the startup wrapper expands this only inside WSL; wsl.exe
// otherwise drops custom Windows environment variables.
addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV])
}
} else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) {
// Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use
// them. ORCA_CODEX_HOME must go too because shell-ready scripts restore
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: vi.fn()
}))
import { getBaseRefDefault } from './repo'
describe('getBaseRefDefault async subprocess bounds', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
})
it('bounds every local probe and degrades a timeout to no default', async () => {
gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.'))
await expect(getBaseRefDefault('/repo')).resolves.toBeNull()
expect(gitExecFileAsyncMock).toHaveBeenCalled()
for (const [, options] of gitExecFileAsyncMock.mock.calls) {
expect(options).toEqual({ cwd: '/repo', timeout: 15_000 })
}
})
it('preserves the same timeout when routing probes through WSL', async () => {
gitExecFileAsyncMock.mockRejectedValue(new Error('git timed out.'))
await expect(
getBaseRefDefault('\\\\wsl.localhost\\Ubuntu\\repo', { wslDistro: 'Ubuntu' })
).resolves.toBeNull()
expect(gitExecFileAsyncMock).toHaveBeenCalled()
for (const [, options] of gitExecFileAsyncMock.mock.calls) {
expect(options).toEqual({
cwd: '\\\\wsl.localhost\\Ubuntu\\repo',
timeout: 15_000,
wslDistro: 'Ubuntu'
})
}
})
})
+20 -3
View File
@@ -15,6 +15,13 @@ type LocalGitExecOptions = {
wslDistro?: string
}
type LocalDefaultBaseRefGitOptions = {
cwd: string
wslDistro?: string
}
const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000
type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate'
type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' }
@@ -651,13 +658,23 @@ export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise<strin
return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref))
}
export function resolveDefaultBaseRefWithLocalGit(
options: LocalDefaultBaseRefGitOptions
): Promise<string | null> {
return resolveDefaultBaseRefViaExec((argv) =>
gitExecFileAsync(argv, {
...options,
// Why: async avoids main-thread stalls, but dead local/WSL filesystems still need a bound.
timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS
})
)
}
async function getDefaultBaseRefAsync(
path: string,
options: LocalGitExecOptions = {}
): Promise<string | null> {
return resolveDefaultBaseRefViaExec((argv) =>
gitExecFileAsync(argv, gitExecOptions(path, options))
)
return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options))
}
/**
+8 -85
View File
@@ -26,7 +26,6 @@ import { closeAllWatchers } from './ipc/filesystem-watcher'
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
import { registerCoreHandlers } from './ipc/register-core-handlers'
import { initObservability, shutdownObservability } from './observability'
import { startSpan } from './observability/tracer'
import { registerMobileHandlers } from './ipc/mobile'
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client'
import { classifyError } from './telemetry/classify-error'
@@ -159,28 +158,21 @@ import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headles
import { AgentAwakeService } from './agent-awake-service'
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
import {
getCrashBreadcrumbSnapshot,
recordCoalescedCrashBreadcrumb,
recordCrashBreadcrumb
} from './crash-reporting/crash-breadcrumb-store'
import { CrashReportStore } from './crash-reporting/crash-report-store'
import {
shouldRecordProcessGoneCrash,
shouldRecoverRendererAfterProcessGone,
type ExpectedTeardownScope
} from './crash-reporting/process-gone-classification'
import {
buildProcessGoneCrashDetails,
buildSuppressedProcessGoneBreadcrumbData
} from './crash-reporting/process-gone-diagnostics'
import { getProcessGoneDedupeKey, processGoneDedupe } from './crash-reporting/process-gone-dedupe'
import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder'
import {
advanceSyntheticTitleSpinnerEntries,
type SyntheticTitleSpinnerEntry
} from './synthetic-title-spinner'
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing'
import { isCrashReportReason } from '../shared/crash-reporting'
import {
getSyntheticAgentTitleProfile,
shouldDriveSyntheticAgentTitleFromHook,
@@ -833,14 +825,6 @@ function openMainWindow(): BrowserWindow {
webContentsId
)
},
shouldRecordRendererCrash: (details, webContentsId) =>
shouldRecordProcessGoneCrash({
source: 'renderer',
processType: 'renderer',
reason: details.reason,
exitCode: details.exitCode ?? null,
expectedTeardown: getExpectedTeardownScope(webContentsId)
}),
shouldRecoverRenderer: (details, webContentsId) =>
shouldRecoverRendererAfterProcessGone({
reason: details.reason,
@@ -1229,75 +1213,14 @@ function recordProcessGoneCrash(
details: Record<string, unknown>,
webContentsId?: number
): void {
if (!crashReports || !isCrashReportReason(reason)) {
return
}
if (
!shouldRecordProcessGoneCrash({
source,
processType,
serviceName: typeof details.serviceName === 'string' ? details.serviceName : undefined,
reason,
exitCode,
expectedTeardown: getExpectedTeardownScope(webContentsId)
})
) {
recordCrashBreadcrumb(
'process_gone_suppressed',
buildSuppressedProcessGoneBreadcrumbData({
source,
processType,
reason,
exitCode,
details
})
)
return
}
const key = getProcessGoneDedupeKey(source, processType, reason, exitCode)
if (!processGoneDedupe.shouldRecord(key)) {
return
}
const crashDetails = buildProcessGoneCrashDetails(details)
const span = startSpan('electron.process_gone', {
attributes: {
'crash.source': source,
'crash.process_type': processType,
'crash.reason': reason,
...(exitCode !== null ? { 'crash.exit_code': exitCode } : {}),
'app.version': app.getVersion(),
platform: process.platform,
osRelease: os.release(),
arch: process.arch,
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
details: crashDetails,
breadcrumbs: getCrashBreadcrumbSnapshot()
}
recordProcessGoneCrashEvent(crashReports, {
source,
processType,
reason,
exitCode,
expectedTeardown: getExpectedTeardownScope(webContentsId),
details
})
// Why: renderer/child crashes belong in the local trace lane so the
// diagnostic bundle has the same process-gone signal as the startup prompt.
span.fail(`${source} process gone: ${processType} ${reason} (${exitCode ?? 'unknown'})`)
void crashReports
.record({
source,
processType,
reason,
exitCode,
appVersion: app.getVersion(),
platform: process.platform,
osRelease: os.release(),
arch: process.arch,
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
details: crashDetails,
// Why: breadcrumbs stay memory-only during normal operation. Persist a
// snapshot only after Electron reports a crash-like process exit.
breadcrumbs: getCrashBreadcrumbSnapshot()
})
.catch((error) => {
console.error('[crash-reporting] Failed to persist crash report:', error)
})
}
function shutdownWatchersOnce(): Promise<void> {
+102 -8
View File
@@ -185,6 +185,42 @@ describe('registerCrashReportingHandlers', () => {
expect(listRecent).not.toHaveBeenCalled()
})
it('copies sanitized submission and diagnostic omission failures for a captured report', async () => {
const pending = report('pending', 'crash-copy-failure')
registerCrashReportingHandlers({
getById: vi.fn(async () => pending),
dismiss: vi.fn(),
markSent: vi.fn(),
markDismissedSent: vi.fn(),
listRecent: vi.fn(async () => [pending]),
record: vi.fn(),
formatDiagnosticText: vi.fn()
} as never)
const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, {
reportId: pending.id,
notes: 'current notes',
submissionFailure: {
error: 'fallback failed at C:\\Users\\alice\\Orca',
diagnosticContext: {
status: 'not_uploaded',
reason: 'attachment token=super-secret-value',
internalEndpointError: 'must-not-cross-copy-boundary'
},
diagnosticBundleFailure: 'must-not-cross-copy-boundary'
}
})
expect(result).toEqual({ ok: true })
const copiedText = String(clipboardWriteTextMock.mock.calls[0]?.[0])
expect(copiedText).toContain('Report ID: crash-copy-failure')
expect(copiedText).toContain('Submission failure:')
expect(copiedText).toContain('Report error: fallback failed at [redacted-path]')
expect(copiedText).toContain('Diagnostic logs not uploaded: attachment token=[redacted]')
expect(copiedText).not.toContain('alice')
expect(copiedText).not.toContain('must-not-cross-copy-boundary')
})
it('returns dismissed unsent reports for the manual Help menu entry', async () => {
const dismissed = report('dismissed', 'crash-help-menu')
registerCrashReportingHandlers({
@@ -244,7 +280,8 @@ describe('registerCrashReportingHandlers', () => {
content: diagnosticBundle().payload,
bytes: 25,
spanCount: 1
}
},
feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
})
expect(markSent).toHaveBeenCalledWith(pending.id)
})
@@ -294,7 +331,8 @@ describe('registerCrashReportingHandlers', () => {
content: diagnosticBundle().payload,
bytes: 25,
spanCount: 1
}
},
feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
})
expect(submitFeedbackMock).toHaveBeenCalledWith(
expect.objectContaining({ feedback: expect.stringContaining('Status: attached') })
@@ -347,6 +385,52 @@ describe('registerCrashReportingHandlers', () => {
)
})
it('marks the report sent when transport retries successfully without diagnostic logs', async () => {
const pending = report('pending', 'crash-degraded')
const sent = report('sent', pending.id)
const markSent = vi.fn(async () => sent)
submitFeedbackMock.mockResolvedValueOnce({
ok: true,
diagnosticBundleFailure: { status: 413, error: 'status 413' }
})
registerCrashReportingHandlers({
getById: vi.fn(async () => pending),
dismiss: vi.fn(),
markSent,
markDismissedSent: vi.fn(),
listRecent: vi.fn(async () => [pending]),
record: vi.fn(),
formatDiagnosticText: vi.fn()
} as never)
const result = await handlers.get('crashReports:submit')?.(null, {
reportId: pending.id,
notes: 'manual report',
submitAnonymously: true,
githubLogin: null,
githubEmail: null
})
expect(result).toEqual({
ok: true,
report: sent,
diagnosticBundle: {
status: 'not_uploaded',
reason: 'diagnostic log attachment failed: status 413',
bundleSubmissionId: 'bundleabcdefghijklmnop',
bytes: 25,
spanCount: 1
}
})
expect(markSent).toHaveBeenCalledWith(pending.id)
expect(submitFeedbackMock).toHaveBeenCalledWith(
expect.objectContaining({
feedback: expect.stringContaining('Status: attached'),
feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
})
)
})
it('submits the crash report without logs when the user excludes diagnostic logs', async () => {
registerCrashReportingHandlers({
getById: vi.fn(async () => null),
@@ -459,7 +543,8 @@ describe('registerCrashReportingHandlers', () => {
content: diagnosticBundle().payload,
bytes: 25,
spanCount: 1
}
},
feedbackWithoutDiagnosticBundle: expect.stringContaining('Status: not uploaded')
})
expect(markDismissedSent).toHaveBeenCalledWith(dismissed.id)
})
@@ -492,8 +577,9 @@ describe('registerCrashReportingHandlers', () => {
const markSent = vi.fn()
submitFeedbackMock.mockResolvedValue({
ok: false,
status: 500,
error: 'status 500'
status: null,
error: 'report-only network failed',
diagnosticBundleFailure: { status: 500, error: 'status 500' }
})
registerCrashReportingHandlers({
getById: vi.fn(async () => pending),
@@ -514,10 +600,18 @@ describe('registerCrashReportingHandlers', () => {
expect(result).toEqual({
ok: false,
status: 500,
error: 'status 500',
report: pending
status: null,
error: 'report-only network failed',
report: pending,
diagnosticBundle: {
status: 'not_uploaded',
reason: 'diagnostic log attachment failed: status 500',
bundleSubmissionId: 'bundleabcdefghijklmnop',
bytes: 25,
spanCount: 1
}
})
expect(result).not.toHaveProperty('diagnosticBundleFailure')
expect(submitFeedbackMock).toHaveBeenCalledWith(
expect.objectContaining({
diagnosticBundle: {
+70 -90
View File
@@ -4,6 +4,7 @@ import os from 'node:os'
import { app, clipboard, ipcMain } from 'electron'
import {
type CrashReportBreadcrumbData,
type CrashReportCopyDiagnosticsArgs,
type CrashReportDiagnosticBundle,
type ReactErrorBoundaryReportArgs,
type ReactErrorBoundaryReportResult,
@@ -20,14 +21,17 @@ import {
getCrashBreadcrumbSnapshot,
recordCrashBreadcrumb
} from '../crash-reporting/crash-breadcrumb-store'
import { collectDiagnosticBundle, getDiagnosticsStatus } from '../observability'
import { resolveDiagnosticOrcaChannel } from '../observability/diagnostic-upload-endpoint'
import { startSpan } from '../observability/tracer'
import type { FeedbackDiagnosticBundleAttachment } from './feedback'
import {
diagnosticBundleForReportOnlyRetry,
prepareCrashDiagnosticBundle,
resolveSubmittedDiagnosticBundle
} from '../crash-reporting/crash-feedback-diagnostic-bundle'
import {
assertClipboardTextWriteWithinLimit,
isClipboardTextWriteTooLargeError
} from '../../shared/clipboard-text'
import { formatCrashReportCopyText } from '../crash-reporting/crash-report-copy-text'
const inFlightSubmissions = new Set<string>()
const submittedReportIds = new Set<string>()
@@ -37,7 +41,6 @@ const RENDERER_ERROR_DEDUPE_MS = 10 * 60 * 1000
const MAX_RENDERER_ERROR_KEY_AGE_MS = RENDERER_ERROR_DEDUPE_MS * 2
const MAX_RECENT_RENDERER_ERROR_REPORT_KEYS = 256
const MAX_SUBMITTED_REPORT_IDS = 256
const CRASH_REPORT_LOG_LOOKBACK_MINUTES = 3 * 24 * 60
const REACT_ERROR_BOUNDARY_SURFACES = new Set<ReactErrorBoundaryReportArgs['surface']>([
'app-root',
@@ -52,11 +55,6 @@ const REACT_ERROR_BOUNDARY_SURFACES = new Set<ReactErrorBoundaryReportArgs['surf
'rich-markdown-editor'
])
type CrashDiagnosticBundleAttachment = {
readonly diagnosticBundle: CrashReportDiagnosticBundle
readonly feedbackDiagnosticBundle?: FeedbackDiagnosticBundleAttachment
}
function stringField(value: unknown, maxLength: number): string | undefined {
if (typeof value !== 'string') {
return undefined
@@ -302,10 +300,6 @@ function recordRendererBreadcrumbTrace(
span.end()
}
function formatUnknownError(error: unknown): string {
return sanitizeCrashReportString(error instanceof Error ? error.message : String(error))
}
function buildUncapturedCrashReportText(
notes: string | undefined,
diagnosticBundle?: CrashReportDiagnosticBundle
@@ -325,58 +319,6 @@ function buildUncapturedCrashReportText(
)
}
function skippedCrashDiagnosticBundle(): CrashDiagnosticBundleAttachment {
return {
diagnosticBundle: {
status: 'not_uploaded',
reason: 'diagnostic log upload skipped by user'
}
}
}
function collectCrashDiagnosticBundleAttachment(): CrashDiagnosticBundleAttachment {
const status = getDiagnosticsStatus()
if (!status.bundleEnabled) {
return {
diagnosticBundle: {
status: 'not_uploaded',
reason: status.disabledReason ?? 'diagnostic bundle collection is disabled'
}
}
}
let bundle: ReturnType<typeof collectDiagnosticBundle>
try {
bundle = collectDiagnosticBundle({
appVersion: app.getVersion(),
platform: os.platform(),
arch: os.arch(),
osRelease: os.release(),
orcaChannel: resolveDiagnosticOrcaChannel(),
// Why: Help > Report Crash is often used after relaunch, long after the
// default 30 minute support bundle window would miss the failure context.
lookbackMinutes: CRASH_REPORT_LOG_LOOKBACK_MINUTES
})
} catch (error) {
return { diagnosticBundle: { status: 'not_uploaded', reason: formatUnknownError(error) } }
}
return {
diagnosticBundle: {
status: 'attached',
bundleSubmissionId: bundle.bundleSubmissionId,
bytes: bundle.bytes,
spanCount: bundle.spanCount
},
feedbackDiagnosticBundle: {
bundleSubmissionId: bundle.bundleSubmissionId,
content: bundle.payload,
bytes: bundle.bytes,
spanCount: bundle.spanCount
}
}
}
export function registerCrashReportingHandlers(store: CrashReportStore): void {
ipcMain.removeHandler('crashReports:getLatestPending')
ipcMain.handle('crashReports:getLatestPending', () => getLatestPendingReport(store))
@@ -412,15 +354,16 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
ipcMain.removeHandler('crashReports:copyLatestDiagnostics')
ipcMain.handle(
'crashReports:copyLatestDiagnostics',
async (_event, args?: { reportId?: string; notes?: string }) => {
async (_event, args?: CrashReportCopyDiagnosticsArgs) => {
const report = await getRequestedCrashReport(store, args)
if (!report) {
clipboard.writeText(buildUncapturedCrashReportText(args?.notes))
return { ok: true as const }
}
const baseText = report
? formatCrashReportText(report, args?.notes)
: buildUncapturedCrashReportText(args?.notes)
try {
clipboard.writeText(
assertClipboardTextWriteWithinLimit(formatCrashReportText(report, args?.notes))
assertClipboardTextWriteWithinLimit(
formatCrashReportCopyText(baseText, args?.submissionFailure)
)
)
} catch (error) {
if (isClipboardTextWriteTooLargeError(error)) {
@@ -448,11 +391,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
async (_event, args: CrashReportSubmitArgs): Promise<CrashReportSubmitResult> => {
const report = await getRequestedCrashReport(store, args)
if (!report) {
const diagnosticUpload =
args.includeDiagnosticLogs === false
? skippedCrashDiagnosticBundle()
: collectCrashDiagnosticBundleAttachment()
const diagnosticUpload = prepareCrashDiagnosticBundle(args.includeDiagnosticLogs !== false)
const diagnosticBundle = diagnosticUpload.diagnosticBundle
const reportOnlyDiagnosticBundle = diagnosticBundleForReportOnlyRetry(diagnosticUpload)
const result = await submitFeedback({
feedback: buildUncapturedCrashReportText(args.notes, diagnosticBundle),
submissionType: 'crash',
@@ -460,14 +401,26 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
githubLogin: args.githubLogin,
githubEmail: args.githubEmail,
...(diagnosticUpload.feedbackDiagnosticBundle
? { diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle }
? {
diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle,
feedbackWithoutDiagnosticBundle: buildUncapturedCrashReportText(
args.notes,
reportOnlyDiagnosticBundle
)
}
: {})
})
const submittedDiagnosticBundle = resolveSubmittedDiagnosticBundle(diagnosticUpload, result)
return result.ok
? { ok: true, report: null, diagnosticBundle }
? { ok: true, report: null, diagnosticBundle: submittedDiagnosticBundle }
: {
...result,
report: null
// Why: the transport-only attachment failure may contain raw
// endpoint detail; only its sanitized bundle reason crosses IPC.
ok: false,
status: result.status,
error: result.error,
report: null,
diagnosticBundle: submittedDiagnosticBundle
}
}
const canSubmitDismissedReport = Boolean(args.reportId && report.status === 'dismissed')
@@ -491,11 +444,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
inFlightSubmissions.add(report.id)
try {
const diagnosticUpload =
args.includeDiagnosticLogs === false
? skippedCrashDiagnosticBundle()
: collectCrashDiagnosticBundleAttachment()
const diagnosticUpload = prepareCrashDiagnosticBundle(args.includeDiagnosticLogs !== false)
const diagnosticBundle = diagnosticUpload.diagnosticBundle
const reportOnlyDiagnosticBundle = diagnosticBundleForReportOnlyRetry(diagnosticUpload)
const result = await submitFeedback({
feedback: formatCrashReportText(report, args.notes, diagnosticBundle),
submissionType: 'crash',
@@ -503,13 +454,26 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
githubLogin: args.githubLogin,
githubEmail: args.githubEmail,
...(diagnosticUpload.feedbackDiagnosticBundle
? { diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle }
? {
diagnosticBundle: diagnosticUpload.feedbackDiagnosticBundle,
feedbackWithoutDiagnosticBundle: formatCrashReportText(
report,
args.notes,
reportOnlyDiagnosticBundle
)
}
: {})
})
const submittedDiagnosticBundle = resolveSubmittedDiagnosticBundle(diagnosticUpload, result)
if (!result.ok) {
return {
...result,
report
// Why: keep the renderer contract allow-listed instead of leaking
// the transport's internal diagnosticBundleFailure object.
ok: false,
status: result.status,
error: result.error,
report,
diagnosticBundle: submittedDiagnosticBundle
}
}
rememberSubmittedReportId(report.id)
@@ -518,21 +482,37 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void {
// Why: startup prompts are dismissed before the user can send from
// the still-open dialog, so successful uploads must update storage.
const sent = await store.markDismissedSent(report.id)
return { ok: true, report: sent ?? { ...report, status: 'sent' }, diagnosticBundle }
return {
ok: true,
report: sent ?? { ...report, status: 'sent' },
diagnosticBundle: submittedDiagnosticBundle
}
} catch (error) {
console.error('[crash-reporting] Failed to mark dismissed crash report sent:', error)
return { ok: true, report: { ...report, status: 'sent' }, diagnosticBundle }
return {
ok: true,
report: { ...report, status: 'sent' },
diagnosticBundle: submittedDiagnosticBundle
}
}
}
try {
const sent = await store.markSent(report.id)
return { ok: true, report: sent ?? { ...report, status: 'sent' }, diagnosticBundle }
return {
ok: true,
report: sent ?? { ...report, status: 'sent' },
diagnosticBundle: submittedDiagnosticBundle
}
} catch (error) {
// Why: the upstream submission already succeeded. A local persistence
// failure must not present as upload failure or invite duplicate sends
// during this app session.
console.error('[crash-reporting] Failed to mark crash report sent:', error)
return { ok: true, report: { ...report, status: 'sent' }, diagnosticBundle }
return {
ok: true,
report: { ...report, status: 'sent' },
diagnosticBundle: submittedDiagnosticBundle
}
}
} finally {
inFlightSubmissions.delete(report.id)
+141 -4
View File
@@ -22,9 +22,34 @@ function okResponse(): Response {
return { ok: true, status: 200 } as unknown as Response
}
function postedBody(): Record<string, unknown> {
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined
return JSON.parse(String(init?.body)) as Record<string, unknown>
function errorResponse(status: number): Response {
return { ok: false, status } as unknown as Response
}
function requestInit(callIndex = 0): RequestInit {
return fetchMock.mock.calls[callIndex]?.[1] as RequestInit
}
function postedBody(callIndex = 0): Record<string, unknown> {
return JSON.parse(String(requestInit(callIndex).body)) as Record<string, unknown>
}
function diagnosticSubmitArgs(): Parameters<typeof submitFeedback>[0] {
return {
feedback: '[Crash Report]\n\nDiagnostic log:\n- Status: attached',
feedbackWithoutDiagnosticBundle:
'[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed',
submissionType: 'crash',
submitAnonymously: true,
githubLogin: null,
githubEmail: null,
diagnosticBundle: {
bundleSubmissionId: 'bundleabcdefghijklmnop',
content: '{"type":"bundle-header"}\n',
bytes: 25,
spanCount: 1
}
}
}
describe('submitFeedback', () => {
@@ -139,6 +164,118 @@ describe('submitFeedback', () => {
expect(JSON.parse(String(feedbackInit?.body))).not.toHaveProperty('diagnosticBundle')
})
it('retries a rejected diagnostic attachment as report-only JSON on the website API', async () => {
fetchMock.mockResolvedValueOnce(errorResponse(413)).mockResolvedValueOnce(okResponse())
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
ok: true,
diagnosticBundleFailure: { status: 413, error: 'status 413' }
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
expect(requestInit(0).body).toBeInstanceOf(FormData)
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
expect(requestInit(1).headers).toEqual({
'Content-Type': 'application/json'
})
expect(postedBody(1)).toMatchObject({
feedback:
'[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed',
submissionType: 'crash'
})
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
})
it('retries a diagnostic attachment server error as report-only JSON on the fallback API', async () => {
fetchMock.mockResolvedValueOnce(errorResponse(500)).mockResolvedValueOnce(okResponse())
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
ok: true,
diagnosticBundleFailure: { status: 500, error: 'status 500' }
})
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback')
expect(requestInit(1).headers).toEqual({ 'Content-Type': 'application/json' })
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
})
it('retries a diagnostic attachment network error as report-only JSON on the fallback API', async () => {
fetchMock.mockRejectedValueOnce(new Error('attachment network failed'))
fetchMock.mockResolvedValueOnce(okResponse())
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
ok: true,
diagnosticBundleFailure: { status: null, error: 'attachment network failed' }
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback')
expect(requestInit(1).body).not.toBeInstanceOf(FormData)
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
})
it('allows 60 seconds for a diagnostic attachment before retrying report-only JSON', async () => {
vi.useFakeTimers()
fetchMock.mockImplementationOnce((_url: string, init?: RequestInit) => {
return new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('runtime abort text')))
})
})
fetchMock.mockResolvedValueOnce(okResponse())
const result = submitFeedback(diagnosticSubmitArgs())
await vi.advanceTimersByTimeAsync(59_999)
expect(fetchMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
await expect(result).resolves.toEqual({
ok: true,
diagnosticBundleFailure: { status: null, error: 'request timed out after 60 seconds' }
})
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.onorca.dev/v1/feedback')
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
})
it('retries a proxy-rejected diagnostic attachment as website JSON', async () => {
fetchMock.mockResolvedValueOnce(errorResponse(403)).mockResolvedValueOnce(okResponse())
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
ok: true,
diagnosticBundleFailure: { status: 403, error: 'status 403' }
})
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
expect(requestInit(1).body).not.toBeInstanceOf(FormData)
})
it.each([401, 409, 429])(
'does not retry a diagnostic attachment rejected with status %s',
async (status) => {
fetchMock.mockResolvedValueOnce(errorResponse(status))
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
ok: false,
status,
error: `status ${status}`
})
expect(fetchMock).toHaveBeenCalledTimes(1)
}
)
it('preserves attachment and report-only failures when the degraded retry fails', async () => {
fetchMock.mockRejectedValueOnce(new Error('attachment network failed'))
fetchMock.mockRejectedValueOnce(new Error('report-only network failed'))
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
ok: false,
status: null,
error: 'report-only network failed',
diagnosticBundleFailure: { status: null, error: 'attachment network failed' }
})
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('falls back when the primary feedback request stalls', async () => {
vi.useFakeTimers()
fetchMock.mockImplementation((url: string, init?: RequestInit) => {
@@ -184,7 +321,7 @@ describe('submitFeedback', () => {
await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toEqual({
ok: false,
status: null,
error: 'fallback aborted'
error: 'request timed out after 10 seconds'
})
expect(fetchMock).toHaveBeenCalledTimes(2)
})
+107 -4
View File
@@ -9,7 +9,11 @@ import { app, ipcMain, net } from 'electron'
const FEEDBACK_API_URL = 'https://www.onorca.dev/v1/feedback'
const FEEDBACK_API_FALLBACK_URL = 'https://api.onorca.dev/v1/feedback'
const FEEDBACK_REQUEST_TIMEOUT_MS = 10_000
const FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS = 60_000
const DIAGNOSTIC_BUNDLE_CONTENT_TYPE = 'application/x-ndjson'
// Why: corporate filters can reject multipart with 403 while allowing the
// small JSON report, so content-shaped failures should shed the attachment.
const DIAGNOSTIC_BUNDLE_JSON_RETRY_STATUSES = new Set([400, 403, 408, 413, 415, 422])
export type FeedbackSubmissionType = 'feedback' | 'crash'
@@ -39,13 +43,21 @@ type FeedbackSubmitBody = {
diagnosticBundle?: FeedbackDiagnosticBundleAttachment
}
export type FeedbackRequestFailure = {
status: number | null
error: string
}
export type FeedbackSubmitResult =
| { ok: true }
| { ok: false; status: number | null; error: string }
| { ok: true; diagnosticBundleFailure?: FeedbackRequestFailure }
| ({ ok: false } & FeedbackRequestFailure & {
diagnosticBundleFailure?: FeedbackRequestFailure
})
type InternalFeedbackSubmitArgs = FeedbackSubmitArgs & {
submissionType?: FeedbackSubmissionType
diagnosticBundle?: FeedbackDiagnosticBundleAttachment
feedbackWithoutDiagnosticBundle?: string
}
// Why: the Slack notification and any follow-up investigation need to know
@@ -74,11 +86,15 @@ function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody {
}
}
async function postFeedback(url: string, body: FeedbackSubmitBody): Promise<Response> {
async function postFeedback(
url: string,
body: FeedbackSubmitBody,
timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS
): Promise<Response> {
const controller = new AbortController()
// Why: a silent feedback endpoint should not leave IPC or crash-report
// submission flows pending forever.
const timeout = setTimeout(() => controller.abort(), FEEDBACK_REQUEST_TIMEOUT_MS)
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const init: RequestInit = {
method: 'POST',
@@ -86,6 +102,13 @@ async function postFeedback(url: string, body: FeedbackSubmitBody): Promise<Resp
signal: controller.signal
}
return await net.fetch(url, init)
} catch (error) {
// Why: Electron and Node use different AbortError messages. Normalize our
// client deadline so support logs explain which request budget expired.
if (controller.signal.aborted) {
throw new Error(`request timed out after ${timeoutMs / 1000} seconds`)
}
throw error
} finally {
clearTimeout(timeout)
}
@@ -140,6 +163,14 @@ function messageFromError(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function responseFailure(response: Response): FeedbackRequestFailure {
return { status: response.status, error: `status ${response.status}` }
}
function errorFailure(error: unknown): FeedbackRequestFailure {
return { status: null, error: messageFromError(error) }
}
async function submitFallbackFeedback(
body: FeedbackSubmitBody,
primaryError?: unknown
@@ -163,10 +194,82 @@ async function submitFallbackFeedback(
}
}
function diagnosticRetryUrl(status: number): string | null {
if (DIAGNOSTIC_BUNDLE_JSON_RETRY_STATUSES.has(status)) {
return FEEDBACK_API_URL
}
if (status === 404 || status >= 500) {
return FEEDBACK_API_FALLBACK_URL
}
return null
}
async function submitFeedbackWithoutDiagnosticBundle(
url: string,
body: FeedbackSubmitBody,
diagnosticBundleFailure: FeedbackRequestFailure
): Promise<FeedbackSubmitResult> {
try {
const response = await postFeedback(url, body)
if (response.ok) {
return { ok: true, diagnosticBundleFailure }
}
return { ok: false, ...responseFailure(response), diagnosticBundleFailure }
} catch (error) {
return { ok: false, ...errorFailure(error), diagnosticBundleFailure }
}
}
async function submitFeedbackWithDiagnosticBundle(
body: FeedbackSubmitBody,
bodyWithoutDiagnosticBundle: FeedbackSubmitBody | null
): Promise<FeedbackSubmitResult> {
try {
// Why: diagnostic bundles can approach 4 MiB and need more upload time than
// the small JSON report-only path, especially on constrained connections.
const response = await postFeedback(
FEEDBACK_API_URL,
body,
FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS
)
if (response.ok) {
return { ok: true }
}
const failure = responseFailure(response)
if (bodyWithoutDiagnosticBundle) {
const retryUrl = diagnosticRetryUrl(response.status)
if (retryUrl) {
return submitFeedbackWithoutDiagnosticBundle(retryUrl, bodyWithoutDiagnosticBundle, failure)
}
}
return { ok: false, ...failure }
} catch (error) {
const failure = errorFailure(error)
return bodyWithoutDiagnosticBundle
? submitFeedbackWithoutDiagnosticBundle(
FEEDBACK_API_FALLBACK_URL,
bodyWithoutDiagnosticBundle,
failure
)
: { ok: false, ...failure }
}
}
export async function submitFeedback(
args: InternalFeedbackSubmitArgs
): Promise<FeedbackSubmitResult> {
const body = buildSubmitBody(args)
if (body.diagnosticBundle) {
const bodyWithoutDiagnosticBundle =
args.feedbackWithoutDiagnosticBundle !== undefined
? buildSubmitBody({
...args,
feedback: args.feedbackWithoutDiagnosticBundle,
diagnosticBundle: undefined
})
: null
return submitFeedbackWithDiagnosticBundle(body, bodyWithoutDiagnosticBundle)
}
try {
const res = await postFeedback(FEEDBACK_API_URL, body)
if (res.ok) {
+6 -3
View File
@@ -27,7 +27,11 @@ import type {
import { getPRForBranch } from '../github/client'
import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree'
import { getBranchConflictKind, resolveDefaultBaseRefViaExec } from '../git/repo'
import {
getBranchConflictKind,
resolveDefaultBaseRefViaExec,
resolveDefaultBaseRefWithLocalGit
} from '../git/repo'
import { resolveLocalGitUsername } from '../git/git-username'
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
import { resolveWorktreeCreateBase } from '../worktree-create-base'
@@ -1999,8 +2003,7 @@ export async function createLocalWorktree(
const baseBranch = await resolveWorktreeCreateBase({
requestedBaseBranch: args.baseBranch,
repoWorktreeBaseRef: repo.worktreeBaseRef,
resolveDefaultBaseRef: () =>
resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions)),
resolveDefaultBaseRef: () => resolveDefaultBaseRefWithLocalGit(localGitExecOptions),
isBaseUsable: async (baseBranchCandidate) => {
if (runtime) {
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
+5
View File
@@ -10,6 +10,7 @@ const {
removeWorktreeMock,
resolveLocalGitUsernameMock,
getDefaultBaseRefMock,
resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExecMock,
getBranchConflictKindMock,
getPRForBranchMock,
@@ -38,6 +39,7 @@ const {
removeWorktreeMock: vi.fn(),
resolveLocalGitUsernameMock: vi.fn(),
getDefaultBaseRefMock: vi.fn(),
resolveDefaultBaseRefWithLocalGitMock: vi.fn(),
resolveDefaultBaseRefViaExecMock: vi.fn(),
getBranchConflictKindMock: vi.fn(),
getPRForBranchMock: vi.fn(),
@@ -84,6 +86,7 @@ vi.mock('../git/runner', () => ({
vi.mock('../git/repo', () => ({
getDefaultBaseRef: getDefaultBaseRefMock,
resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
getBranchConflictKind: getBranchConflictKindMock
}))
@@ -164,6 +167,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
removeWorktreeMock.mockReset()
resolveLocalGitUsernameMock.mockReset()
getDefaultBaseRefMock.mockReset()
resolveDefaultBaseRefWithLocalGitMock.mockReset()
resolveDefaultBaseRefViaExecMock.mockReset()
getBranchConflictKindMock.mockReset()
getPRForBranchMock.mockReset()
@@ -230,6 +234,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
store.setWorktreeMeta.mockReturnValue({})
resolveLocalGitUsernameMock.mockResolvedValue('')
getDefaultBaseRefMock.mockReturnValue('origin/main')
resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main')
resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main')
getBranchConflictKindMock.mockResolvedValue(null)
getPRForBranchMock.mockResolvedValue(null)
+16 -16
View File
@@ -28,7 +28,8 @@ const {
removeWorktreeMock,
forceDeleteLocalBranchMock,
resolveLocalGitUsernameMock,
getDefaultBaseRefMock,
getBaseRefDefaultMock,
resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExecMock,
getDefaultRemoteMock,
getBranchConflictKindMock,
@@ -77,7 +78,8 @@ const {
removeWorktreeMock: vi.fn(),
forceDeleteLocalBranchMock: vi.fn(),
resolveLocalGitUsernameMock: vi.fn(),
getDefaultBaseRefMock: vi.fn(),
getBaseRefDefaultMock: vi.fn(),
resolveDefaultBaseRefWithLocalGitMock: vi.fn(),
resolveDefaultBaseRefViaExecMock: vi.fn(),
getDefaultRemoteMock: vi.fn(),
getBranchConflictKindMock: vi.fn(),
@@ -130,7 +132,8 @@ vi.mock('../git/runner', () => ({
}))
vi.mock('../git/repo', () => ({
getDefaultBaseRef: getDefaultBaseRefMock,
getBaseRefDefault: getBaseRefDefaultMock,
resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
getDefaultRemote: getDefaultRemoteMock,
getBranchConflictKind: getBranchConflictKindMock
@@ -300,7 +303,8 @@ describe('registerWorktreeHandlers', () => {
removeWorktreeMock,
forceDeleteLocalBranchMock,
resolveLocalGitUsernameMock,
getDefaultBaseRefMock,
getBaseRefDefaultMock,
resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExecMock,
getDefaultRemoteMock,
getBranchConflictKindMock,
@@ -401,7 +405,8 @@ describe('registerWorktreeHandlers', () => {
])
store.getAllWorktreeLineage.mockReturnValue({})
resolveLocalGitUsernameMock.mockResolvedValue('')
getDefaultBaseRefMock.mockReturnValue('origin/main')
getBaseRefDefaultMock.mockResolvedValue('origin/main')
resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue('origin/main')
resolveDefaultBaseRefViaExecMock.mockResolvedValue('origin/main')
getDefaultRemoteMock.mockResolvedValue('origin')
getBranchConflictKindMock.mockResolvedValue(null)
@@ -525,6 +530,7 @@ describe('registerWorktreeHandlers', () => {
await handlers['worktrees:prefetchCreateBase'](null, { repoId: 'repo-1' })
expect(getBaseRefDefaultMock).toHaveBeenCalledWith('/workspace/repo')
expect(runtimeStub.resolveRemoteTrackingBase).toHaveBeenCalledWith(
'/workspace/repo',
'origin/master'
@@ -2050,12 +2056,6 @@ describe('registerWorktreeHandlers', () => {
it('routes local worktree creation through the selected WSL project runtime', async () => {
mockSelectedWslProjectRuntime()
resolveDefaultBaseRefViaExecMock.mockImplementation(
async (exec: (args: string[]) => Promise<{ stdout: string }>) => {
await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])
return 'origin/main'
}
)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/repo',
@@ -2087,10 +2087,10 @@ describe('registerWorktreeHandlers', () => {
false,
{ wslDistro: 'Ubuntu' }
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
)
expect(resolveDefaultBaseRefWithLocalGitMock).toHaveBeenCalledWith({
cwd: '/workspace/repo',
wslDistro: 'Ubuntu'
})
expect(getBranchConflictKindMock).toHaveBeenCalledWith(
'/workspace/repo',
'improve-dashboard',
@@ -4980,7 +4980,7 @@ describe('registerWorktreeHandlers', () => {
// no origin/main, no origin/master, and no local main/master), we must
// fail loudly with a message that prompts the user to pick a base
// branch, not hand a non-existent ref to `git worktree add`.
resolveDefaultBaseRefViaExecMock.mockResolvedValue(null)
resolveDefaultBaseRefWithLocalGitMock.mockResolvedValue(null)
store.getRepo.mockReturnValue({
id: 'repo-1',
path: '/workspace/repo',
+18
View File
@@ -5,6 +5,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
_resetTracerForTests,
flushActiveSink,
getActiveSpanContext,
setActiveSink,
startSpan,
@@ -69,6 +70,23 @@ describe('tracer — basic span lifecycle', () => {
expect(r.exit.cause).toBe('user-cancelled')
})
it('keeps forced sink flush failures from escaping crash boundaries', () => {
sink.flush = () => {
throw new Error('disk unavailable')
}
expect(() => flushActiveSink()).not.toThrow()
})
it('keeps sink handoff failures from escaping instrumented operations', async () => {
sink.push = () => {
throw new Error('trace rotation failed')
}
expect(() => startSpan('test').fail('boom')).not.toThrow()
await expect(withSpan('async-test', async () => 'result')).resolves.toBe('result')
})
it('end() is idempotent — second call is a no-op', () => {
const span = startSpan('test')
span.end()
+17 -1
View File
@@ -112,6 +112,17 @@ export function setActiveSink(sink: TracerSink | null): void {
activeSink = sink
}
/** Force records already handed to the tracer onto disk. Reserve this for
* crash boundaries where the process may not survive the normal batch window. */
export function flushActiveSink(): void {
try {
activeSink?.flush()
} catch {
// Why: a disk/rotation failure in optional local diagnostics must never
// turn a recoverable renderer crash into a main-process crash.
}
}
/** Get the current parent context, or `undefined` if we are at the top of
* the trace tree. Renderer-IPC entry points capture this, embed it in
* span-event attributes (so cross-process spans can be visually linked
@@ -221,7 +232,12 @@ export function startSpan(
// Wrap in a `type: 'effect-span'` envelope so the NDJSON file is
// compatible with Effect-style span output. Effect-oriented consumers
// (the LGTM stack, jq cookbooks) can read the file unchanged.
activeSink?.push({ type: 'effect-span', ...redacted })
try {
activeSink?.push({ type: 'effect-span', ...redacted })
} catch {
// Why: tracing is optional; a local file rotation failure must not turn
// the instrumented operation into an application failure.
}
}
return {
@@ -675,7 +675,8 @@ describe('LocalPtyProvider', () => {
await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
env: { ORCA_HERMES_STARTUP_QUERY: 'line one\nline two' }
})
} finally {
if (savedCodexHome === undefined) {
@@ -693,8 +694,12 @@ describe('LocalPtyProvider', () => {
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
expect(spawnCall[2].env.WSLENV).toBe(
'ORCA_TERMINAL_HANDLE/u:POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
expect(spawnCall[2].env.WSLENV?.split(':')).toEqual(
expect.arrayContaining([
'ORCA_TERMINAL_HANDLE/u',
'ORCA_HERMES_STARTUP_QUERY',
POWERLEVEL10K_WIZARD_DISABLE_ENV
])
)
})
+6
View File
@@ -57,6 +57,7 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process
import { readWindowsConptyProcessIds } from './windows-conpty-process-membership'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd'
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
@@ -567,6 +568,11 @@ export class LocalPtyProvider implements IPtyProvider {
// through Windows wsl.exe; non-default env vars need WSLENV import.
addWslEnvKeys(finalEnv, ['CLAUDE_CONFIG_DIR'])
}
if (finalEnv[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) {
// Why: the startup wrapper expands this only inside WSL; wsl.exe
// otherwise drops custom Windows environment variables.
addWslEnvKeys(finalEnv, [ORCA_HERMES_STARTUP_QUERY_ENV])
}
} else if (codexHomeWslInfo || isWslCodexHomeForHost(finalEnv.CODEX_HOME)) {
// Why: WSL-managed Codex homes are Linux paths. Windows Codex cannot use
// them. ORCA_CODEX_HOME must go too because shell-ready scripts restore
@@ -243,6 +243,50 @@ describe('mobile presence lock — multi-mobile semantics', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('elects one query responder and promotes the survivor on unsubscribe', async () => {
const { runtime } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true)
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false)
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true)
await runtime.reclaimTerminalForDesktop('pty-1')
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false)
})
it('keeps the earliest subscriber authoritative after a soft-leave resubscribe', async () => {
const { runtime } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
await vi.advanceTimersByTimeAsync(10)
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true)
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false)
})
it('excludes an older passive desktop-mode subscriber from reply authority', async () => {
const { runtime } = createRuntime()
runtime.setMobileDisplayMode('pty-1', 'desktop')
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
await vi.advanceTimersByTimeAsync(10)
await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 })
await vi.advanceTimersByTimeAsync(10)
runtime.markMobileActor('pty-1', 'phone-B')
runtime.setMobileDisplayMode('pty-1', 'auto')
await runtime.applyMobileDisplayMode('pty-1')
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(false)
expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true)
})
it('most-recent actor wins active phone-fit dims (B subscribes after A)', async () => {
const { runtime, ptySizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
+20 -11
View File
@@ -45,7 +45,7 @@ import {
runHook,
shouldRunSetupForCreate
} from '../hooks'
import { getBranchConflictKind, getDefaultBaseRef } from '../git/repo'
import { getBaseRefDefault, getBranchConflictKind } from '../git/repo'
import type { OrchestrationDb } from './orchestration/db'
import type { MessagePriority, MessageRow, MessageType } from './orchestration/types'
import {
@@ -521,16 +521,23 @@ vi.mock('../github/issues', async (importOriginal) => {
}
})
// Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a
// fallback base branch. Real resolution shells out to `git` against the
// test's fabricated repo path, which has no refs, so we stub it to a
// predictable 'origin/main'. The runtime no longer silently fabricates this
// default, so tests that want the legacy behavior must express it via the mock.
// Why: CLI worktree creation resolves a default against fabricated repo paths
// in these tests, so keep the async resolver deterministic.
vi.mock('../git/repo', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
const actualGetBaseRefDefault = actual.getBaseRefDefault as (
path: string,
options?: { wslDistro?: string }
) => Promise<string | null>
return {
...actual,
getDefaultBaseRef: vi.fn().mockReturnValue('origin/main'),
// Why: fabricated local test repos need a deterministic default, while
// WSL coverage must still exercise the real async Git-options path.
getBaseRefDefault: vi
.fn()
.mockImplementation((path: string, options?: { wslDistro?: string }) =>
options?.wslDistro ? actualGetBaseRefDefault(path, options) : Promise.resolve('origin/main')
),
getBranchConflictKind: vi.fn().mockResolvedValue(null)
}
})
@@ -2857,7 +2864,7 @@ describe('OrcaRuntimeService', () => {
suggestLocalBaseRefUpdate: true
}
)
expect(getDefaultBaseRef).toHaveBeenCalled()
expect(getBaseRefDefault).toHaveBeenCalled()
} finally {
getReposSpy.mockRestore()
gitSpy.mockRestore()
@@ -5363,7 +5370,7 @@ describe('OrcaRuntimeService', () => {
it('treats SSH worktree drift as unknown without local git probes', async () => {
vi.mocked(listWorktrees).mockClear()
vi.mocked(getDefaultBaseRef).mockClear()
vi.mocked(getBaseRefDefault).mockClear()
const remoteStore = {
...store,
getRepos: () => [
@@ -5399,7 +5406,7 @@ describe('OrcaRuntimeService', () => {
}
expect(gitProvider.listWorktrees).toHaveBeenCalledWith('/remote/repo')
expect(getDefaultBaseRef).not.toHaveBeenCalled()
expect(getBaseRefDefault).not.toHaveBeenCalled()
expect(listWorktrees).not.toHaveBeenCalled()
})
@@ -5465,7 +5472,7 @@ describe('OrcaRuntimeService', () => {
})
expect(asyncGitSpy).toHaveBeenCalledWith(
['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
wslGitOptions
{ ...wslGitOptions, timeout: 15_000 }
)
expect(asyncGitSpy).toHaveBeenCalledWith(['remote'], wslGitOptions)
expect(asyncGitSpy).toHaveBeenCalledWith(
@@ -26473,6 +26480,7 @@ describe('OrcaRuntimeService', () => {
})
expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
cwd: TEST_REPO_PATH,
timeout: 15_000,
wslDistro: 'Ubuntu'
})
expect(gitSpy).toHaveBeenCalledWith(
@@ -27140,6 +27148,7 @@ describe('OrcaRuntimeService', () => {
})
expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
cwd: TEST_REPO_PATH,
timeout: 15_000,
wslDistro: 'Ubuntu'
})
expect(getBranchConflictKind).toHaveBeenCalledWith(
+30 -7
View File
@@ -600,7 +600,6 @@ import type {
} from '../../shared/github-project-types'
import {
getBaseRefDefault,
getDefaultBaseRef,
getDefaultRemote,
getBranchConflictKind,
isGitRepo,
@@ -611,6 +610,7 @@ import {
parseAndFilterSearchRefDetails,
parseRemoteCount,
resolveDefaultBaseRefViaExec,
resolveDefaultBaseRefWithLocalGit,
buildSearchBaseRefsArgv,
isForEachRefExcludeUnsupportedError,
mergeBaseRefSearchResultGroups,
@@ -6497,6 +6497,31 @@ export class OrcaRuntimeService {
return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0
}
isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean {
// Why: a passive phone watching desktop-sized output must not race the
// desktop xterm. Mobile becomes reply authority only with the mobile floor.
if (this.getDriver(ptyId).kind !== 'mobile') {
return false
}
const subscribers = this.mobileSubscribers.get(ptyId)
if (!subscribers) {
return false
}
// Why: soft-leave resubscribe preserves the original subscription time but
// reinserts the record. Elect fitted responders from that stable age, not
// mutable Map order or passive desktop-mode watchers.
let earliest: { clientId: string; subscribedAt: number } | null = null
for (const subscriber of subscribers.values()) {
if (!subscriber.wasResizedToPhone) {
continue
}
if (earliest === null || subscriber.subscribedAt < earliest.subscribedAt) {
earliest = subscriber
}
}
return earliest?.clientId === clientId
}
subscribeToFitOverrideChanges(
ptyId: string,
listener: (event: {
@@ -14670,8 +14695,8 @@ export class OrcaRuntimeService {
repoWorktreeBaseRef: repo.worktreeBaseRef,
resolveDefaultBaseRef: () =>
hasLocalWorktreeGitOptions
? resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, localGitExecOptions))
: Promise.resolve(getDefaultBaseRef(repo.path)),
? resolveDefaultBaseRefWithLocalGit(localGitExecOptions)
: getBaseRefDefault(repo.path),
isBaseUsable: async (baseBranchCandidate) => {
const remoteTrackingBase = await this.resolveRemoteTrackingBase(
repo.path,
@@ -14702,10 +14727,8 @@ export class OrcaRuntimeService {
}
})
if (!baseBranch) {
// Why: getDefaultBaseRef returns null when no suitable ref exists.
// Don't fabricate 'origin/main' — passing it to addWorktree would
// produce an opaque git failure. Surface a clear error so the CLI
// caller can pick an explicit --base ref.
// Why: a null default means no suitable ref exists; fail clearly instead
// of handing Git a fabricated origin/main ref.
throw new Error(
'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.'
)
+155 -57
View File
@@ -26,6 +26,13 @@ import {
} from '../../../../shared/terminal-input'
import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply'
import {
EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE,
scanTerminalReplyQuerySequences,
type TerminalReplyQuerySequence,
type TerminalReplyQueryScanState
} from '../../../../shared/terminal-reply-query-scan'
import {
MOBILE_SNAPSHOT_BYTE_BUDGET,
MOBILE_SUBSCRIBE_SCROLLBACK_ROWS
@@ -42,6 +49,7 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024
// Why: pending output is held for later binary frames, so cap the encoded
// payload bytes rather than UTF-16 code units.
const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024
const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024
let nextTerminalStreamId = 1
type SnapshotFrameOptions = {
@@ -380,6 +388,29 @@ function getOutputAfterSnapshotSeq(
return chunk.data.slice(snapshotSeq - chunkStartSeq)
}
function stripSnapshotBoundaryQuerySuffixes(
data: string,
dataStartSeq: number,
snapshotSeq: number,
queries: TerminalReplyQuerySequence[]
): string {
let output = ''
let offset = 0
for (const query of queries) {
if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) {
continue
}
const removeStart = Math.max(0, query.startSeq - dataStartSeq)
const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq)
if (removeEnd <= offset || removeStart >= data.length) {
continue
}
output += data.slice(offset, removeStart)
offset = removeEnd
}
return output + data.slice(offset)
}
function appendAckPendingOutput(
stream: TerminalMultiplexStream,
chunk: TerminalOutputFrameChunk
@@ -731,6 +762,9 @@ const TerminalSend = TerminalHandle.extend({
enter: z.unknown().optional(),
interrupt: z.unknown().optional(),
requireAgentStatus: z.enum(['sendable']).optional(),
// Why: terminal-generated replies are valid input bytes but are not a user
// action that should transfer the shared terminal floor.
inputKind: z.enum(['query-reply']).optional(),
// Why: identifies the caller for the driver state machine. Optional for
// backward compatibility with older mobile clients (server falls back to
// the most recent mobile actor when absent). New mobile builds populate
@@ -1025,13 +1059,40 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.send',
params: TerminalSend,
handler: async (params, { runtime }) => {
handler: async (params, { runtime, clientId }) => {
await assertTerminalSendTextWithinLimit(params.text)
const queryReplyClientId = clientId ?? params.client?.id
if (
params.inputKind === 'query-reply' &&
(!params.text ||
!isTerminalQueryReply(params.text) ||
params.enter === true ||
params.interrupt === true ||
params.requireAgentStatus !== undefined ||
params.client?.type !== 'mobile' ||
!queryReplyClientId ||
(clientId !== undefined && params.client.id !== clientId))
) {
throw new InvalidArgumentError('Invalid terminal query reply')
}
// Why: guarded resolution — a stale handle must fail with
// terminal_handle_stale (clients recover by re-deriving the handle)
// instead of evaluating driver/lock state against the wrong PTY (#7718).
const leaf = runtime.resolveLiveLeafForHandle(params.terminal)
const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null
if (
params.inputKind === 'query-reply' &&
leaf?.ptyId &&
!runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!)
) {
return {
send: {
handle: params.terminal,
accepted: false,
bytesWritten: 0
}
}
}
if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) {
return {
send: {
@@ -1166,7 +1227,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// the most recent actor. Clientless sends are old mobile builds, so use
// the current mobile driver as their compatibility identity.
const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client)
if (leaf?.ptyId && mobileFloorClientId) {
if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) {
await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId)
}
return { send: result }
@@ -2426,6 +2487,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
let desktopClaimTail: Promise<boolean> = Promise.resolve(true)
let pendingOutputBytes = 0
let pendingOutputOverflowed = false
let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
const pendingQuerySequences: TerminalReplyQuerySequence[] = []
let pendingQueryChars = 0
let pendingQueryOverflowed = false
let unsubscribeData = (): void => {}
let unsubscribeResize = (): void => {}
let unsubscribeFit = (): void => {}
@@ -2600,6 +2665,57 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
.catch(() => false)
}
}) ?? (() => {})
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
if (closed) {
return
}
if (buffering) {
const rawLength = meta?.rawLength
if (
typeof meta?.seq === 'number' &&
typeof rawLength === 'number' &&
rawLength === data.length
) {
const scan = scanTerminalReplyQuerySequences(
data,
meta.seq - rawLength,
pendingQueryScanState
)
pendingQueryScanState = scan.state
for (const query of scan.queries) {
if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) {
pendingQueryOverflowed = true
break
}
pendingQuerySequences.push(query)
pendingQueryChars += query.data.length
}
} else {
pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
}
const remainingBudget = Math.max(
1,
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes
)
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
})
pendingOutput.push({ data, bytes: measurement.byteLength, meta })
pendingOutputBytes += measurement.byteLength
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
pendingOutputBytes = trimmed.bytes
pendingOutputOverflowed ||= trimmed.overflowed
return
}
outputBatcher?.push(data, meta)
})
// Why: live bytes must be captured before mobile fit awaits. Registering
// mobile presence first would suppress main while no view held the query.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
unsubscribeData = () => {
releaseViewSubscriber()
unsubscribeStreamData()
}
// Server-side auto-fit: resize PTY to phone dims before serializing scrollback
try {
if (isMobile && clientId) {
@@ -2614,59 +2730,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
return
}
const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => {
if (closed) {
return
}
if (buffering) {
const remainingBudget = Math.max(
1,
TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes
)
const measurement = measureTerminalStreamByteLength(data, {
stopAfterBytes: remainingBudget
})
pendingOutput.push({ data, bytes: measurement.byteLength, meta })
pendingOutputBytes += measurement.byteLength
const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes)
pendingOutputBytes = trimmed.bytes
pendingOutputOverflowed ||= trimmed.overflowed
return
}
outputBatcher?.push(data, meta)
})
// Why: binary subscribe streams feed remote xterm views (mobile and
// binary-capable desktop clients) that answer queries with view
// authority; the main model responder yields while attached.
const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId)
unsubscribeData = () => {
releaseViewSubscriber()
unsubscribeStreamData()
}
if (
clientId &&
params.client &&
registeredRemoteDesktopDriver &&
pendingRemoteDesktopViewport
) {
const viewport = pendingRemoteDesktopViewport
pendingRemoteDesktopViewport = null
await updateViewportForClient(
runtime,
ptyId,
remoteDesktopSubscriptionKey,
params.client,
viewport,
'desktop',
'register',
!supportsDesktopViewportClaims
)
}
if (closed) {
return
}
let read = await runtime.readTerminal(params.terminal)
let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile)
if (closed) {
@@ -2697,7 +2760,11 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
// See docs/mobile-terminal-layout-state-machine.md.
const layoutSeq = runtime.getLayout(ptyId)?.seq
const snapshotFrameSeq = serialized?.seq ?? layoutSeq
const snapshotOutputSeq = serialized?.seq
// Why: recovery snapshots advance output coverage past the initial
// snapshot seq; query replay and boundary trims must track the seq
// that actually covered the buffered chunks or a query absorbed by a
// recovery snapshot gets zero replies.
let snapshotOutputSeq = serialized?.seq
emit({
type: 'subscribed',
streamId,
@@ -2784,12 +2851,43 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq)
pendingOutput = trimmed.chunks
pendingOutputBytes = trimmed.bytes
snapshotOutputSeq = recovery.seq
}
buffering = false
const bufferedOutput = pendingOutput.splice(0)
const queryReplayData = pendingQueryOverflowed
? ''
: pendingQuerySequences
.filter(
(query) =>
initialOutputOverflowed ||
(typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq)
)
.map((query) => query.data)
.join('')
if (queryReplayData) {
// Why: serialized snapshots omit control queries, yet their output seq
// can trim the live chunk. Replay only the query after snapshot so the
// mobile xterm answers once while ordinary output stays deduplicated.
outputBatcher.push(queryReplayData)
}
if (!initialOutputOverflowed) {
for (const item of bufferedOutput) {
const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
let uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq)
if (
uncoveredData &&
uncoveredData !== item.data &&
typeof snapshotOutputSeq === 'number' &&
typeof item.meta?.seq === 'number' &&
typeof item.meta.rawLength === 'number'
) {
uncoveredData = stripSnapshotBoundaryQuerySuffixes(
uncoveredData,
snapshotOutputSeq,
snapshotOutputSeq,
pendingQuerySequences
)
}
if (uncoveredData) {
outputBatcher.push(uncoveredData, item.meta)
}
+151
View File
@@ -4,6 +4,10 @@ import type { RpcRequest } from './core'
import type { OrcaRuntimeService } from '../orca-runtime'
import { TERMINAL_METHODS } from './methods/terminal'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../shared/clipboard-text'
import {
RUNTIME_CAPABILITIES,
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
} from '../../../shared/protocol-version'
import {
TERMINAL_INPUT_MAX_BYTES,
TERMINAL_INPUT_TOO_LARGE_ERROR
@@ -209,6 +213,148 @@ describe('terminal send RPC', () => {
expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1')
})
it('writes a validated terminal query reply without taking the mobile floor', async () => {
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
sendTerminal: vi.fn().mockResolvedValue({
handle: 'terminal-1',
accepted: true,
bytesWritten: 6
}),
isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true),
mobileTookFloor: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text: '\x1b[3;4R',
enter: false,
inputKind: 'query-reply',
client: { id: 'mobile-1', type: 'mobile' }
})
)
expect(response.ok).toBe(true)
expect(runtime.sendTerminal).toHaveBeenCalledWith(
'terminal-1',
{ text: '\x1b[3;4R', enter: false, interrupt: false },
{ beforeWrite: undefined }
)
expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
})
it('accepts a query reply from only the elected mobile subscriber', async () => {
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }),
isMobileTerminalQueryReplyAuthority: vi.fn(
(_ptyId: string, clientId: string) => clientId === 'mobile-1'
),
sendTerminal: vi.fn().mockResolvedValue({
handle: 'terminal-1',
accepted: true,
bytesWritten: 6
}),
mobileTookFloor: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const makeReply = (clientId: string) =>
dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text: '\x1b[3;4R',
inputKind: 'query-reply',
client: { id: clientId, type: 'mobile' }
})
)
const [winner, peer] = await Promise.all([makeReply('mobile-1'), makeReply('mobile-2')])
expect(winner).toMatchObject({ ok: true, result: { send: { accepted: true } } })
expect(peer).toMatchObject({ ok: true, result: { send: { accepted: false } } })
expect(runtime.sendTerminal).toHaveBeenCalledTimes(1)
expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
})
it('rejects ordinary input that claims to be a terminal query reply', async () => {
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn(),
sendTerminal: vi.fn(),
mobileTookFloor: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text: 'a',
inputKind: 'query-reply',
client: { id: 'mobile-1', type: 'mobile' }
})
)
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
expect(runtime.sendTerminal).not.toHaveBeenCalled()
expect(runtime.mobileTookFloor).not.toHaveBeenCalled()
})
it.each([
['enter', { enter: true }],
['interrupt', { interrupt: true }],
['agent guard', { requireAgentStatus: 'sendable' }]
])('rejects a terminal query reply combined with %s semantics', async (_case, extra) => {
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn(),
sendTerminal: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = await dispatcher.dispatch(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text: '\x1b[3;4R',
inputKind: 'query-reply',
client: { id: 'mobile-1', type: 'mobile' },
...extra
})
)
expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } })
expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
expect(runtime.sendTerminal).not.toHaveBeenCalled()
})
it('rejects query replies that spoof a different authenticated mobile client', async () => {
const replies: string[] = []
const runtime = stubRuntime({
resolveLiveLeafForHandle: vi.fn(),
sendTerminal: vi.fn()
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
await dispatcher.dispatchStreaming(
makeRequest('terminal.send', {
terminal: 'terminal-1',
text: '\x1b[3;4R',
inputKind: 'query-reply',
client: { id: 'spoofed-mobile', type: 'mobile' }
}),
(reply) => replies.push(reply),
{ clientId: 'authenticated-mobile' }
)
expect(JSON.parse(replies[0]!)).toMatchObject({
ok: false,
error: { code: 'invalid_argument' }
})
expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled()
expect(runtime.sendTerminal).not.toHaveBeenCalled()
})
it('rejects oversized terminal send text before runtime dispatch', async () => {
const secret = 'terminal-send-secret'
const runtime = stubRuntime({
@@ -475,4 +621,9 @@ describe('terminal send RPC', () => {
expect(response.result).toEqual({ restored: true })
expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1')
})
// Why: mobile gates reply forwarding on this static capability; removing it
// would silently re-break mobile query answering against current hosts.
it('advertises query-reply input support to mobile clients', () => {
expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY)
})
})
@@ -79,6 +79,95 @@ describe('terminal subscribe buffering', () => {
}
})
it('captures live queries before awaiting mobile fit and delivers them after the snapshot', async () => {
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => void>()
let dataListener:
| ((data: string, meta?: { seq?: number; rawLength?: number }) => void)
| undefined
let resolveMobileSubscribe: () => void = () => {}
const registerRemoteTerminalViewSubscriber = vi.fn(() => vi.fn())
const runtime = stubRuntime({
resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
handleMobileSubscribe: vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveMobileSubscribe = () => resolve(true)
})
),
handleMobileUnsubscribe: vi.fn(),
subscribeToTerminalData: vi.fn((_ptyId, listener) => {
dataListener = listener
return vi.fn()
}),
registerRemoteTerminalViewSubscriber,
readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }),
serializeTerminalBuffer: vi
.fn()
.mockResolvedValue({ data: 'snapshot', cols: 80, rows: 24, seq: 4 }),
getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }),
getMobileDisplayMode: vi.fn().mockReturnValue('auto'),
getLayout: vi.fn().mockReturnValue({ seq: 1 }),
isTerminalAlternateScreen: vi.fn().mockReturnValue(false),
subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()),
subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()),
registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => {
cleanups.set(id, cleanup)
}),
cleanupSubscription: vi.fn((id: string) => {
cleanups.get(id)?.()
cleanups.delete(id)
}),
waitForTerminal: vi.fn(() => new Promise<RuntimeTerminalWait>(() => {}))
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const dispatchPromise = dispatcher.dispatchStreaming(
makeRequest('terminal.subscribe', {
terminal: 'terminal-1',
client: { id: 'phone-1', type: 'mobile' },
capabilities: { terminalBinaryStream: 1 }
}),
vi.fn(),
{
connectionId: 'conn-phone',
sendBinary: (bytes) => {
binaryFrames.push(bytes)
},
registerBinaryStreamHandler: vi.fn(() => vi.fn())
}
)
await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled())
expect(dataListener).toBeDefined()
expect(registerRemoteTerminalViewSubscriber).toHaveBeenCalledWith('pty-1')
dataListener?.('\x1b[6n', { seq: 4, rawLength: 4 })
resolveMobileSubscribe()
await vi.waitFor(() =>
expect(
binaryFrames.some((bytes) => {
const frame = decodeTerminalStreamFrame(bytes)
return (
frame?.opcode === TerminalStreamOpcode.Output &&
decodeTerminalStreamText(frame.payload) === '\x1b[6n'
)
})
).toBe(true)
)
const queryOutputFrames = binaryFrames.filter((bytes) => {
const frame = decodeTerminalStreamFrame(bytes)
return (
frame?.opcode === TerminalStreamOpcode.Output &&
decodeTerminalStreamText(frame.payload) === '\x1b[6n'
)
})
expect(queryOutputFrames).toHaveLength(1)
runtime.cleanupSubscription('terminal-1:phone-1')
await dispatchPromise
})
it('marks scrollback-only subscribed previews truncated when the uncursored read is limited', async () => {
const messages: string[] = []
const runtime = stubRuntime({
+157
View File
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock
}))
type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void
const originalUsername = process.env.USERNAME
beforeEach(() => {
vi.resetModules()
execFileMock.mockReset()
execFileSyncMock.mockReset()
})
afterEach(() => {
if (originalUsername === undefined) {
delete process.env.USERNAME
} else {
process.env.USERNAME = originalUsername
}
})
describe('grantDirAclAsync', () => {
it('keeps icacls off the synchronous main-process path', async () => {
process.env.USERNAME = 'alice'
let complete: ExecCallback | undefined
execFileMock.mockImplementation(
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
complete = callback
return {} as never
}
)
const { getIcaclsExePath, grantDirAclAsync } = await import('./win32-utils')
const pending = grantDirAclAsync('C:\\Users\\alice\\Orca')
await Promise.resolve()
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(execFileMock).toHaveBeenCalledWith(
getIcaclsExePath(),
['C:\\Users\\alice\\Orca', '/grant:r', 'alice:(OI)(CI)(F)'],
{ encoding: 'utf-8', windowsHide: true, timeout: 10_000 },
expect.any(Function)
)
complete?.(null, '', '')
await expect(pending).resolves.toBeUndefined()
})
it('resolves a missing username through asynchronous whoami before icacls', async () => {
delete process.env.USERNAME
execFileMock
.mockImplementationOnce(
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '')
return {} as never
}
)
.mockImplementationOnce(
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
callback(null, '', '')
return {} as never
}
)
const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils')
await grantDirAclAsync('C:\\Orca')
expect(execFileMock.mock.calls[0]?.slice(0, 3)).toEqual([
getWhoamiExePath(),
['/user', '/fo', 'csv', '/nh'],
{ encoding: 'utf-8', windowsHide: true, timeout: 5000 }
])
expect(execFileMock.mock.calls[1]?.[1]).toEqual([
'C:\\Orca',
'/grant:r',
'*S-1-5-21-123:(OI)(CI)(F)'
])
expect(execFileSyncMock).not.toHaveBeenCalled()
})
it('does not overwrite a concurrent cached identity with a late async result', async () => {
delete process.env.USERNAME
let completeWhoami: ExecCallback | undefined
execFileMock
.mockImplementationOnce(
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
completeWhoami = callback
return {} as never
}
)
.mockImplementationOnce(
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
callback(null, '', '')
return {} as never
}
)
execFileSyncMock.mockReturnValue('"DOMAIN\\sync","S-1-5-21-456"\r\n')
const { grantDirAclAsync, resolveCurrentWindowsIdentity } = await import('./win32-utils')
const pending = grantDirAclAsync('C:\\Orca')
await Promise.resolve()
expect(resolveCurrentWindowsIdentity()).toBe('*S-1-5-21-456')
completeWhoami?.(null, '"DOMAIN\\async","S-1-5-21-789"\r\n', '')
await pending
expect(execFileMock.mock.calls[1]?.[1]).toEqual([
'C:\\Orca',
'/grant:r',
'*S-1-5-21-456:(OI)(CI)(F)'
])
})
it('retries identity resolution after a transient whoami failure', async () => {
delete process.env.USERNAME
execFileMock
.mockImplementationOnce(
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
callback(new Error('whoami timed out'), '', '')
return {} as never
}
)
.mockImplementationOnce(
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
callback(null, '"DOMAIN\\alice","S-1-5-21-123"\r\n', '')
return {} as never
}
)
.mockImplementationOnce(
(_command: string, _args: string[], _options: unknown, callback: ExecCallback) => {
callback(null, '', '')
return {} as never
}
)
const { getWhoamiExePath, grantDirAclAsync } = await import('./win32-utils')
await grantDirAclAsync('C:\\Orca')
await grantDirAclAsync('C:\\Orca')
expect(
execFileMock.mock.calls.filter(([command]) => command === getWhoamiExePath())
).toHaveLength(2)
expect(execFileMock.mock.calls[2]?.[1]).toEqual([
'C:\\Orca',
'/grant:r',
'*S-1-5-21-123:(OI)(CI)(F)'
])
expect(execFileSyncMock).not.toHaveBeenCalled()
})
})
+105 -14
View File
@@ -1,7 +1,23 @@
import { execFileSync } from 'node:child_process'
import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from 'node:child_process'
import { delimiter, join } from 'node:path'
import { existsSync } from 'node:fs'
function execFileWithoutBlocking(
command: string,
args: string[],
options: ExecFileOptionsWithStringEncoding
): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, options, (error, stdout) => {
if (error) {
reject(error)
return
}
resolve(stdout)
})
})
}
/**
* Full path to icacls.exe. Electron's main process may have a stripped PATH
* that excludes System32, causing bare `icacls` to throw ENOENT.
@@ -10,6 +26,11 @@ export function getIcaclsExePath(): string {
return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\icacls.exe`
}
/** Absolute path because service-launched Electron can omit System32 from PATH. */
export function getWhoamiExePath(): string {
return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\whoami.exe`
}
/**
* Full path to cmd.exe, respecting the ComSpec convention used elsewhere in
* the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts).
@@ -78,13 +99,10 @@ export function isPermissionError(error: unknown): boolean {
// and hardened envs where USERNAME is unset. Fall back to the SID via
// `whoami /user` (same strategy as runtime-metadata.ts), which is authoritative
// and always available on Windows. Cached because it never changes in-process.
let cachedIdentity: string | null | undefined
let cachedIdentity: string | undefined
let pendingIdentityResolution: Promise<string | null> | null = null
export function resolveCurrentWindowsIdentity(): string | null {
return resolveCurrentIdentity()
}
function resolveCurrentIdentity(): string | null {
function cachedOrEnvironmentIdentity(): string | undefined {
if (cachedIdentity !== undefined) {
return cachedIdentity
}
@@ -92,20 +110,75 @@ function resolveCurrentIdentity(): string | null {
cachedIdentity = process.env.USERNAME
return cachedIdentity
}
return undefined
}
function identityFromWhoamiOutput(output: string): string | null {
// CSV columns: "DOMAIN\\user","S-1-5-21-..."
const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output.trim())
return sidMatch ? `*${sidMatch[1]}` : null
}
export function resolveCurrentWindowsIdentity(): string | null {
return resolveCurrentIdentity()
}
function resolveCurrentIdentity(): string | null {
const knownIdentity = cachedOrEnvironmentIdentity()
if (knownIdentity !== undefined) {
return knownIdentity
}
try {
const output = execFileSync('whoami', ['/user', '/fo', 'csv', '/nh'], {
const output = execFileSync(getWhoamiExePath(), ['/user', '/fo', 'csv', '/nh'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
timeout: 5000
}).trim()
// CSV columns: "DOMAIN\\user","S-1-5-21-..."
const sidMatch = /"(S-[\d-]+)"\s*$/.exec(output)
cachedIdentity = sidMatch ? `*${sidMatch[1]}` : null
})
const resolvedIdentity = identityFromWhoamiOutput(output)
if (resolvedIdentity) {
cachedIdentity = resolvedIdentity
}
return resolvedIdentity
} catch {
cachedIdentity = null
return null
}
return cachedIdentity
}
async function resolveCurrentIdentityAsync(): Promise<string | null> {
const knownIdentity = cachedOrEnvironmentIdentity()
if (knownIdentity !== undefined) {
return knownIdentity
}
if (!pendingIdentityResolution) {
pendingIdentityResolution = (async () => {
try {
const stdout = await execFileWithoutBlocking(
getWhoamiExePath(),
['/user', '/fo', 'csv', '/nh'],
{
encoding: 'utf-8',
windowsHide: true,
timeout: 5000
}
)
// Why: a synchronous caller may resolve identity while async whoami
// is in flight; its authoritative cached result must win the race.
const resolvedIdentity = identityFromWhoamiOutput(stdout)
if (cachedIdentity === undefined && resolvedIdentity) {
cachedIdentity = resolvedIdentity
}
return cachedIdentity ?? resolvedIdentity
} catch {
// Why: transient service/PATH failures must not permanently disable
// ACL repair for every later crash attempt in this process.
return cachedIdentity ?? null
}
})().finally(() => {
pendingIdentityResolution = null
})
}
return pendingIdentityResolution
}
/**
@@ -138,6 +211,24 @@ export function grantDirAcl(dirPath: string, options?: { recursive?: boolean }):
})
}
export async function grantDirAclAsync(dirPath: string): Promise<void> {
const identity = await resolveCurrentIdentityAsync()
if (!identity) {
return
}
// Why: crash recovery runs on Electron's main thread; an asynchronous
// icacls child keeps its worst-case timeout from freezing every window.
await execFileWithoutBlocking(
getIcaclsExePath(),
[dirPath, '/grant:r', `${identity}:(OI)(CI)(F)`],
{
encoding: 'utf-8',
windowsHide: true,
timeout: 10_000
}
)
}
/**
* Resolve spawn parameters for a command that may be a Windows batch script.
*
+8 -10
View File
@@ -2390,7 +2390,7 @@ describe('createMainWindow', () => {
expect(onRendererProcessGone).toHaveBeenCalledWith(details, 142)
})
it('passes the renderer webContents id through crash classification callbacks', () => {
it('passes the renderer webContents id through crash recording and recovery callbacks', () => {
vi.useFakeTimers()
const windowHandlers: Record<string, (...args: any[]) => void> = {}
@@ -2423,13 +2423,11 @@ describe('createMainWindow', () => {
return browserWindowInstance
})
const onRendererProcessGone = vi.fn()
const shouldRecordRendererCrash = vi.fn(() => true)
const shouldRecoverRenderer = vi.fn(() => true)
try {
createMainWindow(null, {
onRendererProcessGone,
shouldRecordRendererCrash,
shouldRecoverRenderer
})
@@ -2437,7 +2435,6 @@ describe('createMainWindow', () => {
windowHandlers['render-process-gone']?.({} as never, details)
vi.advanceTimersByTime(250)
expect(shouldRecordRendererCrash).toHaveBeenCalledWith(details, 424)
expect(onRendererProcessGone).toHaveBeenCalledWith(details, 424)
expect(shouldRecoverRenderer).toHaveBeenCalledWith(details, 424)
} finally {
@@ -2445,9 +2442,10 @@ describe('createMainWindow', () => {
}
})
it('does not notify the crash recorder for an expected renderer teardown', () => {
it('forwards expected renderer teardowns so the recorder can diagnose suppression', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
id: 142,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
@@ -2476,10 +2474,7 @@ describe('createMainWindow', () => {
})
const onRendererProcessGone = vi.fn()
createMainWindow(null, {
onRendererProcessGone,
shouldRecordRendererCrash: () => false
})
createMainWindow(null, { onRendererProcessGone })
windowHandlers['render-process-gone']?.(
{} as never,
@@ -2489,7 +2484,10 @@ describe('createMainWindow', () => {
} as Electron.RenderProcessGoneDetails
)
expect(onRendererProcessGone).not.toHaveBeenCalled()
expect(onRendererProcessGone).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'killed', exitCode: 15 }),
expect.any(Number)
)
consoleError.mockRestore()
})
+3 -11
View File
@@ -111,13 +111,6 @@ type CreateMainWindowOptions = {
details: Electron.RenderProcessGoneDetails,
webContentsId: number
) => void
/** Returns true when a renderer loss should be reported as a crash. Why:
* intentional reload/update/quit paths can emit crash-like `killed`
* renderer exits, but surfacing those as crash reports is noise. */
shouldRecordRendererCrash?: (
details: Electron.RenderProcessGoneDetails,
webContentsId: number
) => boolean
/** Returns true when Orca should reload after an unexpected renderer loss.
* Why: update relaunch and app quit intentionally tear down child
* processes; recovering those paths can fight Electron's shutdown. */
@@ -713,10 +706,9 @@ export function createMainWindow(
resetShortcutRecorderFocus()
// Why: macOS can report BrowserWindow teardown as renderer `killed`/SIGKILL
// after a confirmed close; that is window lifecycle noise, not a crash.
if (
!windowClosing &&
opts?.shouldRecordRendererCrash?.(details, rendererWebContentsId) !== false
) {
if (!windowClosing) {
// Why: the recorder owns crash classification and durable suppression
// diagnostics; filtering here made expected-teardown evidence unreachable.
opts?.onRendererProcessGone?.(details, rendererWebContentsId)
}
if (!windowClosing) {
+2 -2
View File
@@ -2,7 +2,7 @@ import { isFolderRepo } from '../shared/repo-kind'
import type { Repo } from '../shared/types'
import { hasLocalCommitObject, isFullGitObjectId } from './git/commit-object-ref'
import { hasWorktreeBaseCommitRef } from './git/worktree-base-ref-probe'
import { getDefaultBaseRef } from './git/repo'
import { getBaseRefDefault } from './git/repo'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { prefetchRemoteWorktreeCreateBase } from './ipc/worktree-remote'
import { resolveWorktreeCreateBase } from './worktree-create-base'
@@ -48,7 +48,7 @@ async function prefetchLocalWorktreeCreateBase(
const resolvedBaseBranch = await resolveWorktreeCreateBase({
requestedBaseBranch: baseBranch,
repoWorktreeBaseRef: repo.worktreeBaseRef,
resolveDefaultBaseRef: async () => getDefaultBaseRef(repo.path),
resolveDefaultBaseRef: () => getBaseRefDefault(repo.path),
isBaseUsable: async (baseBranchCandidate) => {
const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(
repo.path,
+4 -4
View File
@@ -316,6 +316,7 @@ import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
import type {
CrashReportBreadcrumbData,
CrashReportCopyDiagnosticsArgs,
CrashReportRecord,
CrashReportSubmitArgs,
CrashReportSubmitResult,
@@ -1398,10 +1399,9 @@ export type PreloadApi = {
) => Promise<ReactErrorBoundaryReportResult>
recordBreadcrumb: (args: { name: string; data?: CrashReportBreadcrumbData }) => void
submit: (args: CrashReportSubmitArgs) => Promise<CrashReportSubmitResult>
copyLatestDiagnostics: (args?: {
reportId?: string
notes?: string
}) => Promise<{ ok: true } | { ok: false; error: string }>
copyLatestDiagnostics: (
args?: CrashReportCopyDiagnosticsArgs
) => Promise<{ ok: true } | { ok: false; error: string }>
}
export: ExportApi
gh: {
+2 -1
View File
@@ -212,6 +212,7 @@ import type {
} from '../shared/localhost-worktree-labels'
import type {
CrashReportBreadcrumbData,
CrashReportCopyDiagnosticsArgs,
CrashReportSubmitArgs,
CrashReportSubmitResult,
ReactErrorBoundaryReportArgs,
@@ -1151,7 +1152,7 @@ const api = {
ipcRenderer.send('crashReports:recordBreadcrumb', args),
submit: (args: CrashReportSubmitArgs): Promise<CrashReportSubmitResult> =>
ipcRenderer.invoke('crashReports:submit', args),
copyLatestDiagnostics: (args?: { reportId?: string; notes?: string }) =>
copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) =>
ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args)
},
@@ -16,10 +16,18 @@ import { useMountedRef } from '@/hooks/useMountedRef'
import {
formatCrashReportText,
isReactErrorBoundaryReport,
type CrashReportDiagnosticBundle,
type CrashReportRecord
} from '../../../../shared/crash-reporting'
import type { GitHubViewer } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import {
CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID,
getCrashReportCopySubmissionFailure,
getCrashReportSubmitFailureNotice,
getCrashReportSubmitWarningNotice
} from './crash-report-submit-notice'
import { useCrashReportCopy } from './use-crash-report-copy'
function formatSummary(report: CrashReportRecord): string {
if (isReactErrorBoundaryReport(report)) {
@@ -88,6 +96,7 @@ export function CrashReportDialogSurface({
() => (report ? formatCrashReportText(report, deferredNotes) : ''),
[deferredNotes, report]
)
const copyCrashReportDetails = useCrashReportCopy(report, notes)
const clearViewer = useCallback((): void => {
viewerRequestIdRef.current += 1
@@ -121,17 +130,25 @@ export function CrashReportDialogSurface({
loadViewerForOpenDialog()
}, [clearViewer, loadViewerForOpenDialog, open])
const handleCopy = async (): Promise<void> => {
const result = await window.api.crashReports.copyLatestDiagnostics(
report ? { reportId: report.id, notes } : { notes }
)
if (!result.ok) {
toast.error(result.error)
return
}
toast.success(
translate('auto.components.crash.report.CrashReportDialog.8b8473c544', 'Crash report copied.')
)
const showSubmitFailure = (
error: unknown,
diagnosticBundle?: CrashReportDiagnosticBundle
): void => {
const failure = { error, ...(diagnosticBundle ? { diagnosticBundle } : {}) }
const notice = getCrashReportSubmitFailureNotice(failure, includeDiagnosticLogs)
const copyFailure = getCrashReportCopySubmissionFailure(failure)
toast.error(notice.title, {
id: CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID,
description: notice.description,
duration: Infinity,
dismissible: true,
action: {
label: notice.actionLabel,
onClick: () => {
void copyCrashReportDetails(copyFailure)
}
}
})
}
const dismissReportIfNeeded = async (): Promise<void> => {
@@ -164,22 +181,7 @@ export function CrashReportDialogSurface({
githubEmail: null
})
if (!result.ok) {
if (result.diagnosticBundle?.status === 'uploaded') {
toast.error(
translate(
'auto.components.crash.report.CrashReportDialog.b2e36f53a1',
'Failed to send crash report. Diagnostic ticket {{value0}} was uploaded but not linked.',
{ value0: result.diagnosticBundle.ticketId }
)
)
} else {
toast.error(
translate(
'auto.components.crash.report.CrashReportDialog.56a3dfa283',
'Failed to send crash report.'
)
)
}
showSubmitFailure(result.error, result.diagnosticBundle)
console.error('Failed to submit crash report:', result.error)
return
}
@@ -188,17 +190,21 @@ export function CrashReportDialogSurface({
}
onReportChange(result.report)
setNotes('')
toast.success(
translate('auto.components.crash.report.CrashReportDialog.8e24fe4f75', 'Crash report sent.')
)
toast.dismiss(CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID)
const warningNotice = getCrashReportSubmitWarningNotice(result, includeDiagnosticLogs)
if (warningNotice) {
toast.warning(warningNotice.title, { description: warningNotice.description })
} else {
toast.success(
translate(
'auto.components.crash.report.CrashReportDialog.8e24fe4f75',
'Crash report sent.'
)
)
}
onOpenChange(false)
} catch (error) {
toast.error(
translate(
'auto.components.crash.report.CrashReportDialog.56a3dfa283',
'Failed to send crash report.'
)
)
showSubmitFailure(error)
console.error('Failed to submit crash report:', error)
} finally {
if (mountedRef.current) {
@@ -304,7 +310,13 @@ export function CrashReportDialogSurface({
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleCopy} disabled={loading}>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void copyCrashReportDetails()}
disabled={loading}
>
<Clipboard className="size-3.5" />
{translate('auto.components.crash.report.CrashReportDialog.50b00dc327', 'Copy Details')}
</Button>
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import {
getCrashReportCopySubmissionFailure,
getCrashReportSubmitFailureNotice,
getCrashReportSubmitWarningNotice
} from './crash-report-submit-notice'
describe('crash report submit notices', () => {
it('builds an allow-listed copy context with the exact sanitized failure reasons', () => {
expect(
getCrashReportCopySubmissionFailure({
error: 'request failed at C:\\Users\\alice\\Orca',
diagnosticBundle: {
status: 'not_uploaded',
reason: 'attachment timeout token=super-secret-value'
}
})
).toEqual({
error: 'request failed at [redacted-path]',
diagnosticContext: {
status: 'not_uploaded',
reason: 'attachment timeout token=[redacted]'
}
})
})
it('keeps the returned error and points attached-log failures to the no-log retry', () => {
const notice = getCrashReportSubmitFailureNotice({ error: 'status 413' }, true)
expect(notice).toEqual({
title: "Crash report wasn't sent",
description:
'status 413. Uncheck "Attach recent diagnostic logs" and try again, or copy the details.',
actionLabel: 'Copy Details'
})
})
it('uses connection guidance when diagnostic logs were already excluded', () => {
const notice = getCrashReportSubmitFailureNotice({ error: 'network unreachable' }, false)
expect(notice.description).toBe(
'network unreachable. Check your connection and try again, or copy the details.'
)
expect(notice.description).not.toContain('Uncheck')
})
it('preserves an uploaded diagnostic ticket alongside the core failure', () => {
const notice = getCrashReportSubmitFailureNotice(
{
error: 'status 502',
diagnosticBundle: {
status: 'uploaded',
ticketId: 'ticket-123',
bundleSubmissionId: 'bundle-123',
bytes: 42,
spanCount: 2
}
},
true
)
expect(notice.description).toContain('status 502.')
expect(notice.description).toContain(
'Diagnostic ticket ticket-123 was uploaded but not linked.'
)
})
it('shows both failures when the attachment was already omitted', () => {
const notice = getCrashReportSubmitFailureNotice(
{
error: 'fallback host unreachable',
diagnosticBundle: {
status: 'not_uploaded',
reason: 'diagnostic log attachment failed: request timed out after 60 seconds'
}
},
true
)
expect(notice.description).toContain('fallback host unreachable.')
expect(notice.description).toContain(
'Diagnostic logs were not attached: diagnostic log attachment failed: request timed out after 60 seconds.'
)
expect(notice.description).toContain('Check your connection')
expect(notice.description).not.toContain('Uncheck')
})
it('safely surfaces Error messages and ignores unhelpful rejected values', () => {
expect(
getCrashReportSubmitFailureNotice({ error: new Error('IPC channel closed') }, false)
.description
).toContain('IPC channel closed.')
expect(
getCrashReportSubmitFailureNotice({ error: { unexpected: true } }, false).description
).toContain('The crash report request failed before it returned a reason.')
})
it('redacts paths and tokens before displaying transport errors', () => {
const token = `ghp_${'a'.repeat(30)}`
const notice = getCrashReportSubmitFailureNotice(
{
error: `request failed at C:\\Users\\alice\\Orca\\crash-reports.json token=${token}`
},
false
)
expect(notice.description).not.toContain('alice')
expect(notice.description).not.toContain(token)
expect(notice.description).toContain('[redacted')
})
it('warns only when an opted-in diagnostic bundle was not attached', () => {
const result = {
ok: true as const,
report: null,
diagnosticBundle: {
status: 'not_uploaded' as const,
reason: 'bundle collection failed'
}
}
expect(getCrashReportSubmitWarningNotice(result, true)).toEqual({
title: 'Crash report sent without diagnostic logs',
description: 'Diagnostic logs were not attached: bundle collection failed'
})
expect(getCrashReportSubmitWarningNotice(result, false)).toBeNull()
})
})
@@ -0,0 +1,134 @@
import { translate } from '@/i18n/i18n'
import {
sanitizeCrashReportString,
type CrashReportCopySubmissionFailure,
type CrashReportDiagnosticBundle,
type CrashReportSubmitResult
} from '../../../../shared/crash-reporting'
export const CRASH_REPORT_SUBMIT_FAILURE_TOAST_ID = 'crash-report-submit-failure'
export type CrashReportSubmitFailureNotice = {
title: string
description: string
actionLabel: string
}
export type CrashReportSubmitWarningNotice = {
title: string
description: string
}
type CrashReportSubmitFailureLike = {
error: unknown
diagnosticBundle?: CrashReportDiagnosticBundle
}
function normalizedFailureMessage(error: unknown): string {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
const sanitized = sanitizeCrashReportString(message).trim()
return (
sanitized ||
translate(
'auto.components.crash.report.submit.notice.unknownError',
'The crash report request failed before it returned a reason.'
)
)
}
function asSentence(message: string): string {
return /[.!?]$/.test(message) ? message : `${message}.`
}
export function getCrashReportCopySubmissionFailure(
failure: CrashReportSubmitFailureLike
): CrashReportCopySubmissionFailure {
const diagnosticContext =
failure.diagnosticBundle?.status === 'uploaded'
? {
status: 'uploaded' as const,
ticketId: sanitizeCrashReportString(failure.diagnosticBundle.ticketId)
}
: failure.diagnosticBundle?.status === 'not_uploaded'
? {
status: 'not_uploaded' as const,
reason: normalizedFailureMessage(failure.diagnosticBundle.reason)
}
: undefined
return {
error: normalizedFailureMessage(failure.error),
...(diagnosticContext ? { diagnosticContext } : {})
}
}
export function getCrashReportSubmitFailureNotice(
failure: CrashReportSubmitFailureLike,
includeDiagnosticLogs: boolean
): CrashReportSubmitFailureNotice {
const ticketDetail =
failure.diagnosticBundle?.status === 'uploaded'
? translate(
'auto.components.crash.report.submit.notice.ticketUploaded',
'Diagnostic ticket {{value0}} was uploaded but not linked.',
{ value0: sanitizeCrashReportString(failure.diagnosticBundle.ticketId) }
)
: null
const omittedDetail =
failure.diagnosticBundle?.status === 'not_uploaded'
? asSentence(
translate(
'auto.components.crash.report.submit.notice.diagnosticsReason',
'Diagnostic logs were not attached: {{value0}}',
{ value0: normalizedFailureMessage(failure.diagnosticBundle.reason) }
)
)
: null
const attachmentAlreadyOmitted = failure.diagnosticBundle?.status === 'not_uploaded'
const recovery =
includeDiagnosticLogs && !attachmentAlreadyOmitted
? translate(
'auto.components.crash.report.submit.notice.uncheckDiagnostics',
'Uncheck "Attach recent diagnostic logs" and try again, or copy the details.'
)
: translate(
'auto.components.crash.report.submit.notice.checkConnection',
'Check your connection and try again, or copy the details.'
)
return {
title: translate(
'auto.components.crash.report.submit.notice.notSent',
"Crash report wasn't sent"
),
description: [
asSentence(normalizedFailureMessage(failure.error)),
ticketDetail,
omittedDetail,
recovery
]
.filter((part): part is string => Boolean(part))
.join(' '),
actionLabel: translate('auto.components.crash.report.submit.notice.copyDetails', 'Copy Details')
}
}
export function getCrashReportSubmitWarningNotice(
result: Extract<CrashReportSubmitResult, { ok: true }>,
includeDiagnosticLogs: boolean
): CrashReportSubmitWarningNotice | null {
if (!includeDiagnosticLogs || result.diagnosticBundle?.status !== 'not_uploaded') {
return null
}
return {
title: translate(
'auto.components.crash.report.submit.notice.sentWithoutDiagnostics',
'Crash report sent without diagnostic logs'
),
description: translate(
'auto.components.crash.report.submit.notice.diagnosticsReason',
'Diagnostic logs were not attached: {{value0}}',
{ value0: normalizedFailureMessage(result.diagnosticBundle.reason) }
)
}
}
@@ -0,0 +1,133 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import type {
CrashReportCopySubmissionFailure,
CrashReportRecord
} from '../../../../shared/crash-reporting'
import { CRASH_REPORT_COPY_FAILURE_TOAST_ID, useCrashReportCopy } from './use-crash-report-copy'
const copyLatestDiagnostics = vi.fn()
vi.mock('sonner', () => ({
toast: {
dismiss: vi.fn(),
error: vi.fn(),
success: vi.fn()
}
}))
function report(id = 'crash-1'): CrashReportRecord {
return {
id,
createdAt: '2026-05-16T01:00:00.000Z',
status: 'pending',
source: 'renderer',
processType: 'renderer',
reason: 'crashed',
exitCode: 5,
appVersion: '1.0.0',
platform: 'win32',
osRelease: 'test',
arch: 'x64',
electronVersion: '41',
chromeVersion: '141',
details: {}
}
}
beforeEach(() => {
vi.clearAllMocks()
copyLatestDiagnostics.mockResolvedValue({ ok: true })
Object.defineProperty(window, 'api', {
configurable: true,
value: { crashReports: { copyLatestDiagnostics } }
})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('useCrashReportCopy', () => {
it('uses the latest edited notes from an older failure-toast action', async () => {
const failure: CrashReportCopySubmissionFailure = {
error: 'report request timed out',
diagnosticContext: { status: 'not_uploaded', reason: 'logs timed out' }
}
const { result, rerender } = renderHook(({ notes }) => useCrashReportCopy(report(), notes), {
initialProps: { notes: 'notes at submit' }
})
const failureToastCopyAction = result.current
rerender({ notes: 'notes edited while waiting' })
await act(async () => failureToastCopyAction(failure))
expect(copyLatestDiagnostics).toHaveBeenCalledWith({
reportId: 'crash-1',
notes: 'notes edited while waiting',
submissionFailure: failure
})
})
it("does not combine an older report action with a newer report's notes", async () => {
const { result, rerender } = renderHook(
({ currentReport, notes }) => useCrashReportCopy(currentReport, notes),
{
initialProps: {
currentReport: report('crash-a'),
notes: 'latest notes for A'
}
}
)
const reportACopyAction = result.current
rerender({ currentReport: report('crash-b'), notes: 'private notes for B' })
await act(async () => reportACopyAction())
expect(copyLatestDiagnostics).toHaveBeenCalledWith({
reportId: 'crash-a',
notes: 'latest notes for A'
})
expect(JSON.stringify(copyLatestDiagnostics.mock.calls)).not.toContain('private notes for B')
})
it('keeps a returned copy failure visible with its safe reason', async () => {
copyLatestDiagnostics.mockResolvedValueOnce({
ok: false,
error: 'Crash diagnostics are too large to copy safely.'
})
const { result } = renderHook(() => useCrashReportCopy(report(), 'current notes'))
await act(async () => result.current())
expect(toast.error).toHaveBeenCalledWith('Crash report details could not be copied.', {
id: CRASH_REPORT_COPY_FAILURE_TOAST_ID,
description: 'Crash diagnostics are too large to copy safely.',
duration: Infinity,
dismissible: true
})
expect(toast.success).not.toHaveBeenCalled()
})
it('shows a safe sticky error toast when copy IPC rejects', async () => {
copyLatestDiagnostics.mockRejectedValueOnce(
new Error('renderer destroyed with token=must-not-be-shown')
)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useCrashReportCopy(report(), 'current notes'))
await act(async () => result.current())
expect(toast.error).toHaveBeenCalledWith('Crash report details could not be copied.', {
id: CRASH_REPORT_COPY_FAILURE_TOAST_ID,
duration: Infinity,
dismissible: true
})
expect(JSON.stringify(vi.mocked(toast.error).mock.calls)).not.toContain('must-not-be-shown')
expect(toast.success).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,69 @@
import { useCallback, useRef } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type {
CrashReportCopySubmissionFailure,
CrashReportRecord
} from '../../../../shared/crash-reporting'
export const CRASH_REPORT_COPY_FAILURE_TOAST_ID = 'crash-report-copy-failure'
function showCopyFailure(description?: string): void {
toast.error(
translate(
'auto.components.crash.report.copy.copyFailed',
'Crash report details could not be copied.'
),
{
id: CRASH_REPORT_COPY_FAILURE_TOAST_ID,
...(description ? { description } : {}),
duration: Infinity,
dismissible: true
}
)
}
export function useCrashReportCopy(
report: CrashReportRecord | null,
notes: string
): (submissionFailure?: CrashReportCopySubmissionFailure) => Promise<void> {
const reportId = report?.id ?? null
const notesRef = useRef({ reportId, value: notes })
// Why: a submission toast can outlive the render that created it while the
// user edits or changes reports; keep live notes scoped to that report.
if (notesRef.current.reportId === reportId) {
notesRef.current.value = notes
} else {
notesRef.current = { reportId, value: notes }
}
const reportNotes = notesRef.current
return useCallback(
async (submissionFailure?: CrashReportCopySubmissionFailure): Promise<void> => {
try {
const result = await window.api.crashReports.copyLatestDiagnostics({
...(report ? { reportId: report.id } : {}),
notes: reportNotes.value,
...(submissionFailure ? { submissionFailure } : {})
})
if (!result.ok) {
showCopyFailure(result.error)
return
}
toast.dismiss(CRASH_REPORT_COPY_FAILURE_TOAST_ID)
toast.success(
translate(
'auto.components.crash.report.CrashReportDialog.8b8473c544',
'Crash report copied.'
)
)
} catch (error) {
console.error('Failed to copy crash report details:', error)
// Why: Sonner closes an action toast when clicked, so a sticky generic
// replacement keeps the failure actionable without exposing raw IPC detail.
showCopyFailure()
}
},
[report, reportNotes]
)
}
@@ -0,0 +1,176 @@
import { describe, expect, it, vi } from 'vitest'
import type { MarkdownDocument } from '../../../../shared/types'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
import {
getMarkdownDocumentListRequestKey,
requestSharedMarkdownDocumentList
} from './markdown-document-list-request'
function context(overrides: Partial<RuntimeFileOperationArgs> = {}): RuntimeFileOperationArgs {
return {
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'worktree-1',
worktreePath: '/repo',
...overrides
}
}
function deferred<T>(): {
promise: Promise<T>
reject: (error: Error) => void
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
describe('shared Markdown document list requests', () => {
it('collapses concurrent pane scans and releases the result after settlement', async () => {
const pending = deferred<MarkdownDocument[]>()
const load = vi.fn(() => pending.promise)
const requests = Array.from({ length: 200 }, () =>
requestSharedMarkdownDocumentList(context(), '/repo', {}, load)
)
expect(load).toHaveBeenCalledTimes(1)
expect(new Set(requests).size).toBe(1)
const documents = [{ filePath: '/repo/README.md', relativePath: 'README.md' }]
pending.resolve(documents as MarkdownDocument[])
await expect(Promise.all(requests)).resolves.toEqual(
Array.from({ length: 200 }, () => documents)
)
const nextDocuments = [{ filePath: '/repo/notes.md', relativePath: 'notes.md' }]
load.mockResolvedValueOnce(nextDocuments as MarkdownDocument[])
await expect(requestSharedMarkdownDocumentList(context(), '/repo', {}, load)).resolves.toBe(
nextDocuments
)
expect(load).toHaveBeenCalledTimes(2)
})
it('starts a fresh scan after a mutation instead of joining an older snapshot', async () => {
const stale = deferred<MarkdownDocument[]>()
const fresh = deferred<MarkdownDocument[]>()
const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise)
const initialRequest = requestSharedMarkdownDocumentList(context(), '/repo', {}, load)
const mutationRefresh = requestSharedMarkdownDocumentList(
context(),
'/repo',
{ requireFresh: true },
load
)
expect(load).toHaveBeenCalledTimes(2)
expect(mutationRefresh).not.toBe(initialRequest)
const freshDocuments = [{ filePath: '/repo/new.md', relativePath: 'new.md' }]
fresh.resolve(freshDocuments as MarkdownDocument[])
await expect(mutationRefresh).resolves.toBe(freshDocuments)
const staleDocuments = [{ filePath: '/repo/old.md', relativePath: 'old.md' }]
stale.resolve(staleDocuments as MarkdownDocument[])
await expect(initialRequest).resolves.toBe(staleDocuments)
})
it('allows an ordinary retry after an older scan stops settling', async () => {
const stale = deferred<MarkdownDocument[]>()
const retry = deferred<MarkdownDocument[]>()
const load = vi.fn().mockReturnValueOnce(stale.promise).mockReturnValueOnce(retry.promise)
const now = vi.spyOn(performance, 'now').mockReturnValue(1_000)
try {
const initialRequest = requestSharedMarkdownDocumentList(context(), '/stuck', {}, load)
now.mockReturnValue(31_001)
const retryRequest = requestSharedMarkdownDocumentList(context(), '/stuck', {}, load)
expect(load).toHaveBeenCalledTimes(2)
expect(retryRequest).not.toBe(initialRequest)
retry.resolve([])
stale.resolve([])
await expect(retryRequest).resolves.toEqual([])
await expect(initialRequest).resolves.toEqual([])
} finally {
now.mockRestore()
}
})
it('evicts an abandoned scan for a route that is never requested again', async () => {
const abandoned = deferred<MarkdownDocument[]>()
const load = vi.fn().mockReturnValueOnce(abandoned.promise).mockResolvedValueOnce([])
const now = vi.spyOn(performance, 'now').mockReturnValue(1_000)
try {
const abandonedRequest = requestSharedMarkdownDocumentList(
context(),
'/abandoned-only',
{},
load
)
now.mockReturnValue(31_001)
// A request for a different route sweeps the expired entry so the map
// does not pin the abandoned promise for the renderer's lifetime.
await expect(
requestSharedMarkdownDocumentList(context(), '/other-route', {}, load)
).resolves.toEqual([])
load.mockResolvedValueOnce([])
await expect(
requestSharedMarkdownDocumentList(context(), '/abandoned-only', {}, load)
).resolves.toEqual([])
expect(load).toHaveBeenCalledTimes(3)
abandoned.resolve([])
await expect(abandonedRequest).resolves.toEqual([])
} finally {
now.mockRestore()
}
})
it('keeps runtime routes isolated and encodes unusual paths losslessly', () => {
const local = context()
const ssh = context({ connectionId: 'ssh-1' })
const runtime = context({ settings: { activeRuntimeEnvironmentId: ' runtime-1 ' } })
const unusualRoot = '/repo\nwith-newline'
const windowsRoot = 'C:\\repo\\docs'
const uncRoot = '\\\\server\\share\\repo'
expect(getMarkdownDocumentListRequestKey(local, unusualRoot)).not.toBe(
getMarkdownDocumentListRequestKey(ssh, unusualRoot)
)
expect(getMarkdownDocumentListRequestKey(ssh, unusualRoot)).not.toBe(
getMarkdownDocumentListRequestKey(runtime, unusualRoot)
)
expect(JSON.parse(getMarkdownDocumentListRequestKey(local, unusualRoot)).at(-1)).toBe(
unusualRoot
)
expect(JSON.parse(getMarkdownDocumentListRequestKey(local, windowsRoot)).at(-1)).toBe(
windowsRoot
)
expect(JSON.parse(getMarkdownDocumentListRequestKey(local, uncRoot)).at(-1)).toBe(uncRoot)
})
it('clears rejected requests so a retry can run', async () => {
const failure = deferred<MarkdownDocument[]>()
const load = vi.fn(() => failure.promise)
const first = requestSharedMarkdownDocumentList(context(), '/retry', {}, load)
const second = requestSharedMarkdownDocumentList(context(), '/retry', {}, load)
failure.reject(new Error('offline'))
await expect(first).rejects.toThrow('offline')
await expect(second).rejects.toThrow('offline')
load.mockResolvedValueOnce([])
await expect(requestSharedMarkdownDocumentList(context(), '/retry', {}, load)).resolves.toEqual(
[]
)
expect(load).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,69 @@
import type { MarkdownDocument } from '../../../../shared/types'
import {
listRuntimeMarkdownDocuments,
type RuntimeFileOperationArgs
} from '@/runtime/runtime-file-client'
type MarkdownDocumentListLoader = (
context: RuntimeFileOperationArgs,
rootPath: string
) => Promise<MarkdownDocument[]>
type MarkdownDocumentListRequestOptions = {
requireFresh?: boolean
}
type InFlightMarkdownDocumentList = {
request: Promise<MarkdownDocument[]>
startedAt: number
}
const MARKDOWN_DOCUMENT_LIST_JOIN_WINDOW_MS = 30_000
const inFlightMarkdownDocumentLists = new Map<string, InFlightMarkdownDocumentList>()
export function getMarkdownDocumentListRequestKey(
context: RuntimeFileOperationArgs,
rootPath: string
): string {
return JSON.stringify([
context.settings?.activeRuntimeEnvironmentId?.trim() ?? '',
context.connectionId ?? '',
context.worktreeId ?? '',
context.worktreePath ?? '',
rootPath
])
}
export function requestSharedMarkdownDocumentList(
context: RuntimeFileOperationArgs,
rootPath: string,
options: MarkdownDocumentListRequestOptions = {},
load: MarkdownDocumentListLoader = listRuntimeMarkdownDocuments
): Promise<MarkdownDocument[]> {
const now = performance.now()
// Why: a never-settling scan for a route that is never requested again would
// otherwise pin its map entry (and captured context) for the renderer's life.
for (const [staleKey, entry] of inFlightMarkdownDocumentLists) {
if (now - entry.startedAt >= MARKDOWN_DOCUMENT_LIST_JOIN_WINDOW_MS) {
inFlightMarkdownDocumentLists.delete(staleKey)
}
}
const key = getMarkdownDocumentListRequestKey(context, rootPath)
const existing = inFlightMarkdownDocumentLists.get(key)
if (existing && !options.requireFresh) {
return existing.request
}
// Why: split Markdown panes mount together and otherwise launch identical
// whole-worktree local/SSH scans; mutation refreshes bypass older snapshots.
const request = load(context, rootPath).finally(() => {
if (inFlightMarkdownDocumentLists.get(key)?.request === request) {
inFlightMarkdownDocumentLists.delete(key)
}
})
// Why: local and UNC filesystem calls have no timeout, so an abandoned scan
// must not suppress every ordinary retry for the renderer's lifetime.
inFlightMarkdownDocumentLists.set(key, { request, startedAt: now })
return request
}
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { MarkdownDocument } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { getConnectionId } from '@/lib/connection-context'
import { listRuntimeMarkdownDocuments, statRuntimePath } from '@/runtime/runtime-file-client'
import { statRuntimePath } from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import {
@@ -11,6 +11,7 @@ import {
resolveMarkdownDocLink
} from './markdown-doc-links'
import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector'
import { requestSharedMarkdownDocumentList } from './markdown-document-list-request'
type OpenMarkdownDocumentOptions = {
anchor?: string | null
@@ -52,43 +53,47 @@ export function useMarkdownDocuments(
const connectionId = getConnectionId(worktreeId)
const refreshMarkdownDocuments = useCallback(async (): Promise<void> => {
if (!worktreeId || !worktreePath) {
return
}
const requestId = requestRef.current + 1
requestRef.current = requestId
try {
const documents = await listRuntimeMarkdownDocuments(
{
settings: settingsForRuntimeOwner(
useAppStore.getState().settings,
activeFile.runtimeEnvironmentId
),
worktreeId,
worktreePath,
connectionId: connectionId ?? undefined
},
worktreePath
)
if (requestRef.current !== requestId) {
const refreshMarkdownDocuments = useCallback(
async (requireFresh = false): Promise<void> => {
if (!worktreeId || !worktreePath) {
return
}
setMarkdownDocumentsByWorktree((prev) => ({
...prev,
[worktreeId]: documents
}))
} catch (err) {
console.error('Failed to list markdown documents:', err)
if (requestRef.current === requestId) {
const requestId = requestRef.current + 1
requestRef.current = requestId
try {
const documents = await requestSharedMarkdownDocumentList(
{
settings: settingsForRuntimeOwner(
useAppStore.getState().settings,
activeFile.runtimeEnvironmentId
),
worktreeId,
worktreePath,
connectionId: connectionId ?? undefined
},
worktreePath,
{ requireFresh }
)
if (requestRef.current !== requestId) {
return
}
setMarkdownDocumentsByWorktree((prev) => ({
...prev,
[worktreeId]: []
[worktreeId]: documents
}))
} catch (err) {
console.error('Failed to list markdown documents:', err)
if (requestRef.current === requestId) {
setMarkdownDocumentsByWorktree((prev) => ({
...prev,
[worktreeId]: []
}))
}
}
}
}, [activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath])
},
[activeFile.runtimeEnvironmentId, connectionId, worktreeId, worktreePath]
)
const openMarkdownDocument = useCallback(
async (
@@ -112,11 +117,11 @@ export function useMarkdownDocuments(
document.filePath
)
if (stats.isDirectory) {
await refreshMarkdownDocuments()
await refreshMarkdownDocuments(true)
return
}
} catch {
await refreshMarkdownDocuments()
await refreshMarkdownDocuments(true)
return
}
@@ -174,7 +179,7 @@ export function useMarkdownDocuments(
)
const mdSave = useCallback(
(content: string) => onSave(content).then(() => refreshMarkdownDocuments()),
(content: string) => onSave(content).then(() => refreshMarkdownDocuments(true)),
[onSave, refreshMarkdownDocuments]
)
@@ -20,7 +20,7 @@ const IOS_CHANNEL_COPY: Record<IosChannel, InstallCopy> = {
const ANDROID_COPY: InstallCopy = {
ctaLabel: 'Download APK',
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk'
url: 'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk'
}
export function getInstallCopy(platform: Platform, iosChannel: IosChannel): InstallCopy {
@@ -191,4 +191,35 @@ describe('useRuntimeFileListForWorktree', () => {
expect(cancelRuntimeFileListMock).toHaveBeenCalledWith(listContext, listRequest.requestToken)
})
it('does not restart the scan when unrelated ownership metadata changes', async () => {
const workspaceKey = folderWorkspaceKey('folder-workspace-1')
useAppStore.setState({
folderWorkspaces: [makeFolderWorkspace({ connectionId: 'ssh-1' })],
projectGroups: [makeProjectGroup({ connectionId: 'ssh-1' })],
repos: [],
worktreesByRepo: {}
} as Partial<AppState>)
await renderProbe({ enabled: true, onState: () => {}, worktreeId: workspaceKey })
await waitForListRuntimeFilesCall()
await act(async () => {
useAppStore.setState({
repos: [
{
id: 'unrelated-repo',
path: '/tmp/unrelated',
displayName: 'Unrelated',
badgeColor: '#000',
addedAt: 0
}
]
} as Partial<AppState>)
})
await flushEffects()
expect(listRuntimeFilesMock).toHaveBeenCalledTimes(1)
expect(cancelRuntimeFileListMock).not.toHaveBeenCalled()
})
})
@@ -1,18 +1,24 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: quick-open file lists are fetched over local or SSH runtime IPC, so loading/error/results track the request lifecycle. */
import { useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import type { Worktree } from '../../../shared/types'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
import { getConnectionIdFromState } from '@/lib/connection-context'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
import { cancelRuntimeFileList, listRuntimeFiles } from '@/runtime/runtime-file-client'
import { useAppStore } from '@/store'
import { useWorktreesForRepo } from '@/store/selectors'
import type { FileExplorerOperationOwner } from '@/components/right-sidebar/file-explorer-types'
import {
getFileExplorerOperationOwnerFromState,
getFileExplorerOwnerUnresolvedMessage,
getFileExplorerOperationRoute
} from '@/components/right-sidebar/file-explorer-operation-owner'
export type RuntimeFileListState = {
files: string[]
loading: boolean
loadError: string | null
operationOwner?: FileExplorerOperationOwner
}
export function cleanRuntimeFileListError(error: unknown): string {
@@ -106,6 +112,9 @@ export function useRuntimeFileListForWorktree({
const [files, setFiles] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
const [listedOperationOwner, setListedOperationOwner] = useState<FileExplorerOperationOwner>({
kind: 'unresolved'
})
const lastRequestKeyRef = useRef('')
const target = useMemo(
@@ -114,9 +123,28 @@ export function useRuntimeFileListForWorktree({
)
const { excludeRequest } = target
const connectionId = useAppStore(
(state) => getConnectionIdFromState(state, worktreeId) ?? undefined
const operationOwnerState = useAppStore(
useShallow((state) => ({
settings: state.settings,
repos: state.repos,
worktreesByRepo: state.worktreesByRepo,
detectedWorktreesByRepo: state.detectedWorktreesByRepo,
folderWorkspaces: state.folderWorkspaces,
projectGroups: state.projectGroups,
restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey
}))
)
const operationOwner = useMemo(
() => getFileExplorerOperationOwnerFromState(operationOwnerState, worktreeId),
[operationOwnerState, worktreeId]
)
const operationOwnerKey = JSON.stringify(operationOwner)
const operationOwnerRef = useRef(operationOwner)
operationOwnerRef.current = operationOwner
const operationRoute = getFileExplorerOperationRoute(operationOwner)
const operationRouteAvailable = operationRoute !== null
const connectionId = operationRoute?.connectionId
const runtimeEnvironmentId = operationRoute?.settings.activeRuntimeEnvironmentId ?? null
const activeTargetStatus = useAppStore((state) =>
connectionId ? state.sshConnectionStates.get(connectionId)?.status : undefined
)
@@ -126,19 +154,21 @@ export function useRuntimeFileListForWorktree({
activeTargetStatus === 'reconnecting'
const requestKey = useMemo(
() =>
`${worktreePath ?? ''}\n${connectionId ?? ''}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}`,
[activeTargetStatus, connectionId, excludeRequest.key, worktreePath]
`${worktreePath ?? ''}\n${operationOwnerKey}\n${excludeRequest.key}\n${activeTargetStatus ?? ''}`,
[activeTargetStatus, excludeRequest.key, operationOwnerKey, worktreePath]
)
useEffect(() => {
if (!enabled) {
setLoading(false)
setListedOperationOwner({ kind: 'unresolved' })
return
}
if (!target.canList || !worktreeId || !worktreePath) {
if (!target.canList || !worktreeId || !worktreePath || !operationRouteAvailable) {
setFiles([])
setLoadError(null)
setListedOperationOwner({ kind: 'unresolved' })
setLoadError(operationRouteAvailable ? null : getFileExplorerOwnerUnresolvedMessage())
setLoading(false)
return
}
@@ -154,10 +184,9 @@ export function useRuntimeFileListForWorktree({
const excludePaths = excludeRequest.paths.length > 0 ? excludeRequest.paths : undefined
const requestToken = createBrowserUuid()
const requestOperationOwner = operationOwnerRef.current
const requestContext = {
// Why: Quick Open lists files for the selected workspace. It must
// follow that workspace's owner host, not the globally focused host.
settings: getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId),
settings: { activeRuntimeEnvironmentId: runtimeEnvironmentId },
worktreeId,
worktreePath,
connectionId
@@ -171,6 +200,7 @@ export function useRuntimeFileListForWorktree({
.then((result) => {
if (!cancelled) {
setFiles(result)
setListedOperationOwner(requestOperationOwner)
}
})
.catch((error) => {
@@ -193,7 +223,23 @@ export function useRuntimeFileListForWorktree({
// 30s timeout ("Could not load files for this workspace").
cancelRuntimeFileList(requestContext, requestToken)
}
}, [connectionId, enabled, excludeRequest, requestKey, target.canList, worktreeId, worktreePath])
}, [
enabled,
excludeRequest,
connectionId,
operationOwnerKey,
operationRouteAvailable,
requestKey,
runtimeEnvironmentId,
target.canList,
worktreeId,
worktreePath
])
return { files, loading: loading || connectionPending, loadError }
return {
files,
loading: loading || connectionPending,
loadError,
operationOwner: listedOperationOwner
}
}
@@ -163,6 +163,7 @@ function FileExplorerFiles(): React.JSX.Element {
hasNameFilter
? {
query: nameFilterQuery,
operationOwner: nameFilterFiles.operationOwner,
relativePaths: nameFilterQueryTooLarge
? []
: nameFilterFiles.loading && nameFilterFiles.files.length === 0
@@ -174,6 +175,7 @@ function FileExplorerFiles(): React.JSX.Element {
hasNameFilter,
nameFilterFiles.files,
nameFilterFiles.loading,
nameFilterFiles.operationOwner,
nameFilterQuery,
nameFilterQueryTooLarge
]
@@ -38,6 +38,7 @@ export function buildSourceControlAgentDeliveryPlan({
detectedAgents,
disabledAgents: useAppStore.getState().settings?.disabledTuiAgents,
cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides,
terminalWindowsShell: useAppStore.getState().settings?.terminalWindowsShell,
platform: launchPlatform,
isRemote
})
@@ -0,0 +1,60 @@
import { joinPath, normalizeRelativePath } from '@/lib/path'
import type { DirEntry } from '../../../../shared/types'
import { readRuntimeDirectory } from '@/runtime/runtime-file-client'
import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types'
import { shouldIncludeFileExplorerEntry } from './file-explorer-entries'
import {
getFileExplorerOperationOwner,
getFileExplorerOwnerUnresolvedMessage,
getFileExplorerOperationRoute
} from './file-explorer-operation-owner'
export type FileExplorerDirectoryListing = {
entries: DirEntry[]
operationOwner: FileExplorerOperationOwner
}
export function fileExplorerEntriesToTreeNodes(
entries: DirEntry[],
dirPath: string,
depth: number,
worktreePath: string | null,
operationOwner: FileExplorerOperationOwner
): TreeNode[] {
return entries.filter(shouldIncludeFileExplorerEntry).map((entry) => {
const path = joinPath(dirPath, entry.name)
return {
name: entry.name,
path,
relativePath: worktreePath
? normalizeRelativePath(path.slice(worktreePath.length + 1))
: entry.name,
isDirectory: entry.isDirectory,
isSymlink: entry.isSymlink,
depth: depth + 1,
operationOwner
}
})
}
export async function readFileExplorerDirectory(
activeWorktreeId: string | null | undefined,
worktreePath: string | null,
dirPath: string
): Promise<FileExplorerDirectoryListing> {
const operationOwner = getFileExplorerOperationOwner(activeWorktreeId)
const route = getFileExplorerOperationRoute(operationOwner)
if (!route) {
throw new Error(getFileExplorerOwnerUnresolvedMessage())
}
const entries = await readRuntimeDirectory(
{
settings: route.settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId: route.connectionId
},
dirPath
)
return { entries, operationOwner }
}
@@ -1,6 +1,6 @@
import { joinPath, normalizeRelativePath } from '@/lib/path'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import type { TreeNode } from './file-explorer-types'
import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types'
import {
createFileExplorerRowProjectionFromParts,
type FileExplorerRowProjection
@@ -12,6 +12,7 @@ import { isPathIgnored } from './status-display'
export type FileExplorerNameFilterProjectionSource = {
query: string
relativePaths: readonly string[] | null
operationOwner?: FileExplorerOperationOwner
}
export const FILE_EXPLORER_NAME_FILTER_QUERY_MAX_BYTES = 2 * 1024
@@ -133,14 +134,16 @@ function createSyntheticNode(
relativePath: string,
name: string,
depth: number,
isDirectory: boolean
isDirectory: boolean,
operationOwner: FileExplorerOperationOwner | undefined
): TreeNode {
return {
name,
path: joinPath(worktreePath, relativePath),
relativePath,
isDirectory,
depth
depth,
operationOwner
}
}
@@ -197,7 +200,14 @@ export function createNameFilteredFileExplorerProjection({
let entry = currentChildren.get(name)
if (!entry) {
entry = {
node: createSyntheticNode(worktreePath, currentRelativePath, name, index, isDirectory),
node: createSyntheticNode(
worktreePath,
currentRelativePath,
name,
index,
isDirectory,
nameFilter.operationOwner
),
children: new Map()
}
currentChildren.set(name, entry)
@@ -0,0 +1,146 @@
import { getConnectionIdFromState } from '@/lib/connection-context'
import { useAppStore } from '@/store'
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
import type { AppState } from '@/store/types'
import {
getRepoExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { translate } from '@/i18n/i18n'
import {
getExplicitRuntimeEnvironmentIdForWorktree,
getSettingsForWorktreeRuntimeOwner
} from '@/lib/worktree-runtime-owner'
import type { FileExplorerOperationOwner } from './file-explorer-types'
export type FileExplorerOperationRoute = {
settings: { activeRuntimeEnvironmentId: string | null }
connectionId?: string
}
type FileExplorerOwnerState = Pick<
AppState,
| 'settings'
| 'repos'
| 'worktreesByRepo'
| 'detectedWorktreesByRepo'
| 'folderWorkspaces'
| 'projectGroups'
| 'restoredRuntimeHostIdByWorkspaceSessionKey'
>
export function getFileExplorerOperationOwnerFromState(
state: FileExplorerOwnerState,
worktreeId: string | null | undefined
): FileExplorerOperationOwner {
const parsedWorkspace = worktreeId ? parseWorkspaceKey(worktreeId) : null
if (worktreeId && parsedWorkspace?.type !== 'folder') {
const exactHostIds = getExactWorktreeHostIds(state, worktreeId)
if (exactHostIds.size > 1) {
return { kind: 'unresolved' }
}
const exactHostId = exactHostIds.values().next().value
if (exactHostId) {
return operationOwnerFromHostId(exactHostId)
}
const repoId = getRepoIdFromWorktreeId(worktreeId)
const repoHostIds = new Set(
state.repos.filter((repo) => repo.id === repoId).map(getRepoExecutionHostId)
)
if (repoHostIds.size > 1) {
return { kind: 'unresolved' }
}
}
const connectionId = getConnectionIdFromState(state, worktreeId ?? null)
const explicitRuntimeEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId)
// Why: global runtime focus is not ownership evidence while SSH/local
// metadata is unresolved; destructive actions must wait for explicit provenance.
if (connectionId === undefined && explicitRuntimeEnvironmentId === null) {
return { kind: 'unresolved' }
}
const settings = getSettingsForWorktreeRuntimeOwner(state, worktreeId)
// Why: inferred SSH ownership outranks global runtime focus, but an explicit
// workspace runtime still owns its files.
const runtimeEnvironmentId =
connectionId && explicitRuntimeEnvironmentId === null
? null
: settings.activeRuntimeEnvironmentId?.trim()
if (runtimeEnvironmentId) {
return { kind: 'runtime', environmentId: runtimeEnvironmentId }
}
if (connectionId === undefined) {
return { kind: 'unresolved' }
}
return connectionId ? { kind: 'ssh', connectionId } : { kind: 'local' }
}
export function getFileExplorerOperationOwner(
worktreeId: string | null | undefined
): FileExplorerOperationOwner {
return getFileExplorerOperationOwnerFromState(useAppStore.getState(), worktreeId)
}
export function getFileExplorerOperationRoute(
owner: FileExplorerOperationOwner
): FileExplorerOperationRoute | null {
switch (owner.kind) {
case 'local':
return { settings: { activeRuntimeEnvironmentId: null } }
case 'ssh':
return {
settings: { activeRuntimeEnvironmentId: null },
connectionId: owner.connectionId
}
case 'runtime':
return { settings: { activeRuntimeEnvironmentId: owner.environmentId } }
case 'unresolved':
return null
}
}
export function getFileExplorerOwnerUnresolvedMessage(): string {
return translate(
'auto.components.right.sidebar.fileExplorerOperationOwner.unresolved',
"Couldn't determine which host owns this workspace. Check the connection and try again."
)
}
function getExactWorktreeHostIds(
state: Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo'>,
worktreeId: string
): Set<ExecutionHostId> {
const hostIds = new Set<ExecutionHostId>()
for (const worktrees of Object.values(state.worktreesByRepo)) {
for (const worktree of worktrees) {
if (worktree.id === worktreeId && worktree.hostId) {
hostIds.add(worktree.hostId)
}
}
}
for (const result of Object.values(state.detectedWorktreesByRepo)) {
for (const worktree of result.worktrees) {
if (worktree.id === worktreeId && worktree.hostId) {
hostIds.add(worktree.hostId)
}
}
}
return hostIds
}
function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperationOwner {
const parsed = parseExecutionHostId(hostId)
switch (parsed?.kind) {
case 'local':
return { kind: 'local' }
case 'ssh':
return { kind: 'ssh', connectionId: parsed.targetId }
case 'runtime':
return { kind: 'runtime', environmentId: parsed.environmentId }
case undefined:
return { kind: 'unresolved' }
}
}
@@ -15,6 +15,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => {
'src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts',
'src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts',
'src/renderer/src/components/right-sidebar/useFileDuplicate.ts',
'src/renderer/src/components/right-sidebar/useFileDeletion.ts',
'src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts',
'src/renderer/src/components/right-sidebar/useGitStatusPolling.ts',
'src/renderer/src/components/right-sidebar/useFileSearchRunner.ts',
@@ -23,7 +24,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => {
const text = source(path)
expect(text).toMatch(
/getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner/
/getRightSidebarWorktreeRuntimeSettings|getSettingsForWorktreeRuntimeOwner|getFileExplorerOperationOwner|getFileExplorerOperationRoute/
)
expect(text).not.toContain('settings: useAppStore.getState().settings')
expect(text).not.toContain('const settings = useAppStore.getState().settings')
@@ -1,3 +1,9 @@
export type FileExplorerOperationOwner =
| { kind: 'local' }
| { kind: 'ssh'; connectionId: string }
| { kind: 'runtime'; environmentId: string }
| { kind: 'unresolved' }
export type TreeNode = {
name: string
path: string
@@ -5,6 +11,8 @@ export type TreeNode = {
isDirectory: boolean
isSymlink?: boolean
depth: number
/** Host snapshot that produced this node. Destructive actions fail closed without it. */
operationOwner?: FileExplorerOperationOwner
}
export type DirCache = {
@@ -3,11 +3,10 @@ import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { useConfirmationDialog } from '@/components/confirmation-dialog'
import { dirname } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { isPathEqualOrDescendant } from './file-explorer-paths'
import type { TreeNode } from './file-explorer-types'
import { getFileExplorerOperationRoute } from './file-explorer-operation-owner'
import {
requestEditorFileSave,
requestEditorSaveQuiesce
@@ -15,7 +14,6 @@ import {
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
import {
deleteRuntimePath,
isRemoteRuntimeFileOperation,
readRuntimeFileContent,
writeRuntimeFile
} from '@/runtime/runtime-file-client'
@@ -50,6 +48,10 @@ export function useFileDeletion({
}: UseFileDeletionParams): UseFileDeletionResult {
const confirm = useConfirmationDialog()
const deleteShortcutLabel = useShortcutLabel('fileExplorer.delete')
const unresolvedDeleteOwnerError = translate(
'auto.components.right.sidebar.useFileDeletion.8b8ee9d22f',
"Couldn't determine which host owns this file. Check the workspace connection and try again."
)
// Why: track in-flight deletes per-path so repeated Del presses on the same
// node don't issue duplicate IPC calls; the map is a ref to avoid re-renders.
const inFlightRef = useRef<Set<string>>(new Set())
@@ -61,21 +63,28 @@ export function useFileDeletion({
}
inFlightRef.current.add(node.path)
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
const state = useAppStore.getState()
const worktree = activeWorktreeId
? findWorktreeById(state.worktreesByRepo, activeWorktreeId)
: null
const fileContext = {
settings: state.settings,
worktreeId: activeWorktreeId,
worktreePath: worktree?.path ?? null,
connectionId
}
const isRemote =
connectionId !== undefined || isRemoteRuntimeFileOperation(fileContext, node.path)
const operationOwner = node.operationOwner ?? { kind: 'unresolved' as const }
const operationRoute = getFileExplorerOperationRoute(operationOwner)
// Why: treat every non-local owner (ssh, runtime, unresolved) as remote
// for confirm/error copy, then fail closed below when the route is null
// so an unresolved owner never reaches local filesystem authorization.
const isRemote = operationOwner.kind !== 'local'
try {
if (!operationRoute) {
throw new Error(unresolvedDeleteOwnerError)
}
// Why: cached nodes can outlive host hydration changes; preserve the
// listing-time owner so deletion cannot jump to a same-path file elsewhere.
const state = useAppStore.getState()
const worktree = activeWorktreeId ? state.getKnownWorktreeById(activeWorktreeId) : null
const connectionId = operationRoute.connectionId
const fileContext = {
settings: operationRoute.settings,
worktreeId: activeWorktreeId,
worktreePath: worktree?.path ?? null,
connectionId
}
// Why: remote deletes bypass OS Trash, and undo cannot recover
// directories or unreadable files.
if (isRemote) {
@@ -206,7 +215,15 @@ export function useFileDeletion({
inFlightRef.current.delete(node.path)
}
},
[activeWorktreeId, closeFile, confirm, isWindows, openFiles, refreshDir]
[
activeWorktreeId,
closeFile,
confirm,
isWindows,
openFiles,
refreshDir,
unresolvedDeleteOwnerError
]
)
const requestDelete = useCallback(
@@ -33,7 +33,7 @@ describe('refreshFileExplorerExpandedDirs', () => {
'/repo/src': [entry('index.ts')],
'/repo/docs': [entry('guide.md')]
}
return entriesByPath[dirPath] ?? []
return { entries: entriesByPath[dirPath] ?? [], operationOwner: { kind: 'local' as const } }
})
const refreshed = await refreshFileExplorerExpandedDirs({
@@ -115,9 +115,9 @@ describe('refreshFileExplorerExpandedDirs', () => {
...prev,
'/repo/src': newerSrcCache
}))
return [entry('stale.ts')]
return { entries: [entry('stale.ts')], operationOwner: { kind: 'local' as const } }
}
return [entry('guide.md')]
return { entries: [entry('guide.md')], operationOwner: { kind: 'local' as const } }
})
const refreshed = await refreshFileExplorerExpandedDirs({
@@ -158,10 +158,10 @@ describe('refreshFileExplorerExpandedDirs', () => {
})
const readDirectory = vi.fn(async (dirPath: string) => {
if (dirPath === '/repo/src') {
return [entry('stale.ts')]
return { entries: [entry('stale.ts')], operationOwner: { kind: 'local' as const } }
}
await docsGate
return [entry('guide.md')]
return { entries: [entry('guide.md')], operationOwner: { kind: 'local' as const } }
})
const refreshPromise = refreshFileExplorerExpandedDirs({
@@ -1,17 +1,22 @@
import type { Dispatch, SetStateAction } from 'react'
import { useCallback, useRef, useState } from 'react'
import { joinPath, normalizeRelativePath } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
import type { DirEntry } from '../../../../shared/types'
import type { DirCache, TreeNode } from './file-explorer-types'
import type { DirCache } from './file-explorer-types'
import { splitPathSegments } from './path-tree'
import { shouldIncludeFileExplorerEntry } from './file-explorer-entries'
import { readRuntimeDirectory, statRuntimePath } from '@/runtime/runtime-file-client'
import { statRuntimePath } from '@/runtime/runtime-file-client'
import {
createFileExplorerDirLoadTracker,
type FileExplorerDirLoadTracker
} from './file-explorer-dir-load-tracker'
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
import {
getFileExplorerOperationOwner,
getFileExplorerOwnerUnresolvedMessage,
getFileExplorerOperationRoute
} from './file-explorer-operation-owner'
import {
fileExplorerEntriesToTreeNodes,
readFileExplorerDirectory,
type FileExplorerDirectoryListing
} from './file-explorer-directory-listing'
type UseFileExplorerTreeResult = {
dirCache: Record<string, DirCache>
@@ -40,45 +45,7 @@ type RefreshFileExplorerExpandedDirsParams = {
worktreePath: string
dirLoadTracker: FileExplorerDirLoadTracker
setDirCache: Dispatch<SetStateAction<Record<string, DirCache>>>
readDirectory: (dirPath: string) => Promise<DirEntry[]>
}
function entriesToTreeNodes(
entries: DirEntry[],
dirPath: string,
depth: number,
worktreePath: string | null
): TreeNode[] {
return entries.filter(shouldIncludeFileExplorerEntry).map((entry) => {
const path = joinPath(dirPath, entry.name)
return {
name: entry.name,
path,
relativePath: worktreePath
? normalizeRelativePath(path.slice(worktreePath.length + 1))
: entry.name,
isDirectory: entry.isDirectory,
isSymlink: entry.isSymlink,
depth: depth + 1
}
})
}
async function readWorktreeDirectory(
activeWorktreeId: string | null | undefined,
worktreePath: string | null,
dirPath: string
): Promise<DirEntry[]> {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
return readRuntimeDirectory(
{
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
dirPath
)
readDirectory: (dirPath: string) => Promise<FileExplorerDirectoryListing>
}
export async function refreshFileExplorerExpandedDirs({
@@ -115,7 +82,7 @@ export async function refreshFileExplorerExpandedDirs({
uniqueDirs.map(async ({ dirPath, depth }) => {
const loadToken = loadTokens.get(dirPath)!
try {
const entries = await readDirectory(dirPath)
const listing = await readDirectory(dirPath)
if (!dirLoadTracker.isCurrent(loadToken)) {
return { current: false as const }
}
@@ -123,7 +90,13 @@ export async function refreshFileExplorerExpandedDirs({
current: true as const,
dirPath,
cache: {
children: entriesToTreeNodes(entries, dirPath, depth, worktreePath),
children: fileExplorerEntriesToTreeNodes(
listing.entries,
dirPath,
depth,
worktreePath,
listing.operationOwner
),
loading: false
}
}
@@ -197,14 +170,20 @@ export function useFileExplorerTree(
}
}))
try {
const entries = await readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath)
const listing = await readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath)
if (!dirLoadTrackerRef.current.isCurrent(loadToken)) {
return false
}
if (depth === -1) {
setRootError(null)
}
const children = entriesToTreeNodes(entries, dirPath, depth, worktreePath)
const children = fileExplorerEntriesToTreeNodes(
listing.entries,
dirPath,
depth,
worktreePath,
listing.operationOwner
)
setDirCache((prev) => ({ ...prev, [dirPath]: { children, loading: false } }))
return true
} catch (error) {
@@ -247,13 +226,17 @@ export function useFileExplorerTree(
const statPath = useCallback(
async (path: string) => {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
const operationOwner = getFileExplorerOperationOwner(activeWorktreeId)
const route = getFileExplorerOperationRoute(operationOwner)
if (!route) {
throw new Error(getFileExplorerOwnerUnresolvedMessage())
}
return statRuntimePath(
{
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
settings: route.settings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
connectionId: route.connectionId
},
path
)
@@ -286,7 +269,7 @@ export function useFileExplorerTree(
worktreePath,
dirLoadTracker: dirLoadTrackerRef.current,
setDirCache,
readDirectory: (dirPath) => readWorktreeDirectory(activeWorktreeId, worktreePath, dirPath)
readDirectory: (dirPath) => readFileExplorerDirectory(activeWorktreeId, worktreePath, dirPath)
})
}, [activeWorktreeId, expanded, loadDir, worktreePath])
@@ -12,7 +12,7 @@ export { getMobileSettingsPaneSearchEntries }
const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217'
const ORCA_ANDROID_APK_URL =
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.26/app-release.apk'
'https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk'
export function MobileSettingsPane(): React.JSX.Element {
const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false)
@@ -23,6 +23,7 @@ vi.mock('@/lib/new-workspace', async (importOriginal) => {
})
import {
buildFolderWorkspaceLinkedStartupPlan,
getFolderWorkspaceAgentLaunchPlatform,
submitFolderWorkspaceCreate
} from './folder-workspace-composer-submit'
@@ -633,3 +634,27 @@ describe('submitFolderWorkspaceCreate', () => {
expect(mocks.activateAndRevealFolderWorkspace).not.toHaveBeenCalled()
})
})
describe('buildFolderWorkspaceLinkedStartupPlan', () => {
it('uses cmd quoting for configured arguments on local Windows', () => {
const plan = buildFolderWorkspaceLinkedStartupPlan({
agent: 'hermes',
linkedWorkItem: {
provider: 'github',
type: 'issue',
number: 42,
title: 'Restore linked quick-create',
url: 'https://github.com/stablyai/orca/issues/42',
repoId: 'repo-1'
},
note: '',
agentCmdOverrides: {},
agentArgs: '--provider "value with space"',
platform: 'win32',
shell: 'cmd',
isRemote: false
})
expect(plan?.launchCommand).toBe('hermes --tui "--provider" "value with space"')
})
})
@@ -17,6 +17,8 @@ import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config'
import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path'
import type { FolderWorkspace, ProjectGroup, TuiAgent } from '../../../../shared/types'
import { isWslUncPath } from '../../../../shared/wsl-paths'
import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell'
import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell'
import type { LaunchSource } from '../../../../shared/telemetry-events'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import {
@@ -44,6 +46,7 @@ type SubmitFolderWorkspaceCreateParams = {
agentCmdOverrides: Record<string, string> | undefined
agentArgs?: string | null
agentEnv?: Record<string, string>
terminalWindowsShell?: string | null
isRemote?: boolean
launchSource?: LaunchSource
runtimeEnvironmentId?: string | null
@@ -61,7 +64,7 @@ export function getFolderWorkspaceAgentLaunchPlatform(
return parentPath && isWslUncPath(parentPath) ? 'linux' : CLIENT_PLATFORM
}
function buildFolderWorkspaceLinkedStartupPlan(args: {
export function buildFolderWorkspaceLinkedStartupPlan(args: {
agent: TuiAgent
linkedWorkItem: LinkedWorkItemSummary
note: string
@@ -69,6 +72,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: {
agentArgs?: string | null
agentEnv?: Record<string, string>
platform: NodeJS.Platform
shell?: AgentStartupShell
isRemote: boolean
}): AgentStartupPlan | null {
const { prompt, draftPrompt } = resolveQuickCreateLinkedWorkItemPrompt(
@@ -84,6 +88,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: {
agentArgs: args.agentArgs,
agentEnv: args.agentEnv,
platform: args.platform,
shell: args.shell,
isRemote: args.isRemote
})
: null
@@ -110,6 +115,7 @@ function buildFolderWorkspaceLinkedStartupPlan(args: {
agentArgs: args.agentArgs,
agentEnv: args.agentEnv,
platform: args.platform,
shell: args.shell,
isRemote: args.isRemote,
allowEmptyPromptLaunch: true
})
@@ -153,6 +159,7 @@ export async function submitFolderWorkspaceCreate({
agentCmdOverrides,
agentArgs,
agentEnv,
terminalWindowsShell,
launchSource = 'sidebar',
runtimeEnvironmentId = null,
createFolderWorkspace,
@@ -168,6 +175,11 @@ export async function submitFolderWorkspaceCreate({
// Why: an SSH folder group runs the plain `orca` relay shim, so the Linux-only
// `orca-ide` rename must not be applied for remote launches.
const launchIsRemote = Boolean(projectGroup.connectionId)
const launchShell = resolveLocalWindowsAgentStartupShell({
platform: launchPlatform,
isRemote: launchIsRemote,
terminalWindowsShell
})
const startupPlan =
quickAgent && linkedWorkItem
? buildFolderWorkspaceLinkedStartupPlan({
@@ -178,6 +190,7 @@ export async function submitFolderWorkspaceCreate({
agentArgs,
agentEnv,
platform: launchPlatform,
shell: launchShell,
isRemote: launchIsRemote
})
: quickAgent
@@ -188,6 +201,7 @@ export async function submitFolderWorkspaceCreate({
agentArgs,
agentEnv,
platform: launchPlatform,
shell: launchShell,
isRemote: launchIsRemote,
allowEmptyPromptLaunch: true
})
@@ -4297,10 +4297,21 @@ describe('connectPanePty', () => {
}
// Simulate xterm answering a TUI's DA1 query while the phone owns the PTY.
;(onDataHandler as unknown as (data: string) => void)('\x1b[?1;2c')
// Capability handlers are registered outside onData and must honor the
// same mobile query-authority lock.
const csiCalls = (
pane.terminal.parser.registerCsiHandler as unknown as {
mock: { calls: [{ final: string }, (params: (number | number[])[]) => boolean][] }
}
).mock.calls
const da1Handler = csiCalls.find(([id]) => id.final === 'c')?.[1]
expect(da1Handler).toBeTypeOf('function')
da1Handler?.([])
await flushAsyncTicks()
expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled()
expect(transport.sendInput).not.toHaveBeenCalled()
expect(transport.sendInputImmediate).not.toHaveBeenCalled()
} finally {
setDriverForPty(ptyId, { kind: 'idle' })
}
@@ -17714,7 +17725,12 @@ describe('connectPanePty', () => {
)
await flushAsyncTicks()
expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledTimes(3)
// The bounded ladder runs at least its initial read plus two retries; an
// incidental droid reconfirm can add one more, so assert the floor, not an
// exact count (matches the warm-reattach-to-shell sibling test above).
expect(
vi.mocked(window.api.pty.confirmForegroundProcess).mock.calls.length
).toBeGreaterThanOrEqual(3)
expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({
agent: null,
shellForeground: true
@@ -3256,6 +3256,14 @@ export function connectPanePty(
const transport = runtimeEnvironmentId
? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions)
: createIpcPtyTransport(transportOptions)
const canSendDesktopQueryReply = (): boolean => {
const ptyId = transport.getPtyId()
return !ptyId || !isPtyLocked(ptyId)
}
// Why: parser/capability handlers bypass the ordinary onData guard. Keep
// desktop silent while the elected mobile xterm owns query replies.
const sendDesktopQueryReplyImmediate = (data: string): boolean =>
canSendDesktopQueryReply() && transport.sendInputImmediate(data)
// Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031
// responder — visible, hidden, marked or not. Conditioning the reply on the
// hidden mark double-fired (mark set + bytes delivered live via interest →
@@ -3273,7 +3281,7 @@ export function connectPanePty(
)
// Why immediate: a mode-2031 query reply must beat the remote input debounce
// or it can miss the querying program's read window (#7329).
transport.sendInputImmediate(mode2031SequenceFor(mode))
sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode))
// Why: register the subscription exactly like the xterm CSI handler
// would — without the registry entry, later theme flips never push the
// CSI 997 update and the TUI keeps a stale theme after reveal.
@@ -3287,13 +3295,13 @@ export function connectPanePty(
// Why: OSC 10/11 + DA1 replies must beat the querying program's raw-mode
// read window; the remote transport's input debounce would corrupt them
// (#7329), so send immediately.
sendInput: (data) => transport.sendInputImmediate(data),
sendInput: sendDesktopQueryReplyImmediate,
isReplaying: () => isPaneReplaying(deps.replayingPanesRef, pane.id),
...(isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {})
})
const respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder(
pane.terminal,
(data) => transport.sendInputImmediate(data)
sendDesktopQueryReplyImmediate
)
const claimViewportForUserActivity = (): void => {
@@ -3361,7 +3369,7 @@ export function connectPanePty(
// isTerminalQueryReply (it requires length >= 3 and a full reply grammar),
// so a real keystroke never reaches this branch.
if (isTerminalQueryReply(data)) {
transport.sendInputImmediate(data)
sendDesktopQueryReplyImmediate(data)
return
}
const intent = pendingTerminalInputIntent
@@ -5120,7 +5128,7 @@ export function connectPanePty(
// Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer
// immediately so the reply cannot outlive the program's read window.
deps.paneMode2031Ref.current.set(pane.id, true)
transport.sendInputImmediate(mode2031SequenceFor(mode))
sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode))
deps.paneLastThemeModeRef.current.set(pane.id, mode)
recordHiddenMode2031Reply()
}
@@ -5291,8 +5299,10 @@ export function connectPanePty(
// Why: Codex's startup palette probe has a 100 ms budget. Answer
// hidden color queries directly and immediately so neither renderer
// scheduling nor the remote input debounce (#7329) can miss it.
sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) =>
transport.sendInputImmediate(reply)
sendTerminalOscColorQueryReplies(
extracted.oscColorQueryData,
pane.terminal,
sendDesktopQueryReplyImmediate
)
}
if (extracted.statelessQueryData) {
@@ -5411,8 +5421,10 @@ export function connectPanePty(
}
const extracted = extractHiddenStartupRendererQueryData(data, '')
if (extracted.oscColorQueryData) {
sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) =>
transport.sendInputImmediate(reply)
sendTerminalOscColorQueryReplies(
extracted.oscColorQueryData,
pane.terminal,
sendDesktopQueryReplyImmediate
)
}
let unansweredQueryData = ''
@@ -5426,9 +5438,9 @@ export function connectPanePty(
const buffer = pane.terminal.buffer.active
const row = Math.min(buffer.cursorY + 1, pane.terminal.rows)
const col = Math.min(buffer.cursorX + 1, pane.terminal.cols)
transport.sendInputImmediate(`\x1b[${row};${col}R`)
sendDesktopQueryReplyImmediate(`\x1b[${row};${col}R`)
} else if (sequence === '\x1b[c' || sequence === '\x1b[0c') {
transport.sendInputImmediate(DEFAULT_DA1_RESPONSE)
sendDesktopQueryReplyImmediate(DEFAULT_DA1_RESPONSE)
} else {
unansweredQueryData += sequence
}
@@ -6382,7 +6394,7 @@ export function connectPanePty(
pane.terminal,
// Why: OSC color reply — immediate so the remote debounce cannot delay
// it past the querying program's read window (#7329).
(reply) => transport.sendInputImmediate(reply)
sendDesktopQueryReplyImmediate
)
}
const restoreAppliesToCurrentPty =
@@ -22,6 +22,7 @@ import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup'
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../shared/tui-agent-selection'
import { repoIsRemote } from '../../../shared/agent-launch-remote'
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
@@ -780,6 +781,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
// `orca-ide` rename must not be applied to remote launch commands.
const selectedRepoIsRemote = selectedRepo ? repoIsRemote(selectedRepo) : false
const selectedRepoStartupShell = resolveLocalWindowsAgentStartupShell({
platform: selectedRepoAgentLaunchPlatform,
isRemote: selectedRepoIsRemote,
terminalWindowsShell: settings?.terminalWindowsShell
})
const selectedRepoProjectId =
selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.projectId : null
const selectedProjectId = selectedProjectGroup
@@ -3344,6 +3350,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
? resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs)
: undefined,
agentEnv: agent ? resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv) : undefined,
terminalWindowsShell: settings?.terminalWindowsShell,
isRemote: folderTargetIsRemote,
launchSource: telemetrySource === 'onboarding' ? 'onboarding' : 'new_workspace_composer',
runtimeEnvironmentId: folderTargetRuntimeEnvironmentId,
@@ -3399,6 +3406,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
settings?.agentDefaultArgs,
settings?.agentDefaultEnv,
settings?.autoRenameBranchFromWork,
settings?.terminalWindowsShell,
telemetrySource
]
)
@@ -3598,6 +3606,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
agentArgs: resolveTuiAgentLaunchArgs(tuiAgent, settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(tuiAgent, settings?.agentDefaultEnv),
platform: selectedRepoAgentLaunchPlatform,
shell: selectedRepoStartupShell,
isRemote: selectedRepoIsRemote
})
const shouldSeedInitialAgentStatus =
@@ -3769,6 +3778,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedRepo,
selectedRepoAgentLaunchPlatform,
selectedRepoIsRemote,
selectedRepoStartupShell,
selectedRepoIsGit,
selectedRepoRequiresConnection,
showProjectRequiredError,
@@ -4015,6 +4025,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
platform: selectedRepoAgentLaunchPlatform,
shell: selectedRepoStartupShell,
isRemote: selectedRepoIsRemote
})
@@ -4039,6 +4050,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
agentArgs: resolveTuiAgentLaunchArgs(agent, settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, settings?.agentDefaultEnv),
platform: selectedRepoAgentLaunchPlatform,
shell: selectedRepoStartupShell,
isRemote: selectedRepoIsRemote,
allowEmptyPromptLaunch: true
})
@@ -4214,6 +4226,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
selectedRepo,
selectedRepoAgentLaunchPlatform,
selectedRepoIsRemote,
selectedRepoStartupShell,
selectedRepoIsGit,
selectedRepoSettings,
selectedRepoRequiresConnection,
+20 -1
View File
@@ -9849,6 +9849,9 @@
}
}
},
"fileExplorerOperationOwner": {
"unresolved": "Couldn't determine which host owns this workspace. Check the connection and try again."
},
"useFileDeletion": {
"72691dfebc": "Failed to {{value0}} '{{value1}}'.",
"96affe1302": "'{{value0}}' moved to {{value1}}",
@@ -9857,7 +9860,8 @@
"a76c74f105": "destructive",
"92276aceb7": "Delete",
"7fb9435c86": "This permanently deletes the directory and its contents on the remote host. This cannot be undone.",
"23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone."
"23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.",
"8b8ee9d22f": "Couldn't determine which host owns this file. Check the workspace connection and try again."
},
"useFileExplorerHandlers": {
"32cd9fd991": "Cannot open symlink target"
@@ -11905,6 +11909,21 @@
"ead6fc0510": "No automatic crash report was captured. You can still send details and include recent diagnostic logs when available.",
"b082f27490": "Attach recent diagnostic logs",
"e59f0b9427": "Sends a capped redacted log bundle with the report."
},
"submit": {
"notice": {
"unknownError": "The crash report request failed before it returned a reason.",
"ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.",
"uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.",
"checkConnection": "Check your connection and try again, or copy the details.",
"notSent": "Crash report wasn't sent",
"copyDetails": "Copy Details",
"sentWithoutDiagnostics": "Crash report sent without diagnostic logs",
"diagnosticsReason": "Diagnostic logs were not attached: {{value0}}"
}
},
"copy": {
"copyFailed": "Crash report details could not be copied."
}
}
},
+20 -1
View File
@@ -9849,6 +9849,9 @@
}
}
},
"fileExplorerOperationOwner": {
"unresolved": "No se pudo determinar qué host posee este espacio de trabajo. Comprueba la conexión e inténtalo de nuevo."
},
"useFileDeletion": {
"72691dfebc": "No se pudo {{value0}} '{{value1}}'.",
"96affe1302": "'{{value0}}' se movió a {{value1}}",
@@ -9857,7 +9860,8 @@
"a76c74f105": "destructivo",
"92276aceb7": "Eliminar",
"7fb9435c86": "Esto elimina permanentemente el directorio y su contenido en el host remoto. Esto no se puede deshacer.",
"23e98f192f": "Esto elimina permanentemente el archivo en el host remoto. Esto no se puede deshacer."
"23e98f192f": "Esto elimina permanentemente el archivo en el host remoto. Esto no se puede deshacer.",
"8b8ee9d22f": "No se pudo determinar qué host posee este archivo. Comprueba la conexión del espacio de trabajo e inténtalo de nuevo."
},
"useFileExplorerHandlers": {
"32cd9fd991": "No se puede abrir el destino del enlace simbólico"
@@ -11905,6 +11909,21 @@
"ead6fc0510": "No se capturó ningún informe de fallo automático. Aun así puedes enviar detalles e incluir logs de diagnóstico recientes cuando estén disponibles.",
"b082f27490": "Adjuntar logs de diagnóstico recientes",
"e59f0b9427": "Envía un paquete limitado de logs redactados junto con el informe."
},
"submit": {
"notice": {
"unknownError": "The crash report request failed before it returned a reason.",
"ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.",
"uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.",
"checkConnection": "Check your connection and try again, or copy the details.",
"notSent": "Crash report wasn't sent",
"copyDetails": "Copy Details",
"sentWithoutDiagnostics": "Crash report sent without diagnostic logs",
"diagnosticsReason": "Diagnostic logs were not attached: {{value0}}"
}
},
"copy": {
"copyFailed": "No se pudieron copiar los detalles del informe de fallo."
}
}
},
+20 -1
View File
@@ -9849,6 +9849,9 @@
}
}
},
"fileExplorerOperationOwner": {
"unresolved": "このワークスペースを所有しているホストを特定できませんでした。接続を確認して、もう一度お試しください。"
},
"useFileDeletion": {
"72691dfebc": "{{value0}} '{{value1}}' に失敗しました。",
"96affe1302": "「{{value0}}」は {{value1}} に移動されました",
@@ -9857,7 +9860,8 @@
"a76c74f105": "破壊的な",
"92276aceb7": "削除",
"7fb9435c86": "This permanently deletes the directory and its contents on the remote host. This cannot be undone.",
"23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone."
"23e98f192f": "This permanently deletes the file on the remote host. This cannot be undone.",
"8b8ee9d22f": "このファイルを所有しているホストを特定できませんでした。ワークスペースの接続を確認して、もう一度お試しください。"
},
"useFileExplorerHandlers": {
"32cd9fd991": "シンボリックリンクターゲットを開けません"
@@ -11905,6 +11909,21 @@
"ead6fc0510": "No automatic crash report was captured. You can still send details and include recent diagnostic logs when available.",
"b082f27490": "Attach recent diagnostic logs",
"e59f0b9427": "Sends a capped redacted log bundle with the report."
},
"submit": {
"notice": {
"unknownError": "The crash report request failed before it returned a reason.",
"ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.",
"uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.",
"checkConnection": "Check your connection and try again, or copy the details.",
"notSent": "Crash report wasn't sent",
"copyDetails": "Copy Details",
"sentWithoutDiagnostics": "Crash report sent without diagnostic logs",
"diagnosticsReason": "Diagnostic logs were not attached: {{value0}}"
}
},
"copy": {
"copyFailed": "クラッシュレポートの詳細をコピーできませんでした。"
}
}
},
+21 -2
View File
@@ -9849,6 +9849,9 @@
}
}
},
"fileExplorerOperationOwner": {
"unresolved": "이 워크스페이스를 소유한 호스트를 확인할 수 없습니다. 연결을 확인한 후 다시 시도하세요."
},
"useFileDeletion": {
"72691dfebc": "{{value0}} '{{value1}}'에 실패했습니다.",
"96affe1302": "'{{value0}}'이(가) {{value1}}(으)로 이동되었습니다.",
@@ -9857,7 +9860,8 @@
"a76c74f105": "파괴적",
"92276aceb7": "삭제",
"7fb9435c86": "원격 호스트의 디렉터리와 그 안의 내용을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.",
"23e98f192f": "원격 호스트의 파일을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다."
"23e98f192f": "원격 호스트의 파일을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.",
"8b8ee9d22f": "이 파일을 소유한 호스트를 확인할 수 없습니다. 워크스페이스 연결을 확인한 후 다시 시도하세요."
},
"useFileExplorerHandlers": {
"32cd9fd991": "심볼릭 링크 대상을 열 수 없습니다"
@@ -11905,6 +11909,21 @@
"ead6fc0510": "자동 크래시 보고서가 캡처되지 않았습니다. 그래도 세부 정보를 보내고, 가능한 경우 최근 진단 로그를 포함할 수 있습니다.",
"b082f27490": "최근 진단 로그 첨부",
"e59f0b9427": "보고서와 함께 크기가 제한되고 민감 정보가 제거된 로그 묶음을 보냅니다."
},
"submit": {
"notice": {
"unknownError": "The crash report request failed before it returned a reason.",
"ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.",
"uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.",
"checkConnection": "Check your connection and try again, or copy the details.",
"notSent": "Crash report wasn't sent",
"copyDetails": "Copy Details",
"sentWithoutDiagnostics": "Crash report sent without diagnostic logs",
"diagnosticsReason": "Diagnostic logs were not attached: {{value0}}"
}
},
"copy": {
"copyFailed": "충돌 보고서 세부 정보를 복사하지 못했습니다."
}
}
},
@@ -12734,7 +12753,7 @@
"confirm": {
"move": {
"title": "프로젝트를 이동하시겠습니까?",
"description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 {{repoName}}을(를) {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.",
"description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 해당 프로젝트를 {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.",
"action": "프로젝트 이동"
},
"copy": {
+20 -1
View File
@@ -9849,6 +9849,9 @@
}
}
},
"fileExplorerOperationOwner": {
"unresolved": "无法确定哪个主机拥有此工作区。请检查连接后重试。"
},
"useFileDeletion": {
"72691dfebc": "无法 {{value0}}“{{value1}}”。",
"96affe1302": "“{{value0}}”已移至 {{value1}}",
@@ -9857,7 +9860,8 @@
"a76c74f105": "destructive",
"92276aceb7": "删除",
"7fb9435c86": "这会永久删除远程主机上的目录及其内容。此操作无法撤销。",
"23e98f192f": "这会永久删除远程主机上的文件。此操作无法撤销。"
"23e98f192f": "这会永久删除远程主机上的文件。此操作无法撤销。",
"8b8ee9d22f": "无法确定哪个主机拥有此文件。请检查工作区连接后重试。"
},
"useFileExplorerHandlers": {
"32cd9fd991": "无法打开符号链接目标"
@@ -11905,6 +11909,21 @@
"ead6fc0510": "没有捕获到自动崩溃报告。你仍然可以发送详情,并在可用时包含最近的诊断日志。",
"b082f27490": "附加最近的诊断日志",
"e59f0b9427": "随报告发送经过删减且有大小限制的日志包。"
},
"submit": {
"notice": {
"unknownError": "The crash report request failed before it returned a reason.",
"ticketUploaded": "Diagnostic ticket {{value0}} was uploaded but not linked.",
"uncheckDiagnostics": "Uncheck \"Attach recent diagnostic logs\" and try again, or copy the details.",
"checkConnection": "Check your connection and try again, or copy the details.",
"notSent": "Crash report wasn't sent",
"copyDetails": "Copy Details",
"sentWithoutDiagnostics": "Crash report sent without diagnostic logs",
"diagnosticsReason": "Diagnostic logs were not attached: {{value0}}"
}
},
"copy": {
"copyFailed": "无法复制崩溃报告详细信息。"
}
}
},
@@ -0,0 +1,9 @@
export function runBestEffortAgentBackgroundCleanups(...actions: (() => void)[]): void {
for (const action of actions) {
try {
action()
} catch {
// Preserve the launch/setup error that triggered cleanup.
}
}
}
@@ -25,6 +25,7 @@ import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/tas
import type { AgentStartedTelemetry } from '@/lib/worktree-activation'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection'
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
export type GitHubWorkItemBackgroundStoreSnapshot = {
repos: readonly Repo[]
@@ -43,6 +44,7 @@ export type GitHubWorkItemBackgroundStoreSnapshot = {
| 'agentCmdOverrides'
| 'agentDefaultArgs'
| 'agentDefaultEnv'
| 'terminalWindowsShell'
>
>
| null
@@ -185,6 +187,11 @@ export function buildGitHubWorkItemStartupPlan(args: {
// Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
// `orca-ide` rename must not be applied for remote launches.
const isRemote = repoIsRemote(repo)
const shell = resolveLocalWindowsAgentStartupShell({
platform,
isRemote,
terminalWindowsShell: store.settings?.terminalWindowsShell
})
const draftLaunchPlan = draftPrompt
? buildAgentDraftLaunchPlan({
agent,
@@ -193,6 +200,7 @@ export function buildGitHubWorkItemStartupPlan(args: {
agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv),
platform,
shell,
isRemote
})
: null
@@ -215,6 +223,7 @@ export function buildGitHubWorkItemStartupPlan(args: {
agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs),
agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv),
platform,
shell,
isRemote,
allowEmptyPromptLaunch: true
})
@@ -22,6 +22,7 @@ const mockSubscribeToPtyExit = vi.fn()
const mockPasteDraftWhenAgentReady = vi.fn()
const mockMarkTrusted = vi.fn()
const mockDispatchEvent = vi.fn()
const mockGetAgentLaunchPlatformForRepo = vi.fn<() => NodeJS.Platform>()
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
function expectStablePaneSpawn(): string {
@@ -94,6 +95,10 @@ vi.mock('@/lib/agent-paste-draft', () => ({
pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady
}))
vi.mock('@/lib/agent-launch-platform', () => ({
getAgentLaunchPlatformForRepo: mockGetAgentLaunchPlatformForRepo
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
registerEagerPtyBuffer: mockRegisterEagerPtyBuffer,
subscribeToPtyExit: mockSubscribeToPtyExit
@@ -108,6 +113,7 @@ describe('launchAgentBackgroundSession', () => {
resetRemoteRuntimeTerminalMultiplexersForTests()
clearRuntimeCompatibilityCacheForTests()
vi.clearAllMocks()
mockGetAgentLaunchPlatformForRepo.mockReturnValue('linux')
mockRuntimeEnvironmentTransportCall.mockImplementation(
(args) =>
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args)
@@ -492,6 +498,62 @@ describe('launchAgentBackgroundSession', () => {
)
})
it('passes Hermes automation prompts through the native startup query', async () => {
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
await launchAgentBackgroundSession({
agent: 'hermes',
worktreeId: 'wt-1',
prompt: 'run the automation'
})
expect(mockSpawn).toHaveBeenCalledWith(
expect.objectContaining({
command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'),
env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'run the automation' })
})
)
expect(mockPasteDraftWhenAgentReady).not.toHaveBeenCalled()
})
it('uses the configured cmd shell for Windows Hermes background launches', async () => {
mockGetAgentLaunchPlatformForRepo.mockReturnValue('win32')
Object.assign(state.settings, { terminalWindowsShell: 'cmd.exe' })
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
await launchAgentBackgroundSession({
agent: 'hermes',
worktreeId: 'wt-1',
prompt: 'run the automation'
})
expect(mockSpawn).toHaveBeenCalledWith(
expect.objectContaining({
command: expect.stringContaining('powershell.exe -NoProfile -EncodedCommand'),
env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'run the automation' })
})
)
})
it('forwards Hermes startup queries through SSH command transport', async () => {
state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }]
const { launchAgentBackgroundSession } = await import('./launch-agent-background-session')
await launchAgentBackgroundSession({
agent: 'hermes',
worktreeId: 'wt-1',
prompt: 'remote automation prompt'
})
expect(mockSpawn).toHaveBeenCalledWith(
expect.objectContaining({
command: expect.stringContaining('ORCA_HERMES_STARTUP_QUERY'),
connectionId: 'ssh-1',
env: expect.objectContaining({ ORCA_HERMES_STARTUP_QUERY: 'remote automation prompt' })
})
)
})
it('injects fast startup commands into SSH background sessions after shell output arrives', async () => {
vi.useFakeTimers()
try {
@@ -38,14 +38,8 @@ import type { RuntimeTerminalCreate } from '../../../shared/runtime-types'
import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery'
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler'
function runBestEffortCleanup(action: () => void): void {
try {
action()
} catch {
// Preserve the launch/setup error that triggered cleanup.
}
}
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
import { runBestEffortAgentBackgroundCleanups } from '@/lib/agent-background-session-cleanup'
export async function launchAgentBackgroundSession(
args: LaunchAgentBackgroundSessionArgs
@@ -80,6 +74,11 @@ export async function launchAgentBackgroundSession(
// Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
// `orca-ide` rename must not be applied for remote launches.
const isRemote = repo ? repoIsRemote(repo) : false
const startupShell = resolveLocalWindowsAgentStartupShell({
platform: launchPlatform,
isRemote,
terminalWindowsShell: store.settings?.terminalWindowsShell
})
const trimmedPrompt = prompt?.trim() ?? ''
const hasPrompt = trimmedPrompt.length > 0
const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
@@ -92,6 +91,7 @@ export async function launchAgentBackgroundSession(
agentArgs,
agentEnv,
platform: launchPlatform,
shell: startupShell,
isRemote,
allowEmptyPromptLaunch: !hasPrompt || isFollowupPath
})
@@ -311,12 +311,11 @@ export async function launchAgentBackgroundSession(
// Why: terminal creation and stream subscription are separate remote calls.
// A failure between them must not strand an invisible runtime terminal.
exitHandled = true
runBestEffortCleanup(unsubscribeExit)
runBestEffortCleanup(unsubscribeData)
runBestEffortCleanup(() => eagerPtyBuffer?.dispose())
runBestEffortCleanup(() => sshStartupDelivery.clear())
runBestEffortCleanup(() => store.clearTabPtyId(tab.id, ptyId))
runBestEffortCleanup(() => store.clearAgentLaunchConfig(paneKey))
runBestEffortAgentBackgroundCleanups(unsubscribeExit, unsubscribeData)
runBestEffortAgentBackgroundCleanups(() => eagerPtyBuffer?.dispose())
runBestEffortAgentBackgroundCleanups(() => sshStartupDelivery.clear())
runBestEffortAgentBackgroundCleanups(() => store.clearTabPtyId(tab.id, ptyId))
runBestEffortAgentBackgroundCleanups(() => store.clearAgentLaunchConfig(paneKey))
if (ptyId) {
try {
if (runtimeTarget.kind === 'environment' && runtimeTerminalHandle) {
@@ -330,7 +329,7 @@ export async function launchAgentBackgroundSession(
// Best-effort close; retiring the invalid hidden tab must still proceed.
}
}
runBestEffortCleanup(() => store.closeTab(tab.id, { recordInteraction: false }))
runBestEffortAgentBackgroundCleanups(() => store.closeTab(tab.id, { recordInteraction: false }))
throw error
}
}
@@ -4,7 +4,8 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { openMobileEmulatorTab } from './open-mobile-emulator-tab'
import {
consumePrelaunchedSimulatorSession,
isManualSimulatorLaunchPending
isManualSimulatorLaunchPending,
resetSimulatorLaunchCoordinationForTests
} from './simulator-launch-coordination'
import { cancelPendingSimulatorPaneShutdown } from './simulator-pane-shutdown-scheduler'
import { ensureSimulatorTab, getSimulatorTabForWorktree } from './ensure-simulator-tab'
@@ -62,7 +63,7 @@ describe('openMobileEmulatorTab', () => {
vi.mocked(getSimulatorTabForWorktree).mockReset()
vi.mocked(getSimulatorTabForWorktree).mockReturnValue(null)
vi.mocked(toast.error).mockReset()
consumePrelaunchedSimulatorSession('wt-1')
resetSimulatorLaunchCoordinationForTests()
})
afterEach(() => {
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { EmulatorStreamInfo } from '@/components/emulator-pane/emulator-pane-types'
import {
consumePrelaunchedSimulatorSession,
getPrelaunchedSimulatorSessionCountForTests,
rememberPrelaunchedSimulatorSession,
resetSimulatorLaunchCoordinationForTests
} from './simulator-launch-coordination'
function streamInfo(id: string): EmulatorStreamInfo {
return {
deviceUdid: id,
streamUrl: `http://127.0.0.1:3100/${id}/stream.mjpeg`,
wsUrl: `ws://127.0.0.1:3100/${id}/ws`
}
}
describe('simulator-launch-coordination', () => {
beforeEach(() => {
resetSimulatorLaunchCoordinationForTests()
vi.useFakeTimers()
vi.setSystemTime(1_000)
})
afterEach(() => {
resetSimulatorLaunchCoordinationForTests()
vi.useRealTimers()
})
it('expires unconsumed sessions at the TTL boundary', () => {
rememberPrelaunchedSimulatorSession('wt-fresh', streamInfo('device-fresh'))
rememberPrelaunchedSimulatorSession('wt-stale', streamInfo('device-stale'))
vi.advanceTimersByTime(29_999)
expect(consumePrelaunchedSimulatorSession('wt-fresh')).toMatchObject({
deviceUdid: 'device-fresh'
})
vi.advanceTimersByTime(1)
expect(consumePrelaunchedSimulatorSession('wt-stale')).toBeNull()
expect(getPrelaunchedSimulatorSessionCountForTests()).toBe(0)
})
it('measures handoff age independently of wall-clock corrections', () => {
vi.setSystemTime(100_000)
rememberPrelaunchedSimulatorSession('wt-rollback', streamInfo('device-rollback'))
vi.setSystemTime(0)
vi.advanceTimersByTime(30_000)
expect(consumePrelaunchedSimulatorSession('wt-rollback')).toBeNull()
rememberPrelaunchedSimulatorSession('wt-forward', streamInfo('device-forward'))
vi.setSystemTime(1_000_000)
expect(consumePrelaunchedSimulatorSession('wt-forward')).toMatchObject({
deviceUdid: 'device-forward'
})
})
it('caps prelaunched simulator sessions by oldest worktree key', () => {
for (let index = 0; index < 16; index += 1) {
rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`))
}
rememberPrelaunchedSimulatorSession('wt-16', streamInfo('device-16'))
expect(consumePrelaunchedSimulatorSession('wt-0')).toBeNull()
expect(consumePrelaunchedSimulatorSession('wt-1')).toMatchObject({
deviceUdid: 'device-1'
})
expect(consumePrelaunchedSimulatorSession('wt-16')).toMatchObject({
deviceUdid: 'device-16'
})
})
it('treats a refreshed worktree key as the most recently remembered', () => {
for (let index = 0; index < 16; index += 1) {
rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`))
}
rememberPrelaunchedSimulatorSession('wt-0', streamInfo('device-refreshed'))
rememberPrelaunchedSimulatorSession('wt-16', streamInfo('device-16'))
expect(consumePrelaunchedSimulatorSession('wt-1')).toBeNull()
expect(consumePrelaunchedSimulatorSession('wt-0')).toMatchObject({
deviceUdid: 'device-refreshed'
})
expect(consumePrelaunchedSimulatorSession('wt-16')).toMatchObject({
deviceUdid: 'device-16'
})
})
it('stays bounded through prolonged unconsumed-session churn', () => {
for (let index = 0; index < 5_000; index += 1) {
rememberPrelaunchedSimulatorSession(`wt-${index}`, streamInfo(`device-${index}`))
expect(getPrelaunchedSimulatorSessionCountForTests()).toBeLessThanOrEqual(16)
}
expect(consumePrelaunchedSimulatorSession('wt-4983')).toBeNull()
expect(consumePrelaunchedSimulatorSession('wt-4984')).toMatchObject({
deviceUdid: 'device-4984'
})
expect(consumePrelaunchedSimulatorSession('wt-4999')).toMatchObject({
deviceUdid: 'device-4999'
})
})
})
@@ -4,7 +4,31 @@ export const EMULATOR_MANUAL_LAUNCH_STARTED_EVENT = 'orca:emulator-launch-starte
export const EMULATOR_MANUAL_LAUNCH_FAILED_EVENT = 'orca:emulator-launch-failed'
const manualLaunchesByWorktree = new Set<string>()
const prelaunchedSessionsByWorktree = new Map<string, EmulatorStreamInfo>()
type PrelaunchedSimulatorSession = {
info: EmulatorStreamInfo
rememberedAt: number
}
// Why: prelaunch info is a short-lived handoff to the pane; if the pane never
// consumes it, stale stream URLs should not stick to removed worktree ids.
const PRELAUNCHED_SIMULATOR_SESSION_TTL_MS = 30_000
const PRELAUNCHED_SIMULATOR_SESSION_MAX = 16
const prelaunchedSessionsByWorktree = new Map<string, PrelaunchedSimulatorSession>()
function prunePrelaunchedSimulatorSessions(now = performance.now()): void {
for (const [worktreeId, entry] of prelaunchedSessionsByWorktree) {
if (now - entry.rememberedAt >= PRELAUNCHED_SIMULATOR_SESSION_TTL_MS) {
prelaunchedSessionsByWorktree.delete(worktreeId)
}
}
while (prelaunchedSessionsByWorktree.size > PRELAUNCHED_SIMULATOR_SESSION_MAX) {
const oldest = prelaunchedSessionsByWorktree.keys().next().value
if (oldest === undefined) {
return
}
prelaunchedSessionsByWorktree.delete(oldest)
}
}
export function beginManualSimulatorLaunch(worktreeId: string): void {
manualLaunchesByWorktree.add(worktreeId)
@@ -25,15 +49,33 @@ export function rememberPrelaunchedSimulatorSession(
if (!info?.streamUrl && !info?.wsUrl) {
return
}
prelaunchedSessionsByWorktree.set(worktreeId, info)
// Why: the TTL measures elapsed handoff age, so wall-clock corrections must
// not resurrect stale stream metadata or discard a fresh handoff.
const rememberedAt = performance.now()
prelaunchedSessionsByWorktree.delete(worktreeId)
prelaunchedSessionsByWorktree.set(worktreeId, {
info,
rememberedAt
})
prunePrelaunchedSimulatorSessions(rememberedAt)
}
export function consumePrelaunchedSimulatorSession(worktreeId: string): EmulatorStreamInfo | null {
const info = prelaunchedSessionsByWorktree.get(worktreeId) ?? null
prunePrelaunchedSimulatorSessions()
const info = prelaunchedSessionsByWorktree.get(worktreeId)?.info ?? null
prelaunchedSessionsByWorktree.delete(worktreeId)
return info
}
export function resetSimulatorLaunchCoordinationForTests(): void {
manualLaunchesByWorktree.clear()
prelaunchedSessionsByWorktree.clear()
}
export function getPrelaunchedSimulatorSessionCountForTests(): number {
return prelaunchedSessionsByWorktree.size
}
export function dispatchManualSimulatorLaunchStarted(worktreeId: string): void {
dispatchManualSimulatorLaunchEvent(EMULATOR_MANUAL_LAUNCH_STARTED_EVENT, { worktreeId })
}

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